diff --git a/README.md b/README.md index dedd7bdf..4e815f35 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,29 @@ Counterscale.trackPageview(); The deployment URL can always be changed to go behind a custom domain you own. [More here](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/). +### Allowed Origins: Restricting Which Sites Can Report + +By default, a Counterscale deployment records hits from any site that loads your tracker (or posts to `/collect`). To limit recording to specific domains, set the `TRACKER_ALLOWED_ORIGINS` environment variable on the Cloudflare Worker to a comma-separated list of allowed domains: + +``` +TRACKER_ALLOWED_ORIGINS=example.com,myblog.io,acme.dev +``` + +- Each entry matches the specified domain **and any of its subdomains**, you only need the parent domain. `example.com` already covers `blog.example.com`, `app.example.com`, and any other subdomain, so there's no need to list them separately. Lookalikes such as `notexample.com` are not matched. Entries may be bare hostnames or include a scheme (`https://example.com`), both are treated the same. +- When set, hits whose origin isn't on the list are ignored: the tracker still returns a normal response, but no data is recorded. Each dropped hit is logged as a warning in the Worker's logs (Workers & Pages → your worker → Logs), so if your dashboard looks empty after enabling this, check there first. +- Leave it unset (the default) or set it to `*` to disable the allowlist and record from any origin. + +Set it as a **secret** on the Worker so it survives redeploys. Plain-text variables added in the Cloudflare dashboard are overwritten the next time the worker is deployed (via the CLI or `wrangler deploy`), which would silently turn the allowlist back off. Either: + +- **Cloudflare dashboard:** Workers & Pages → Counterscale worker → Settings → Variables and Secrets → Add, choose type **Secret**, name `TRACKER_ALLOWED_ORIGINS`. +- **From source:** `npx wrangler secret put TRACKER_ALLOWED_ORIGINS` in `packages/server`. + +> [!WARNING] +> The tracker reports the page's `` URL as the hostname when one is present. If your pages declare a canonical URL on a different domain, that domain must also be in the allowlist or those hits will be dropped. + +> [!NOTE] +> This is a best-effort filter. The signals it checks (`Origin`, `Referer`, and the reported hostname) are supplied by the client and can be spoofed by non-browser tools, so it deters casual or accidental cross-site reporting rather than a determined attacker. Recorded data is also partitioned by site ID, which further limits the impact of unwanted hits. + ## CLI Commands Counterscale provides a command-line interface (CLI) to help you install, configure, and manage your deployment. diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index a98bc3eb..d4754a9a 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -218,6 +218,11 @@ export async function install( // If --advanced is true, prompt the user for worker name and analytics dataset name. // Otherwise, stick to the default values read from the server package. if (opts.advanced) { + log.warn( + "If you previously installed with a custom worker name or analytics dataset, " + + "re-enter the same values below — accepting the defaults will repoint your " + + "deployment at a fresh dataset and your existing analytics will appear empty.", + ); ({ workerName, analyticsDataset } = await promptProjectConfig( workerName, analyticsDataset, diff --git a/packages/cli/src/lib/__tests__/config.test.ts b/packages/cli/src/lib/__tests__/config.test.ts index 647cbe0f..af152617 100644 --- a/packages/cli/src/lib/__tests__/config.test.ts +++ b/packages/cli/src/lib/__tests__/config.test.ts @@ -254,7 +254,7 @@ describe("CLI Functions", () => { "/target/wrangler.json", initialConfig, "new-worker", - "new-dataset", + "newDataset", ); // Verify writeFileSync was called with the correct arguments @@ -271,8 +271,11 @@ describe("CLI Functions", () => { // Verify worker name and dataset were updated expect(writtenConfig.name).toBe("new-worker"); expect(writtenConfig.analytics_engine_datasets[0].dataset).toBe( - "new-dataset", + "newDataset", ); + // Verify CF_DATASET_NAME var was set so the dashboard's SQL + // read path uses the same dataset as the AE binding. + expect(writtenConfig.vars.CF_DATASET_NAME).toBe("newDataset"); // Verify paths were made absolute expect(writtenConfig.build.cwd).toMatch(/^\//); // Should start with / @@ -294,7 +297,7 @@ describe("CLI Functions", () => { "/target/wrangler.json", initialConfig, "new-worker", - "new-dataset", + "newDataset", accountId, ); @@ -319,7 +322,7 @@ describe("CLI Functions", () => { "/target/wrangler.json", initialConfig, "new-worker", - "new-dataset", + "newDataset", ); const writtenConfig = JSON.parse( diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 61e04866..728cfb1e 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -123,6 +123,11 @@ export function readInitialServerConfig() { * converted to be absolute. This makes it so that the `wrangler deploy` command can be * run from any directory. */ +// Mirrors the validation in AnalyticsEngineAPI's constructor — the dataset +// name is interpolated into raw SQL on the read path, and only matching names +// will pass the server-side guard. +export const DATASET_NAME_PATTERN = /^[A-Za-z0-9_]+$/; + export async function stageDeployConfig( targetPath: string, initialDeployConfig: ReturnType, @@ -130,6 +135,12 @@ export async function stageDeployConfig( analyticsDataset: string, accountId?: string, ): Promise { + if (!DATASET_NAME_PATTERN.test(analyticsDataset)) { + throw new Error( + `Invalid Analytics Engine dataset name: ${analyticsDataset}. Only letters, digits, and underscores are allowed.`, + ); + } + const serverPkgDir = getServerPkgDir(); const outDeployConfig = makePathsAbsolute( @@ -138,6 +149,10 @@ export async function stageDeployConfig( ); outDeployConfig.name = workerName; outDeployConfig.analytics_engine_datasets[0].dataset = analyticsDataset; + outDeployConfig.vars = { + ...(outDeployConfig.vars ?? {}), + CF_DATASET_NAME: analyticsDataset, + }; if (accountId) { outDeployConfig.account_id = accountId; diff --git a/packages/server/.dev.vars.example b/packages/server/.dev.vars.example index 8946785f..512e0aa8 100644 --- a/packages/server/.dev.vars.example +++ b/packages/server/.dev.vars.example @@ -6,3 +6,5 @@ CF_PASSWORD_HASH='' CF_JWT_SECRET='' CF_AUTH_ENABLED='' CF_STORAGE_ENABLED='' +CF_DATASET_NAME='' +TRACKER_ALLOWED_ORIGINS='' diff --git a/packages/server/app/analytics/__tests__/collect.test.ts b/packages/server/app/analytics/__tests__/collect.test.ts index c70f0f6d..8b262f0f 100644 --- a/packages/server/app/analytics/__tests__/collect.test.ts +++ b/packages/server/app/analytics/__tests__/collect.test.ts @@ -427,3 +427,151 @@ describe("collectRequestHandler", () => { expect(blobs[14]).toBe(""); // utm_content (empty) }); }); + +describe("collectRequestHandler allowlist enforcement", () => { + const UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"; + + function buildRequest(opts: { + h: string; + r?: string; + origin?: string; + referer?: string; + }) { + const headers: Record = { "user-agent": UA }; + if (opts.origin) headers["origin"] = opts.origin; + if (opts.referer) headers["referer"] = opts.referer; + return { + method: "GET", + url: + "https://example.com/collect?" + + new URLSearchParams({ + sid: "example", + h: opts.h, + p: "/", + r: opts.r ?? "", + ht: "1", + }).toString(), + headers: { + get: (header: string) => headers[header], + }, + }; + } + + function makeEnv(allowed?: string) { + return { + WEB_COUNTER_AE: { writeDataPoint: vi.fn() }, + TRACKER_ALLOWED_ORIGINS: allowed, + } as unknown as Env; + } + + test("writes when h matches a listed origin via subdomain", () => { + const env = makeEnv("pmux.io"); + collectRequestHandler( + buildRequest({ h: "https://docs.pmux.io" }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); + + test("silently drops (200 gif, no write) when h is not allowed", () => { + const env = makeEnv("pmux.io"); + const response = collectRequestHandler( + buildRequest({ h: "https://evil.com" }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/gif"); + // Drop path must NOT set Last-Modified: the tracker uses it for + // cookieless visit counting, and updating it on a dropped hit would + // corrupt session state. + expect(response.headers.get("Last-Modified")).toBeNull(); + }); + + test("writes when h is a bare hostname (no scheme) that is allowed", () => { + const env = makeEnv("pmux.io"); + collectRequestHandler(buildRequest({ h: "docs.pmux.io" }) as any, env); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); + + test("writes when only the Referer header is present and allowed", () => { + const env = makeEnv("pmux.io"); + collectRequestHandler( + buildRequest({ + h: "https://docs.pmux.io", + referer: "https://docs.pmux.io/guide", + }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); + + test("does not drop legit traffic from an opaque 'null' Origin", () => { + // Sandboxed iframes send Origin: null; this must not block an + // otherwise-allowed hit. + const env = makeEnv("pmux.io"); + collectRequestHandler( + buildRequest({ h: "https://docs.pmux.io", origin: "null" }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); + + test("'*' in the allowlist disables enforcement (allow all)", () => { + const env = makeEnv("*"); + collectRequestHandler( + buildRequest({ h: "https://anything.com" }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); + + test("drops when h is allowed but the Origin header is not", () => { + const env = makeEnv("pmux.io"); + collectRequestHandler( + buildRequest({ + h: "https://docs.pmux.io", + origin: "https://evil.com", + }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).not.toHaveBeenCalled(); + }); + + test("writes when h, Origin, and Referer headers all match", () => { + const env = makeEnv("pmux.io"); + collectRequestHandler( + buildRequest({ + h: "https://docs.pmux.io", + origin: "https://docs.pmux.io", + referer: "https://docs.pmux.io/guide", + }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); + + test("ignores the analytics referrer (r) param for enforcement", () => { + // r is the visitor's traffic source, not the embedding page; it must + // not be validated against the allowlist. + const env = makeEnv("pmux.io"); + collectRequestHandler( + buildRequest({ + h: "https://docs.pmux.io", + r: "https://google.com", + }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); + + test("writes for any host when the allowlist is unset (opt-in)", () => { + const env = makeEnv(undefined); + collectRequestHandler( + buildRequest({ h: "https://anything.com" }) as any, + env, + ); + expect(env.WEB_COUNTER_AE.writeDataPoint).toHaveBeenCalled(); + }); +}); diff --git a/packages/server/app/analytics/__tests__/query.test.ts b/packages/server/app/analytics/__tests__/query.test.ts index c21d8d6b..c56bda8c 100644 --- a/packages/server/app/analytics/__tests__/query.test.ts +++ b/packages/server/app/analytics/__tests__/query.test.ts @@ -682,3 +682,50 @@ describe("intervalToSql", () => { }); }); }); + +describe("AnalyticsEngineAPI dataset name", () => { + let fetch: Mock; + beforeEach(() => { + fetch = global.fetch = vi.fn(); + fetch.mockResolvedValue(createFetchResponse({ data: [] })); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("defaults to metricsDataset when no dataset arg is provided", () => { + const api = new AnalyticsEngineAPI("acct", "tok"); + expect(api.dataset).toBe("metricsDataset"); + }); + + test("defaults to metricsDataset when empty string is provided", () => { + const api = new AnalyticsEngineAPI("acct", "tok", ""); + expect(api.dataset).toBe("metricsDataset"); + }); + + test("uses the provided custom dataset name", () => { + const api = new AnalyticsEngineAPI("acct", "tok", "counterscaleMetrics"); + expect(api.dataset).toBe("counterscaleMetrics"); + }); + + test("rejects invalid dataset names", () => { + expect( + () => new AnalyticsEngineAPI("acct", "tok", "bad name; DROP"), + ).toThrow(/Invalid Analytics Engine dataset name/); + }); + + test("getCounts emits SQL referencing the custom dataset", async () => { + const api = new AnalyticsEngineAPI( + "acct", + "tok", + "counterscaleMetrics", + ); + await api.getCounts("site1", "7d"); + expect(fetch).toHaveBeenCalled(); + const body = fetch.mock.calls[0][1].body as string; + expect(body).toContain("FROM counterscaleMetrics"); + expect(body).not.toContain("FROM metricsDataset"); + }); +}); diff --git a/packages/server/app/analytics/collect.ts b/packages/server/app/analytics/collect.ts index 193b9cec..5048b958 100644 --- a/packages/server/app/analytics/collect.ts +++ b/packages/server/app/analytics/collect.ts @@ -1,6 +1,11 @@ import type { AnalyticsEngineDataset } from "@cloudflare/workers-types"; import { IDevice, UAParser } from "ua-parser-js"; import { maskBrowserVersion } from "~/lib/utils"; +import { + parseAllowedOrigins, + extractHost, + isHostAllowed, +} from "~/lib/allowedOrigins"; // Cookieless visitor/session tracking // Uses the approach described here: https://notes.normally.com/cookieless-unique-visitor-counts/ @@ -142,6 +147,21 @@ export function collectRequestHandler( return new Response("Missing siteId", { status: 400 }); } + // Optional allowlist enforcement. When TRACKER_ALLOWED_ORIGINS is set, drop + // any hit whose reported host or Origin/Referer header isn't an allowed + // origin. CORS can't gate the worker, so we enforce it here. + // Best-effort: these signals are client-controlled and spoofable. + const allowedOrigins = parseAllowedOrigins(env.TRACKER_ALLOWED_ORIGINS); + if ( + allowedOrigins.length > 0 && + !requestIsAllowed(request, params, allowedOrigins) + ) { + console.warn( + `Dropped hit for site "${siteId}": host "${params.h}" / origin "${request.headers.get("origin")}" / referer "${request.headers.get("referer")}" not in TRACKER_ALLOWED_ORIGINS`, + ); + return trackingGifResponse(); + } + const userAgent = request.headers.get("user-agent") || undefined; const parsedUserAgent = new UAParser(userAgent); @@ -210,7 +230,37 @@ export function collectRequestHandler( writeDataPoint(env.WEB_COUNTER_AE, data); - // encode 1x1 transparent gif + return trackingGifResponse(nextLastModifiedDate); +} + +/** + * Returns true if the request's reported host (h) and any present + * Origin/Referer header all resolve to an allowed origin. Requires at least + * one usable signal. The analytics referrer (params.r) is intentionally NOT + * checked — it's the visitor's traffic source, not the embedding page. + */ +function requestIsAllowed( + request: Request, + params: { [key: string]: string }, + allowedOrigins: string[], +): boolean { + const candidates = [ + params.h, + request.headers.get("origin"), + request.headers.get("referer"), + ]; + const hosts = candidates + .map((candidate) => extractHost(candidate)) + .filter((host): host is string => host !== null); + + return ( + hosts.length > 0 && + hosts.every((host) => isHostAllowed(host, allowedOrigins)) + ); +} + +/** Encodes the 1x1 transparent tracking gif response. */ +function trackingGifResponse(nextLastModifiedDate?: Date): Response { const gif = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; const gifData = atob(gif); const gifLength = gifData.length; diff --git a/packages/server/app/analytics/query.ts b/packages/server/app/analytics/query.ts index 5810f7eb..d66482af 100644 --- a/packages/server/app/analytics/query.ts +++ b/packages/server/app/analytics/query.ts @@ -166,9 +166,12 @@ function filtersToSql(filters: SearchFilters) { * See: https://developers.cloudflare.com/analytics/analytics-engine/sql-reference/ */ +export const DEFAULT_DATASET_NAME = "metricsDataset"; + export class AnalyticsEngineAPI { cfApiToken: string; cfAccountId: string; + dataset: string; defaultHeaders: { "content-type": string; "X-Source": string; @@ -176,10 +179,22 @@ export class AnalyticsEngineAPI { }; defaultUrl: string; - constructor(cfAccountId: string, cfApiToken: string) { + constructor( + cfAccountId: string, + cfApiToken: string, + dataset?: string, + ) { this.cfAccountId = cfAccountId; this.cfApiToken = cfApiToken; + const resolved = dataset || DEFAULT_DATASET_NAME; + if (!/^[A-Za-z0-9_]+$/.test(resolved)) { + throw new Error( + `Invalid Analytics Engine dataset name: ${resolved}`, + ); + } + this.dataset = resolved; + this.defaultUrl = `https://api.cloudflare.com/client/v4/accounts/${this.cfAccountId}/analytics_engine/sql`; this.defaultHeaders = { "content-type": "application/json;charset=UTF-8", @@ -246,7 +261,7 @@ export class AnalyticsEngineAPI { /* output as UTC */ toDateTime(_bucket, 'Etc/UTC') as bucket - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= toDateTime('${localStartTime.format("YYYY-MM-DD HH:mm:ss")}') AND timestamp < toDateTime('${localEndTime.format("YYYY-MM-DD HH:mm:ss")}') AND ${ColumnMappings.siteId} = '${siteId}' @@ -352,7 +367,7 @@ export class AnalyticsEngineAPI { SELECT SUM(_sample_interval) as count, ${ColumnMappings.newVisitor} as isVisitor, ${ColumnMappings.bounce} as isBounce - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} ${filterStr} AND ${siteIdColumn} = '${siteId}' @@ -416,7 +431,7 @@ export class AnalyticsEngineAPI { const _column = ColumnMappings[column]; const query = ` SELECT ${_column}, SUM(_sample_interval) as count - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} AND ${ColumnMappings.newVisitor} = 1 AND ${ColumnMappings.siteId} = '${siteId}' @@ -492,7 +507,7 @@ export class AnalyticsEngineAPI { ${ColumnMappings.newVisitor} as isVisitor, ${ColumnMappings.bounce} as isBounce, ${columnsStrWithAliases} - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= toDateTime('${startDateTimeSql}') AND timestamp < toDateTime('${endDateTimeSql}') GROUP BY timestamp, ${ColumnMappings.siteId}, @@ -584,7 +599,7 @@ export class AnalyticsEngineAPI { SELECT ${_column}, ${ColumnMappings.newVisitor} as isVisitor, SUM(_sample_interval) as count - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} AND ${ColumnMappings.newVisitor} = 0 AND ${ColumnMappings.siteId} = '${siteId}' @@ -889,7 +904,7 @@ export class AnalyticsEngineAPI { const query = ` SELECT SUM(_sample_interval) as count, ${ColumnMappings.siteId} as siteId - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} GROUP BY siteId ORDER BY count DESC @@ -936,7 +951,7 @@ export class AnalyticsEngineAPI { SELECT MIN(timestamp) as earliestEvent, ${ColumnMappings.bounce} as isBounce - FROM metricsDataset + FROM ${this.dataset} WHERE ${ColumnMappings.siteId} = '${siteId}' GROUP by isBounce `; diff --git a/packages/server/app/lib/__tests__/allowedOrigins.test.ts b/packages/server/app/lib/__tests__/allowedOrigins.test.ts new file mode 100644 index 00000000..87b75581 --- /dev/null +++ b/packages/server/app/lib/__tests__/allowedOrigins.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "vitest"; +import { + parseAllowedOrigins, + extractHost, + isHostAllowed, +} from "../allowedOrigins"; + +describe("parseAllowedOrigins", () => { + test("returns empty list for undefined", () => { + expect(parseAllowedOrigins(undefined)).toEqual([]); + }); + + test("returns empty list for empty string", () => { + expect(parseAllowedOrigins("")).toEqual([]); + }); + + test("splits comma-separated entries and trims whitespace", () => { + expect(parseAllowedOrigins("foo.com, bar.com ,baz.com")).toEqual([ + "foo.com", + "bar.com", + "baz.com", + ]); + }); + + test("strips scheme, port, path, and wildcard prefix; lowercases", () => { + expect( + parseAllowedOrigins( + "https://Foo.com, http://bar.com:8080/path, *.baz.com", + ), + ).toEqual(["foo.com", "bar.com", "baz.com"]); + }); + + test("keeps bracketed IPv6 entries intact (with or without a port)", () => { + expect( + parseAllowedOrigins("[::1], https://[2001:db8::1]:8080"), + ).toEqual(["[::1]", "[2001:db8::1]"]); + }); + + test("drops empty entries from trailing/duplicate commas", () => { + expect(parseAllowedOrigins("foo.com,,bar.com,")).toEqual([ + "foo.com", + "bar.com", + ]); + }); + + test("treats a lone '*' as allow-all (empty list, enforcement off)", () => { + expect(parseAllowedOrigins("*")).toEqual([]); + expect(parseAllowedOrigins("https://*")).toEqual([]); + }); + + test("drops a '*' entry but keeps real hosts alongside it", () => { + expect(parseAllowedOrigins("*, foo.com")).toEqual(["foo.com"]); + }); +}); + +describe("extractHost", () => { + test("extracts hostname from a full URL", () => { + expect(extractHost("https://docs.pmux.io")).toBe("docs.pmux.io"); + }); + + test("extracts hostname from a URL with path and query", () => { + expect(extractHost("https://docs.pmux.io/guide?x=1")).toBe( + "docs.pmux.io", + ); + }); + + test("handles a bare hostname without scheme", () => { + expect(extractHost("example.com")).toBe("example.com"); + }); + + test("lowercases the host", () => { + expect(extractHost("https://Docs.PMUX.io")).toBe("docs.pmux.io"); + }); + + test("returns null for empty or nullish input", () => { + expect(extractHost("")).toBeNull(); + expect(extractHost(null)).toBeNull(); + expect(extractHost(undefined)).toBeNull(); + }); + + test("treats the opaque 'null' origin as absent", () => { + // Sandboxed iframes / file:// pages send the literal Origin: null. + expect(extractHost("null")).toBeNull(); + }); + + test("rejects candidates carrying userinfo (anti-spoofing)", () => { + // new URL("https://evil.com@pmux.io/").hostname is "pmux.io"; without + // this guard an attacker-controlled `h` could impersonate an allowed host. + expect(extractHost("https://evil.com@pmux.io/")).toBeNull(); + expect(extractHost("https://pmux.io@evil.com/")).toBeNull(); + }); + + test("handles a bare host with a port without corrupting it", () => { + expect(extractHost("localhost:3000")).toBe("localhost"); + }); + + test("does not corrupt a bracketed IPv6 host", () => { + expect(extractHost("[::1]")).toBe("[::1]"); + }); + + test("returns null for schemes with no host (data:, javascript:)", () => { + expect(extractHost("data:text/html,hi")).toBeNull(); + expect(extractHost("javascript:alert(1)")).toBeNull(); + }); +}); + +describe("isHostAllowed", () => { + const allowed = parseAllowedOrigins( + "shiftinbits.com,constellationdev.io,pmux.io", + ); + + test("matches an exact host", () => { + expect(isHostAllowed("pmux.io", allowed)).toBe(true); + }); + + test("matches a subdomain of a listed host", () => { + expect(isHostAllowed("docs.pmux.io", allowed)).toBe(true); + expect(isHostAllowed("app.constellationdev.io", allowed)).toBe(true); + }); + + test("matches a deep subdomain", () => { + expect(isHostAllowed("a.b.pmux.io", allowed)).toBe(true); + }); + + test("rejects a sibling domain that merely ends with the name", () => { + expect(isHostAllowed("evil-pmux.io", allowed)).toBe(false); + expect(isHostAllowed("notpmux.io", allowed)).toBe(false); + }); + + test("rejects an unlisted host", () => { + expect(isHostAllowed("example.com", allowed)).toBe(false); + }); + + test("rejects null host", () => { + expect(isHostAllowed(null, allowed)).toBe(false); + }); + + test("rejects any host against an empty allowlist", () => { + expect(isHostAllowed("pmux.io", [])).toBe(false); + }); +}); diff --git a/packages/server/app/lib/allowedOrigins.ts b/packages/server/app/lib/allowedOrigins.ts new file mode 100644 index 00000000..01ec8bf8 --- /dev/null +++ b/packages/server/app/lib/allowedOrigins.ts @@ -0,0 +1,81 @@ +// Helpers for the TRACKER_ALLOWED_ORIGINS allowlist. +// +// Matching is host-based (not scheme-based): an entry like "pmux.io" matches +// the host itself and any subdomain (e.g. "docs.pmux.io"). Enforcement of this +// list lives in the /collect handler, since CORS headers cannot actually block +// who loads or POSTs to the worker. + +/** + * Normalize a single allowlist entry to a bare, lowercase hostname. + * Strips a leading "*." wildcard, then scheme, port, and path (via + * extractHost, so bracketed IPv6 entries such as "[::1]" survive intact). + */ +function normalizeEntry(entry: string): string { + return extractHost(entry.trim().replace(/^\*\./, "")) ?? ""; +} + +/** + * Parse the comma-separated TRACKER_ALLOWED_ORIGINS env var into a list of + * normalized hostnames. Returns [] when unset/empty (enforcement is opt-in). + * A lone "*" entry is dropped so that TRACKER_ALLOWED_ORIGINS="*" reads as + * "allow all" (empty list = enforcement off) rather than silently blocking + * every host (no real hostname equals "*"). + */ +export function parseAllowedOrigins(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map(normalizeEntry) + .filter((host) => host.length > 0 && host !== "*"); +} + +function tryParseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +/** + * Extract a lowercase hostname from a candidate that may be a full URL + * (e.g. "https://docs.pmux.io/x") or a bare host (e.g. "example.com:3000"). + * Returns null for empty/nullish input, the opaque "null" origin, schemes + * with no host (data:, javascript:), or any candidate carrying userinfo + * (e.g. "https://evil.com@pmux.io/", whose host parses as the allowed one). + */ +export function extractHost( + candidate: string | null | undefined, +): string | null { + if (!candidate) return null; + const value = candidate.trim(); + // The literal "null" is what browsers send as the Origin for sandboxed + // iframes / file:// pages — treat it as no signal, not a host named "null". + if (value.length === 0 || value.toLowerCase() === "null") return null; + + // Parse as-is first; fall back to assuming a bare host when there's no + // scheme, or when the value parsed as a scheme with no authority (e.g. + // "localhost:3000" parses as scheme "localhost", "data:..."/"javascript:..." + // have no host). The bare-host parse also handles ports and bracketed IPv6. + let url = tryParseUrl(value); + if (!url || url.hostname === "") { + url = tryParseUrl(`https://${value}`); + } + if (!url) return null; + + // Reject userinfo: "https://evil.com@pmux.io/" has hostname "pmux.io", so + // an attacker-controlled value could otherwise impersonate an allowed host. + if (url.username !== "" || url.password !== "") return null; + + return url.hostname ? url.hostname.toLowerCase() : null; +} + +/** + * True if the host exactly equals a listed origin or is a subdomain of one. + */ +export function isHostAllowed(host: string | null, allowed: string[]): boolean { + if (!host) return false; + const lower = host.toLowerCase(); + return allowed.some( + (entry) => lower === entry || lower.endsWith(`.${entry}`), + ); +} diff --git a/packages/server/app/load-context.ts b/packages/server/app/load-context.ts index 6e7fa07a..90d4cdc5 100644 --- a/packages/server/app/load-context.ts +++ b/packages/server/app/load-context.ts @@ -25,6 +25,7 @@ export const getLoadContext: GetLoadContext = ({ context }) => { const analyticsEngine = new AnalyticsEngineAPI( context.cloudflare.env.CF_ACCOUNT_ID, context.cloudflare.env.CF_BEARER_TOKEN, + context.cloudflare.env.CF_DATASET_NAME, ); return { diff --git a/packages/server/worker-configuration.d.ts b/packages/server/worker-configuration.d.ts index 3d59e313..772afad6 100644 --- a/packages/server/worker-configuration.d.ts +++ b/packages/server/worker-configuration.d.ts @@ -10,6 +10,8 @@ declare namespace Cloudflare { CF_AUTH_ENABLED: string; CF_TRACKER_SCRIPT_NAME: string; CF_STORAGE_ENABLED: string; + CF_DATASET_NAME: string; + TRACKER_ALLOWED_ORIGINS?: string; DAILY_ROLLUPS: R2Bucket; WEB_COUNTER_AE: AnalyticsEngineDataset; ASSETS: Fetcher; diff --git a/packages/server/workers/app.ts b/packages/server/workers/app.ts index 76cb782d..54e0d937 100644 --- a/packages/server/workers/app.ts +++ b/packages/server/workers/app.ts @@ -29,6 +29,7 @@ export default { { accountId: env.CF_ACCOUNT_ID, bearerToken: env.CF_BEARER_TOKEN, + dataset: env.CF_DATASET_NAME, }, env.DAILY_ROLLUPS, ), diff --git a/packages/server/workers/lib/arrow.ts b/packages/server/workers/lib/arrow.ts index 746ca262..f6c92552 100644 --- a/packages/server/workers/lib/arrow.ts +++ b/packages/server/workers/lib/arrow.ts @@ -4,10 +4,14 @@ import { tableFromJSON, tableToIPC } from "apache-arrow"; import dayjs from "dayjs"; export async function extractAsArrow( - { accountId, bearerToken }: { accountId: string; bearerToken: string }, + { + accountId, + bearerToken, + dataset, + }: { accountId: string; bearerToken: string; dataset?: string }, bucket: R2Bucket, ) { - const api = new AnalyticsEngineAPI(accountId, bearerToken); + const api = new AnalyticsEngineAPI(accountId, bearerToken, dataset); // Get yesterday's date range const yesterday = dayjs().subtract(1, "day"); @@ -141,7 +145,11 @@ if (import.meta.url === `file://${process.argv[1]}`) { try { const result = await extractAsArrow( - { accountId, bearerToken }, + { + accountId, + bearerToken, + dataset: process.env.CF_DATASET_NAME, + }, mockBucket, ); console.log("Success:", result); diff --git a/packages/server/wrangler.json b/packages/server/wrangler.json index 435959b1..2b208d5a 100644 --- a/packages/server/wrangler.json +++ b/packages/server/wrangler.json @@ -7,6 +7,9 @@ "binding": "ASSETS", "directory": "./build/client" }, + "vars": { + "CF_DATASET_NAME": "metricsDataset" + }, "analytics_engine_datasets": [ { "binding": "WEB_COUNTER_AE",