Skip to content

feat(server): TRACKER_ALLOWED_ORIGINS origin allowlist for tracking - #252

Open
rbonestell wants to merge 12 commits into
benvinegar:mainfrom
rbonestell:feat/tracker-allowed-origins
Open

rbonestell wants to merge 12 commits into
benvinegar:mainfrom
rbonestell:feat/tracker-allowed-origins

Conversation

@rbonestell

@rbonestell rbonestell commented May 28, 2026 •

Copy link
Copy Markdown

Overview

Stacked PR: depends on #251. See the note below.

Adds an opt-in origin allowlist for tracking (TRACKER_ALLOWED_ORIGINS) so a Counterscale instance only records hits from sites you actually own.

Important

This PR is stacked on top of #251 (fix/custom-dataset-name-bug) and should be reviewed and merged after it.

Until #251 lands, the diff against main also includes that PR's changes (CF_DATASET_NAME, the CLI config, packages/cli/*, query.ts, arrow.ts). Those are not part of this PR. The changes that belong here are:

  • packages/server/app/lib/allowedOrigins.ts (+ tests)
  • packages/server/app/analytics/collect.ts (+ tests)
  • packages/server/worker-configuration.d.ts, .dev.vars.example
  • README.md

To review only this PR's changes: git diff <#251 head>..feat/tracker-allowed-origins, or wait for #251 to merge and this diff will shrink to the list above.

Changes by Package

@counterscale/cli

No changes in this PR. (CLI/dataset changes visible in the diff belong to #251.)

@counterscale/server

Origin allowlist for tracking (TRACKER_ALLOWED_ORIGINS) — set a comma-separated list of domains and the instance only records pageviews originating from those domains (and their subdomains):

TRACKER_ALLOWED_ORIGINS=example.com,myblog.io,acme.dev
  • Matching is host-based and covers subdomains automatically: a single entry like example.com matches example.com and all of its subdomains (blog.example.com, app.example.com, …) — you don't list subdomains separately — but not sibling domains like notexample.com. Entries may be bare hosts, scheme-prefixed (https://example.com), or bracketed IPv6 ([::1]); all normalize to the host.
  • Enforced at /collect (where data is recorded). When the var is set, a hit whose reported host and Origin/Referer don't resolve to an allowed domain is dropped — the endpoint still returns the normal 1×1 gif (200) but writes no datapoint, and a warning is logged so dropped traffic is visible in Worker logs. The analytics referrer (the visitor's traffic source) is deliberately not validated.
  • Opt-in. Unset (or *) means no enforcement; all origins are recorded, preserving today's behavior. The var is intentionally not declared in wrangler.json so it can be set as a Worker secret that survives CLI/wrangler redeploys (dashboard plain-text vars get overwritten on deploy).
  • New app/lib/allowedOrigins.ts (parse / host-extract / match helpers) plus TRACKER_ALLOWED_ORIGINS wiring in the /collect handler and type definitions.

Tests — new unit + integration coverage for origin parsing/matching (exact, subdomain, sibling-rejection, userinfo, null origin, ports, IPv6 entries, * allow-all) and /collect enforcement (allow/drop, drop-path leaves Last-Modified unset, bare-host, Referer-only, opt-in). Full server suite green.

@counterscale/tracker

No changes in this PR.

Other Changes

  • README.md: documents the variable, that it should be set as a secret, the drop logging, and the <link rel="canonical"> cross-domain caveat.
  • .dev.vars.example: adds TRACKER_ALLOWED_ORIGINS.

