Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Paths that are not part of the application a visitor runs: the test suite, the
# developer tooling and the vendored libraries. Third-party code is tracked by
# Dependabot and by its own maintainers, not by these queries.
paths-ignore:
- tests
- .dev
- assets/vendor
- assets/css
24 changes: 24 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
version: 2

updates:
# The workflows use actions published by GitHub and by the community; a
# compromised or outdated action runs with the repository's token.
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
day: 'monday'
open-pull-requests-limit: 3
groups:
actions:
patterns:
- '*'

# Tailwind is the only dependency, and it only builds the CSS. An update that
# changes the output would be caught by the css job, which rebuilds and diffs.
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
day: 'monday'
open-pull-requests-limit: 3
37 changes: 37 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CodeQL

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Weekly, so a new query pack reaches the code without a commit.
- cron: '23 4 * * 1'

concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true

jobs:
analyze:
name: Analyze PHP
runs-on: ubuntu-22.04
timeout-minutes: 20
permissions:
actions: read
contents: read
security-events: write

steps:
- uses: actions/checkout@v7

- uses: github/codeql-action/init@v4
with:
languages: php
# The project has no build step: the queries run on the sources.
config-file: ./.github/codeql-config.yml

- uses: github/codeql-action/analyze@v4
with:
category: '/language:php'
18 changes: 9 additions & 9 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.0'
Expand Down Expand Up @@ -50,8 +50,8 @@ jobs:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '20'
cache: npm
Expand Down Expand Up @@ -102,7 +102,7 @@ jobs:
PB_TEST_DB_PORT: '3306'
PB_TEST_PORT: '8099'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
Expand Down Expand Up @@ -133,7 +133,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the application logs on failure
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: app-logs-php${{ matrix.php }}
path: |
Expand Down Expand Up @@ -169,7 +169,7 @@ jobs:
PB_TEST_DB_PORT: '3306'
PB_TEST_PORT: '8099'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
Expand Down Expand Up @@ -198,7 +198,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the application logs on failure
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: app-logs-mariadb
path: |
Expand Down Expand Up @@ -241,7 +241,7 @@ jobs:
PB_TEST_DB_PORT: '3306'
PB_TEST_PORT: '8099'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
Expand Down Expand Up @@ -270,7 +270,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the application logs on failure
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: app-logs-geoip
path: |
Expand Down
43 changes: 43 additions & 0 deletions tests/e2e.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,18 @@
$response = $client->request('POST', '/api/collect.php?json=1', [], (string) json_encode(['w' => 'not-a-uuid', 'p' => '/']), ['Origin' => 'https://example.com']);
Result::ok($response['status'] === 400, 'invalid website id is rejected', 'status=' . $response['status']);

// The tracker accepts a small JSON body. Anything larger is refused before it is
// parsed, so a hostile client cannot make the server buffer megabytes.
$response = $client->request(
'POST',
'/api/collect.php?json=1',
[],
str_repeat('x', 5000),
['Origin' => 'https://example.com'],
'203.0.113.7'
);
Result::ok($response['status'] === 413, 'an oversized tracker payload is refused', 'status=' . $response['status']);

$pageviews = (int) $pdo->query('SELECT COUNT(*) FROM ' . DB_PREFIX . 'pageviews')->fetchColumn();
$visits = (int) $pdo->query('SELECT COUNT(*) FROM ' . DB_PREFIX . 'visits')->fetchColumn();
$paths = $pdo->query('SELECT id, path, country FROM ' . DB_PREFIX . 'pageviews ORDER BY id ASC')->fetchAll();
Expand Down Expand Up @@ -526,8 +538,12 @@
$linkId = (int) $pdo->query('SELECT id FROM ' . DB_PREFIX . 'share_links ORDER BY id DESC LIMIT 1')->fetchColumn();
$response = $client->request('POST', '/sites/share/revoke', ['_token' => $token, 'site_id' => (string) $siteId, 'link_id' => (string) $linkId]);
Result::ok($response['status'] === 302, 'share link revoked');

// Revocation has to take effect immediately: the token is still in the wild.
$response = $public->request('GET', $sharePath);
Result::contains($response['body'], 'Link not available', 'a revoked link stops working');
$response = $public->request('GET', '/api/stats.php?share=' . basename($sharePath) . '&preset=last7');
Result::ok($response['status'] === 403, 'a revoked token no longer authorises the API', 'status=' . $response['status']);

// A revoked link can be deleted, otherwise the panel fills up with dead rows.
$response = $client->request('GET', '/sites?site=' . $siteId);
Expand Down Expand Up @@ -585,6 +601,33 @@
$response = $clientSession->request('GET', '/users');
Result::ok($response['status'] === 403, 'client cannot open user management', 'status=' . $response['status']);

// Read access is not the only thing worth refusing: a client must not be able to
// mint a public link for a website it cannot see, because that link would leak
// the statistics to anybody who receives it.
$response = $clientSession->request('GET', '/sites?site=' . $siteId);
$clientToken = csrf($response['body']);
$before = (int) $pdo->query('SELECT COUNT(*) FROM ' . DB_PREFIX . 'share_links WHERE site_id = ' . $otherSite)->fetchColumn();

$response = $clientSession->request('POST', '/sites/share', [
'_token' => $clientToken,
'site_id' => (string) $otherSite,
'label' => 'foreign-attempt',
]);

$after = (int) $pdo->query('SELECT COUNT(*) FROM ' . DB_PREFIX . 'share_links WHERE site_id = ' . $otherSite)->fetchColumn();
Result::ok($after === $before, 'a client cannot create a share link for a foreign website', 'before=' . $before . ' after=' . $after);

// The same attempt against goals: only an administrator configures them.
$response = $clientSession->request('POST', '/sites/goals', [
'_token' => $clientToken,
'site_id' => (string) $siteId,
'name' => 'client-goal',
'type' => 'event',
'match_value' => 'signup',
]);
$clientGoals = (int) $pdo->query('SELECT COUNT(*) FROM ' . DB_PREFIX . 'goals WHERE name = "client-goal"')->fetchColumn();
Result::ok($clientGoals === 0, 'a client cannot create a goal', 'goals=' . $clientGoals);

echo "\n[18] Settings and maintenance\n";
$response = $client->request('GET', '/settings');
Result::ok($response['status'] === 200, 'settings page renders');
Expand Down
Loading