From cbef4f627b5af28c49e2e8f39844aec3f83068f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:33:12 +0000 Subject: [PATCH] feat(cli): the console static plugin takes a cloudflareWebAnalytics option that adds the beacon to the console document createConsoleStaticPlugin(distPath, { cloudflareWebAnalytics: { token } }) writes a deferred script tag for Cloudflare Web Analytics' beacon into the document before the nonce pass, so it carries the document's nonce, with the token as its escaped data-cf-beacon JSON, and adds https://cloudflareinsights.com to connect-src. script-src is unchanged: the nonce admits the tag. A token that is not 32 hexadecimal digits refuses the boot without echoing the value. Left out, the document and the policy are byte-identical to before, pinned against the output read at f108e6d2. The published-subpath pin carries the widened options type. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxGr3fDvcDkCLUwZAo --- .../30936-console-cloudflare-web-analytics.md | 13 ++ .../console.content-security-policy.test.ts | 146 +++++++++++++++++- packages/cli/src/utils/console.ts | 78 +++++++++- .../published-subpath-console.pin.test.ts | 2 +- 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 .changeset/30936-console-cloudflare-web-analytics.md diff --git a/.changeset/30936-console-cloudflare-web-analytics.md b/.changeset/30936-console-cloudflare-web-analytics.md new file mode 100644 index 00000000000..1555b7fe911 --- /dev/null +++ b/.changeset/30936-console-cloudflare-web-analytics.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': patch +--- + +feat(cli): `createConsoleStaticPlugin` takes a `cloudflareWebAnalytics: { token }` option that adds the Cloudflare Web Analytics beacon to the console document + +Clause-②: yes (widening) +Metadata-protocol: none + +- **The option.** `createConsoleStaticPlugin(distPath, { cloudflareWebAnalytics: { token } })`, also on the published `@objectstack/cli/console` subpath. The document that plugin serves then carries a deferred `script` tag for `https://static.cloudflareinsights.com/beacon.min.js` with the token as its `data-cf-beacon` JSON, stamped with the document's nonce like every other script on the page, and its `content-security-policy` adds `https://cloudflareinsights.com`, where the beacon reports page views, to `connect-src`. `script-src` is unchanged: the nonce admits the beacon tag, and no host is added. +- **Per mount.** It is an option, not an env var, so a host that mounts the Console more than once (a control plane and its tenant planes) passes it to the one mount it means to count. +- **Refused at boot.** A token that is not 32 hexadecimal digits (or a missing one) throws when the plugin starts, and the message gives the value's length and kind, not the value. +- **Unchanged without it.** Left out, the document and its policy are byte-identical to before. diff --git a/packages/cli/src/utils/console.content-security-policy.test.ts b/packages/cli/src/utils/console.content-security-policy.test.ts index 4ca4f9adb7c..037526abea1 100644 --- a/packages/cli/src/utils/console.content-security-policy.test.ts +++ b/packages/cli/src/utils/console.content-security-policy.test.ts @@ -86,6 +86,8 @@ async function mount(opts: { /** The kernel services the console plugin's context resolves by name. */ services?: Map; warnings?: string[]; + /** The host's options to the console plugin itself. */ + plugin?: Parameters[1]; } = {}) { const server = new HonoHttpServer(0); server.installNotFoundSeam(); @@ -107,7 +109,7 @@ async function mount(opts: { } as any); for (const fn of hooks) await fn(); } - await createConsoleStaticPlugin(distPath).start({ + await createConsoleStaticPlugin(distPath, opts.plugin).start({ getServiceAsync: async () => server, getService: (name: string) => { if (opts.services?.has(name)) return opts.services.get(name); @@ -561,3 +563,145 @@ describe('the opt-in Turnstile challenge on sign-up', () => { return response.headers.get('content-security-policy'); } }); + +describe('the host\'s cloudflareWebAnalytics option', () => { + const TOKEN = '0123456789abcdef0123456789ABCDEF'; + const BEACON_SRC = 'https://static.cloudflareinsights.com/beacon.min.js'; + const REPORT_ORIGIN = 'https://cloudflareinsights.com'; + + /** + * The document and the policy this suite's shell is served with when no + * option is passed, as `main` served them before the option existed (read + * at `f108e6d2`), with the per-response nonce written as `NONCE`. + */ + const DOCUMENT_WITHOUT_OPTION = [ + '', + ' ', + ' ', + '', + '', + '', + '', + '
', + ].join('\n'); + const POLICY_WITHOUT_OPTION = [ + "default-src 'self'", + "script-src 'self' 'nonce-NONCE' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "connect-src 'self' https://demotiles.maplibre.org", + "manifest-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'self'", + ].join('; '); + + /** The response's document and policy, its nonce written as `NONCE`. */ + async function served(request: (p: string) => Promise): Promise<{ html: string; policy: string }> { + const res = await request('/_console/'); + expect(res.status).toBe(200); + const policy = res.headers.get('content-security-policy')!; + const nonce = headerNonce(policy); + const html = await res.text(); + return { html: html.split(nonce).join('NONCE'), policy: policy.split(nonce).join('NONCE') }; + } + + /** The `data-cf-beacon` value of the document's one beacon tag, as a browser decodes it. */ + function beaconConfig(html: string): unknown { + const tags = scriptTags(html).filter((tag) => tag.includes(BEACON_SRC)); + expect(tags).toHaveLength(1); + const match = /\sdata-cf-beacon="([^"]*)"/.exec(tags[0]); + expect(match, 'a double-quoted data-cf-beacon attribute').not.toBeNull(); + const decoded = match![1] + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + return JSON.parse(decoded); + } + + it('writes the beacon tag under the document\'s nonce, and adds the origin it reports to in connect-src', async () => { + const request = await mount({ plugin: { cloudflareWebAnalytics: { token: TOKEN } } }); + const res = await request('/_console/'); + const raw = res.headers.get('content-security-policy'); + const nonce = headerNonce(raw); + const html = await res.text(); + + const beacon = scriptTags(html).filter((tag) => tag.includes(BEACON_SRC)); + expect(beacon).toHaveLength(1); + expect(beacon[0]).toContain(` nonce="${nonce}"`); + expect(beacon[0]).toMatch(/\sdefer\b/); + expect(beacon[0]).toContain(` src="${BEACON_SRC}"`); + expect(beaconConfig(html)).toEqual({ token: TOKEN }); + // The build's three, the capability global and the beacon: every one nonced. + const tags = scriptTags(html); + expect(tags).toHaveLength(5); + for (const tag of tags) expect(tag).toContain(` nonce="${nonce}"`); + + const policy = directives(raw); + expect(policy.get('connect-src')).toEqual(["'self'", 'https://demotiles.maplibre.org', REPORT_ORIGIN]); + // The beacon loads by the nonce: script-src names no host for it. + expect(policy.get('script-src')).toEqual(["'self'", `'nonce-${nonce}'`, "'wasm-unsafe-eval'"]); + expect(raw).not.toContain('static.cloudflareinsights.com'); + }); + + it('keeps OS_CONSOLE_CSP_CONNECT_SRC working beside it, after the report origin', async () => { + process.env.OS_CONSOLE_CSP_CONNECT_SRC = 'https://tiles.example.com'; + const request = await mount({ plugin: { cloudflareWebAnalytics: { token: TOKEN } } }); + const { policy } = await served(request); + expect(directives(policy).get('connect-src')).toEqual([ + "'self'", + 'https://demotiles.maplibre.org', + REPORT_ORIGIN, + 'https://tiles.example.com', + ]); + }); + + for (const [label, plugin] of [ + ['no options at all', undefined], + ['the other options only', { isDev: false, rootRedirect: true }], + ['the option key left undefined', { cloudflareWebAnalytics: undefined }], + ] as const) { + it(`serves the document and the policy byte-identical to before with ${label}`, async () => { + const request = await mount({ plugin }); + const { html, policy } = await served(request); + expect(html).toBe(DOCUMENT_WITHOUT_OPTION); + expect(policy).toBe(POLICY_WITHOUT_OPTION); + }); + } + + it.each([ + ['an empty string', ''], + ['31 hex digits', TOKEN.slice(1)], + ['33 hex digits', `${TOKEN}0`], + ['32 characters that are not all hex', 'g'.repeat(32)], + ['a token padded with whitespace', ` ${TOKEN} `], + ['a token that would close the attribute', `${TOKEN.slice(0, 16)}">`], + ['a token that would close the JSON', `${TOKEN.slice(0, 30)}",`], + ['a number', 42], + ['null', null], + ])('refuses the boot when the token is %s, without echoing it', async (_label, token) => { + const failure = await mount({ plugin: { cloudflareWebAnalytics: { token } as unknown as { token: string } } }) + .then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + const { message } = failure as Error; + expect(message).toMatch(/cloudflareWebAnalytics option's token is .*not a Cloudflare Web Analytics site token/); + if (typeof token === 'string' && token.length > 0) expect(message).not.toContain(token); + }); + + it('refuses the boot when the option carries no token', async () => { + await expect(mount({ plugin: { cloudflareWebAnalytics: {} as unknown as { token: string } } })) + .rejects.toThrow(/cloudflareWebAnalytics option's token is undefined/); + }); + + it('keeps the token inside the attribute: the JSON\'s own quotes are escaped, so the tag parses as one element', async () => { + const request = await mount({ plugin: { cloudflareWebAnalytics: { token: TOKEN } } }); + const { html } = await served(request); + const tag = scriptTags(html).find((t) => t.includes(BEACON_SRC))!; + expect(tag).toBe( + ``); + }); +}); diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index ce77ffb40de..96f2c47898c 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -849,11 +849,17 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org'; * sign-up form loads Cloudflare's script, which draws the challenge in a * frame from the same origin. With the challenge off, neither is listed and * `frame-src` is left to `default-src 'self'`, exactly as before. + * - {@link CLOUDFLARE_WEB_ANALYTICS_ORIGIN} in `connect-src` only while the + * host passes the `cloudflareWebAnalytics` option: the beacon the document + * then carries reports each page view there. The beacon itself loads by the + * document's nonce, so its host is not in `script-src`. Without the option, + * neither the tag nor the origin is written, exactly as before. */ function consoleContentSecurityPolicy(input: { nonce: string; reactPages: boolean; turnstile: boolean; + cloudflareWebAnalytics: boolean; errorReportingOrigin: string | undefined; mapStyleOrigin: string | undefined; storageUploadOrigin: string | undefined; @@ -867,6 +873,7 @@ function consoleContentSecurityPolicy(input: { if (input.mapStyleOrigin !== undefined) connectSrc.push(input.mapStyleOrigin); if (input.errorReportingOrigin !== undefined) connectSrc.push(input.errorReportingOrigin); if (input.storageUploadOrigin !== undefined) connectSrc.push(input.storageUploadOrigin); + if (input.cloudflareWebAnalytics) connectSrc.push(CLOUDFLARE_WEB_ANALYTICS_ORIGIN); connectSrc.push(...input.extraConnectSources); const directives: string[][] = [ ['default-src', "'self'"], @@ -886,6 +893,57 @@ function consoleContentSecurityPolicy(input: { /** Cloudflare Turnstile's origin: its loader script and its challenge frame. */ const TURNSTILE_ORIGIN = 'https://challenges.cloudflare.com'; +/** + * Cloudflare Web Analytics' beacon script, as the site's manual JavaScript + * snippet names it. It is written into the document only when the host passes + * the `cloudflareWebAnalytics` option ({@link cloudflareWebAnalyticsBeaconScript}). + * + * Its host is ⛔ NOT added to `script-src`: the tag carries the document's + * nonce, which admits that one element whatever its `src`, while a host entry + * would admit every file the host serves. + */ +const CLOUDFLARE_WEB_ANALYTICS_BEACON_SRC = 'https://static.cloudflareinsights.com/beacon.min.js'; + +/** The origin Cloudflare Web Analytics' beacon reports page views to. */ +const CLOUDFLARE_WEB_ANALYTICS_ORIGIN = 'https://cloudflareinsights.com'; + +/** + * A Cloudflare Web Analytics site token: 32 hexadecimal digits. The character + * set is what keeps the token from carrying anything but itself into the + * document; the attribute it lands in is escaped as well. + */ +const CLOUDFLARE_WEB_ANALYTICS_TOKEN = /^[0-9a-f]{32}$/i; + +/** + * The site token of the host's `cloudflareWebAnalytics` option, or `undefined` + * when the option is left out; a token outside {@link CLOUDFLARE_WEB_ANALYTICS_TOKEN} + * refuses the boot. The refusal names the value's length and kind, never the + * value: a credential passed here by mistake would otherwise land in the boot + * log. + */ +function readCloudflareWebAnalyticsToken(option: { token: string } | undefined): string | undefined { + if (option === undefined) return undefined; + const token: unknown = (option as { token?: unknown } | null)?.token; + if (typeof token === 'string' && CLOUDFLARE_WEB_ANALYTICS_TOKEN.test(token)) return token; + const shown = typeof token === 'string' ? `a ${token.length}-character string` : token === null ? 'null' : typeof token; + throw new Error( + `Console static: the cloudflareWebAnalytics option's token is ${shown}, which is not a Cloudflare Web Analytics ` + + 'site token. Pass the 32 hexadecimal digits the site\'s JavaScript snippet carries as its token, or leave the ' + + 'option out to serve the console without the beacon.', + ); +} + +/** + * The Cloudflare Web Analytics beacon tag for `token`, deferred, with the + * token as the `data-cf-beacon` JSON the beacon reads. The attribute value is + * escaped, so nothing in it can close the attribute or the tag. It is written + * before {@link stampScriptNonce} runs, which gives it the document's nonce. + */ +function cloudflareWebAnalyticsBeaconScript(token: string): string { + const config = escapeHtml(JSON.stringify({ token })); + return ``; +} + /** * Whether the opt-in Turnstile challenge on sign-up is on: BOTH variables set * and non-blank, the rule `@objectstack/plugin-auth` mounts the challenge and @@ -995,8 +1053,19 @@ function stampScriptNonce(html: string, nonce: string): string { * the anonymous form door serves that slug, and otherwise gets the same * not-found answer as before. Mounted * only with the Console, because without it there is no page to send anyone to. + * + * `cloudflareWebAnalytics: { token }` adds Cloudflare Web Analytics to the + * document this plugin serves, and to no other: the beacon tag, stamped with + * the document's nonce, and the origin it reports to in `connect-src`. It is an + * option rather than an env var so a host that mounts the Console more than + * once (a control plane and its tenant planes) can count one mount only. A + * token that is not 32 hexadecimal digits refuses the boot. Left out, the + * document and its policy are exactly what they were without it. */ -export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: boolean; rootRedirect?: boolean }) { +export function createConsoleStaticPlugin( + distPath: string, + options?: { isDev?: boolean; rootRedirect?: boolean; cloudflareWebAnalytics?: { token: string } }, +) { return { name: 'com.objectstack.console-static', @@ -1017,6 +1086,8 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: // The deployment's default map style (#26001), on the contract the // runtime-config door serves it under, so the policy allows that URL. const mapStyleOrigin = readMapStyleOrigin(process.env); + // The host's own option, checked at boot like the two reads above. + const cloudflareWebAnalyticsToken = readCloudflareWebAnalyticsToken(options?.cloudflareWebAnalytics); const indexPath = path.join(absoluteDist, 'index.html'); if (!fs.existsSync(indexPath)) { @@ -1097,6 +1168,10 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: } const capTag = reactPagesCapabilityScript(reactPages); html = html.replace(/]*)?>/i, (m) => `${m}\n ${capTag}`); + if (cloudflareWebAnalyticsToken !== undefined) { + const beaconTag = cloudflareWebAnalyticsBeaconScript(cloudflareWebAnalyticsToken); + html = html.replace(/]*)?>/i, (m) => `${m}\n ${beaconTag}`); + } // Stamped while the document holds only the build's markup and the // server's own tags: nothing read at request time is written yet, so // no runtime value can ever carry the nonce. @@ -1117,6 +1192,7 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: nonce, reactPages, turnstile: readTurnstileEnabled(), + cloudflareWebAnalytics: cloudflareWebAnalyticsToken !== undefined, errorReportingOrigin: shell?.errorReportingOrigin, mapStyleOrigin, storageUploadOrigin: await readStorageUploadOrigin(ctx, reportStorageUploadOriginFailure), diff --git a/packages/cli/test/published-subpath-console.pin.test.ts b/packages/cli/test/published-subpath-console.pin.test.ts index a8a145576ed..85a18c6b002 100644 --- a/packages/cli/test/published-subpath-console.pin.test.ts +++ b/packages/cli/test/published-subpath-console.pin.test.ts @@ -327,7 +327,7 @@ type CreateConsoleStaticPluginKeepsItsRatifiedSignature = Expect< typeof createConsoleStaticPlugin, ( distPath: string, - options?: { isDev?: boolean; rootRedirect?: boolean }, + options?: { isDev?: boolean; rootRedirect?: boolean; cloudflareWebAnalytics?: { token: string } }, ) => { name: string; init: () => Promise; start: (ctx: any) => Promise } > >;