Additional Notes

  • Threat model. Enforcement is best-effort: Origin, Referer, and the reported host are client-supplied and can be forged by a non-browser client, so it stops accidental/casual cross-site recording and honest browsers, not a determined attacker. Data is partitioned by site ID, which limits the blast radius. Hardening includes rejecting userinfo-bearing values (e.g. https://evil.com@example.com/), the opaque null origin, and treating a lone * as allow-all rather than silently blocking everything.
  • Backward compatible. TRACKER_ALLOWED_ORIGINS is unset by default; existing deployments are unaffected until it's set.

…ker.js as ACAO *

CORS cannot gate who loads a <script> or POSTs to the worker, so the
per-origin ACAO echo on tracker.js enforced nothing. Move real allowlist
enforcement to /collect, where data is actually recorded.

- tracker.js now returns Access-Control-Allow-Origin: * unconditionally
  (satisfies SRI/crossorigin, drops Vary:Origin cache fragmentation).
- /collect drops (200 gif, no datapoint write) hits whose host (h) or
  Origin/Referer headers aren't an allowed origin; opt-in via the env var.
  The analytics referrer param (r) is intentionally not validated.
- New app/lib/allowedOrigins.ts: parseAllowedOrigins / extractHost /
  isHostAllowed (exact + subdomain matching).

Hardening from review:
- "*" entry => allow-all (empty list) instead of silently blocking all.
- extractHost rejects userinfo (https://evil.com@pmux.io/), the opaque
  "null" origin, and hostless schemes; robust bare-host/port/IPv6 parse.

Adds 12 tests (lib + collect) incl. drop-path Last-Modified guard,
bare-host e2e, Referer-only, empty-list, and "*" allow-all.
@rbonestell rbonestell changed the title feat(server): Configurable Access-Control-Allow-Origin response header for tracker.js feat(server): TRACKER_ALLOWED_ORIGINS origin allowlist + configurable Analytics Engine dataset May 29, 2026
@rbonestell rbonestell changed the title feat(server): TRACKER_ALLOWED_ORIGINS origin allowlist + configurable Analytics Engine dataset feat(server): TRACKER_ALLOWED_ORIGINS origin allowlist for tracking May 29, 2026
@rbonestell
rbonestell force-pushed the feat/tracker-allowed-origins branch from 6d8b2e6 to 044290f Compare May 29, 2026 03:07
@rbonestell
rbonestell marked this pull request as ready for review August 12, 2026 23:08
@stordahl stordahl self-assigned this Sep 11, 2026

@stordahl stordahl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The allowedOrigins implementation is careful (userinfo rejection, null origin, sibling-domain rejection) and well-tested, but requesting changes:

Blocker: CI will fail. $script.ts imports app/tracker/tracker.js?raw, so $script.test.tsx fails with ENOENT unless copytracker ran first. Turbo's test/test-ci tasks don't depend on copytracker, and CI's command (turbo build lint typecheck test-ci --concurrency=1) schedules server#test-ci before the tracker build (reproduced locally). Fix in turbo.json: add dependsOn: ["@counterscale/server#copytracker"] to the server's test and test-ci tasks.

Concerns:

  1. Fail-open on redeploy — README says set the var via dashboard, but wrangler deploys overwrite dashboard-set vars with wrangler.json's "". A later CLI redeploys the allowlist away silently. Warn in the README or support it in the CLI.
  2. Silent drops — no log on drop; combined with h following <link rel="canonical"> (cross-domain canonicals get legit traffic dropped), this invites "my dashboard is empty" reports. Log on drop and document the canonical caveat.
  3. Perf regression — /tracker.js was edge-served as a static asset; now it invokes the worker on every pageview. Consider keeping the asset path.
  4. Please split the PR — the tracker inlining is orthogonal to the allowlist; separate them for independent review/revert.

Nits: IPv6 entries can't work (normalizeEntry splits on :); add TRACKER_ALLOWED_ORIGINS to .dev.vars.example; revert the package.json reformat that inflates the diff.

…atic asset

The ?raw import made server tests depend on copytracker having run
(CI ordering failure) and moved every /tracker.js request into the
worker. Restore the ASSETS-backed route from main; the inlining can be
proposed separately. Also restores package.json formatting and drops
the empty TRACKER_ALLOWED_ORIGINS var from wrangler.json so the value
can be set as a secret that survives redeploys.
- log a warning on every dropped hit instead of failing silently
- normalize allowlist entries via extractHost so bracketed IPv6 works
- document setting the var as a secret (dashboard vars are overwritten
  on redeploy) and the <link rel=canonical> cross-domain caveat
- add TRACKER_ALLOWED_ORIGINS to .dev.vars.example; type it optional
@rbonestell

Copy link
Copy Markdown
Author

Addressed in 7366e70 and a05f2b4:

  • CI blocker / perf regression / split PR — reverted the tracker inlining entirely. $script.ts is back to the ASSETS.fetch implementation from main, so /tracker.js stays edge-served and the test suite no longer depends on copytracker. The inlining can come as its own PR if it's still wanted. This also restores the original package.json formatting.
  • Fail-open on redeploy — removed "TRACKER_ALLOWED_ORIGINS": "" from wrangler.json (the CLI merges that vars block into its deploy config, so it would have clobbered a dashboard value). README now says to set it as a Worker secret (dashboard "Secret" type or wrangler secret put), which survives redeploys, and explains why a plain-text dashboard var does not.
  • Silent drops — /collect now console.warns on every dropped hit with the site id, h, Origin, and Referer. README mentions the logs and documents the <link rel="canonical"> cross-domain caveat.
  • IPv6 entries — normalizeEntry now reuses extractHost (URL parsing) instead of splitting on :, so [::1] / https://[2001:db8::1]:8080 parse correctly; test added.
  • Nits — TRACKER_ALLOWED_ORIGINS added to .dev.vars.example; package.json reformat is gone with the revert.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants