Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/30936-console-cloudflare-web-analytics.md
Original file line number Diff line number Diff line change
@@ -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.
146 changes: 145 additions & 1 deletion packages/cli/src/utils/console.content-security-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ async function mount(opts: {
/** The kernel services the console plugin's context resolves by name. */
services?: Map<string, unknown>;
warnings?: string[];
/** The host's options to the console plugin itself. */
plugin?: Parameters<typeof createConsoleStaticPlugin>[1];
} = {}) {
const server = new HonoHttpServer(0);
server.installNotFoundSeam();
Expand All @@ -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);
Expand Down Expand Up @@ -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 = [
'<!doctype html><html><head>',
' <script nonce="NONCE">window.__OBJECTUI_CAPABILITIES_DISABLED__=(window.__OBJECTUI_CAPABILITIES_DISABLED__||[]).concat(\'react-pages\');</script>',
' <base href="/_console/"><meta charset="UTF-8"><title></title>',
'<script nonce="NONCE">window.early = 1;</script>',
'<SCRIPT nonce="NONCE">window.shim = "</" + "script>";</SCRIPT>',
'<style>#boot-splash{display:flex}</style>',
'<script nonce="NONCE" type="module" crossorigin src="./assets/index-BQWjE1TW.js"></script>',
'</head><body><div id="root"></div></body></html>',
].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<Response>): 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(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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)}"><script>x()</script>`],
['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(
`<script nonce="NONCE" defer src="${BEACON_SRC}" data-cf-beacon="{&quot;token&quot;:&quot;${TOKEN}&quot;}">`,
);
expect(html).toContain(`${tag}</script>`);
});
});
78 changes: 77 additions & 1 deletion packages/cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'"],
Expand All @@ -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 `<script defer src="${CLOUDFLARE_WEB_ANALYTICS_BEACON_SRC}" data-cf-beacon="${config}"></script>`;
}

/**
* 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
Expand Down Expand Up @@ -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',

Expand All @@ -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)) {
Expand Down Expand Up @@ -1097,6 +1168,10 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
}
const capTag = reactPagesCapabilityScript(reactPages);
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${capTag}`);
if (cloudflareWebAnalyticsToken !== undefined) {
const beaconTag = cloudflareWebAnalyticsBeaconScript(cloudflareWebAnalyticsToken);
html = html.replace(/<head(\s[^>]*)?>/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.
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/published-subpath-console.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>; start: (ctx: any) => Promise<void> }
>
>;
Expand Down
Loading