diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 0000000..cbef16a --- /dev/null +++ b/.github/codeql-config.yml @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ba15705 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..71991cf --- /dev/null +++ b/.github/workflows/codeql.yml @@ -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' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfdd8a2..6f6e51d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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' @@ -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 @@ -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 }} @@ -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: | @@ -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' @@ -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: | @@ -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' @@ -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: | diff --git a/tests/e2e.php b/tests/e2e.php index c746b9d..e5ec1dd 100644 --- a/tests/e2e.php +++ b/tests/e2e.php @@ -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(); @@ -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); @@ -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');