From 43f0925471f9b13503537ae83fc8855e3798185f Mon Sep 17 00:00:00 2001 From: PlotBird Date: Mon, 19 Oct 2026 04:47:53 +0300 Subject: [PATCH 1/3] ci: analyse the sources with CodeQL and keep actions current CodeQL runs on every push and pull request, and once a week so a new query pack reaches the code without a commit. The interpreter's sources are analysed directly; the vendored libraries and the test harness are excluded because their maintainers track them. Dependabot watches the workflow actions and the single build dependency. An outdated action runs with the repository's token, which is the part of a workflow that ages badly. --- .github/codeql-config.yml | 8 ++++++++ .github/dependabot.yml | 24 +++++++++++++++++++++++ .github/workflows/codeql.yml | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 .github/codeql-config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml 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..77f2e86 --- /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@v5 + + - uses: github/codeql-action/init@v3 + 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@v3 + with: + category: '/language:php' From 665a15a0d43f09734fb12482511ed07b5576d902 Mon Sep 17 00:00:00 2001 From: PlotBird Date: Mon, 19 Oct 2026 04:47:53 +0300 Subject: [PATCH 2/3] test: cover authorisation boundaries and input limits Four checks an attacker would try first, so they keep running instead of living in a report: a client cannot mint a share link for a website it cannot see, a client cannot create a goal, a revoked token stops authorising the API as well as the page, and the tracker refuses a body larger than it accepts. --- tests/e2e.php | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) 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'); From 5216dd1853ac029620921c759c4cb342e80e7337 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:49:25 +0000 Subject: [PATCH 3/3] build(deps): bump the actions group with 4 updates Bumps the actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [github/codeql-action](https://github.com/github/codeql-action), [actions/setup-node](https://github.com/actions/setup-node) and [actions/upload-artifact](https://github.com/actions/upload-artifact). Updates `actions/checkout` from 5 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v7) Updates `github/codeql-action` from 3 to 4 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) Updates `actions/setup-node` from 5 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v5...v7) Updates `actions/upload-artifact` from 4 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/tests.yml | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 77f2e86..71991cf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,14 +24,14 @@ jobs: security-events: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v3 + - 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@v3 + - 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: |