diff --git a/__tests__/production-smoke.test.ts b/__tests__/production-smoke.test.ts index 0004e4a..a8578f8 100644 --- a/__tests__/production-smoke.test.ts +++ b/__tests__/production-smoke.test.ts @@ -1,10 +1,11 @@ import { spawn } from 'node:child_process'; -import { copyFile, mkdtemp, rm } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { createServer } from 'node:http'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; +import { CONTENT_SECURITY_POLICY } from '../security-policy.mjs'; import { buildSmokeChecks, @@ -45,7 +46,9 @@ describe('production smoke harness', () => { if (url.endsWith('/settings')) { return new Response('

Operational readiness

Chrome extension

'); } - return new Response('

RolePatch

'); + return new Response('

RolePatch

', { + headers: { 'content-security-policy': CONTENT_SECURITY_POLICY }, + }); }); const summary = await runProductionSmoke({ @@ -62,6 +65,30 @@ describe('production smoke harness', () => { ); }); + it.each([undefined, '', 'default-src *'])( + 'rejects a missing, empty or different landing CSP: %s', + async (policy) => { + const summary = await runProductionSmoke({ + baseUrl: 'https://rolepatch.com', + fetchImpl: vi.fn( + async () => + new Response('

RolePatch

', { + headers: policy === undefined ? {} : { 'content-security-policy': policy }, + }) + ), + }); + expect(summary.results[0]).toMatchObject({ + name: 'landing', + ok: false, + errors: [ + policy === undefined + ? 'missing response header: content-security-policy' + : 'unexpected response header: content-security-policy', + ], + }); + } + ); + it('runs authenticated apply-agent read checks when a session cookie is supplied', async () => { const fetchImpl = vi.fn(async (input: string | URL | Request) => { const url = input instanceof Request ? input.url : String(input); @@ -88,7 +115,9 @@ describe('production smoke harness', () => { if (url.endsWith('/settings')) { return new Response('

Operational readiness

Chrome extension

'); } - return new Response('

RolePatch

'); + return new Response('

RolePatch

', { + headers: { 'content-security-policy': CONTENT_SECURITY_POLICY }, + }); }); const summary = await runProductionSmoke({ @@ -147,7 +176,10 @@ describe('production smoke harness', () => { return; } - response.writeHead(200, { 'content-type': 'text/html' }); + response.writeHead(200, { + 'content-type': 'text/html', + ...(path === '/' ? { 'content-security-policy': CONTENT_SECURITY_POLICY } : {}), + }); response.end( { '/': '

RolePatch

', @@ -172,8 +204,13 @@ describe('production smoke harness', () => { dirname(fileURLToPath(import.meta.url)), '../scripts/production-smoke.mjs' ); - const spacedScriptPath = join(tempDirectory, 'production-smoke.mjs'); + const spacedScriptPath = join(tempDirectory, 'scripts', 'production-smoke.mjs'); + await mkdir(join(tempDirectory, 'scripts')); await copyFile(sourcePath, spacedScriptPath); + await copyFile( + join(dirname(sourcePath), '../security-policy.mjs'), + join(tempDirectory, 'security-policy.mjs') + ); const { code, stdout, stderr } = await new Promise<{ code: number | null; diff --git a/__tests__/root-static-csp.test.ts b/__tests__/root-static-csp.test.ts new file mode 100644 index 0000000..d6b4b9d --- /dev/null +++ b/__tests__/root-static-csp.test.ts @@ -0,0 +1,140 @@ +import { Blob } from 'node:buffer'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +import nextConfig from '../next.config'; +import { CONTENT_SECURITY_POLICY } from '../security-policy.mjs'; + +const headersFile = readFileSync(join(process.cwd(), 'landing-astro/public/_headers'), 'utf8'); +const workerSource = readFileSync(join(process.cwd(), 'worker.mjs'), 'utf8'); +const cacheControl = 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800'; + +type RootWorker = { + fetch: ( + request: Request, + env: { ASSETS: { fetch: (request: Request) => Promise } }, + ctx: { waitUntil: () => void } + ) => Promise; +}; + +// Use Node streaming Blob rather than the JSDOM Blob, which has no stream(). +// Execute the actual handler with isolated routing dependencies. This verifies +// response behavior in Node; the production build and Cloudflare smoke are +// separate gates for the real static-asset routing and compression runtime. +function loadWorker() { + const openNextFetch = vi.fn(async () => new Response('Next response')); + const sandbox = { + Request, + Response, + Headers, + URL, + Blob, + Uint8Array, + CompressionStream, + DecompressionStream, + console, + openNext: { fetch: openNextFetch }, + withTiming: (handler: RootWorker['fetch']) => handler, + handleAgentEdge: () => null, + handleRolePatchAgentRoutes: () => null, + isDocumentRequest: () => true, + documentCacheRequest: async () => null, + DOCUMENT_CLIENT_CACHE_CONTROL: 'unused', + DOCUMENT_EDGE_CACHE_CONTROL: 'unused', + CONTENT_SECURITY_POLICY, + rolePatchWorker: undefined as RootWorker | undefined, + }; + const executable = workerSource + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export \{[\s\S]*?\} from '\.\/\.open-next\/worker\.js';\r?\n/m, '') + .replace('export default {', 'globalThis.rolePatchWorker = {'); + runInNewContext(executable, sandbox, { timeout: 1000 }); + if (!sandbox.rolePatchWorker) throw new Error('Worker handler was not loaded'); + return { worker: sandbox.rolePatchWorker, openNextFetch }; +} + +describe('static Astro homepage CSP parity', () => { + it('matches the intended Next root policy exactly in the static root rule', async () => { + const routes = await nextConfig.headers?.(); + const rootPolicy = routes + ?.find((route) => route.source === '/') + ?.headers.find((header) => header.key === 'Content-Security-Policy')?.value; + expect(CONTENT_SECURITY_POLICY).toBe(rootPolicy); + const rootBlock = headersFile.match(/^\/\r?\n((?:[ \t].*(?:\r?\n|$))*)/m)?.[1] ?? ''; + const policies = rootBlock + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('Content-Security-Policy:')); + expect(policies).toEqual([`Content-Security-Policy: ${rootPolicy}`]); + }); + + it.each(['identity', 'gzip'])( + 'adds the intended policy to a headerless root asset using %s', + async (encoding) => { + const { worker, openNextFetch } = loadWorker(); + const assetFetch = vi.fn( + async () => + new Response('

RolePatch

', { + headers: { 'content-type': 'text/html', etag: 'test-asset' }, + }) + ); + const response = await worker.fetch( + new Request('https://rolepatch.com/', { headers: { 'accept-encoding': encoding } }), + { ASSETS: { fetch: assetFetch } }, + { waitUntil: () => {} } + ); + expect(response.status).toBe(200); + expect(response.headers.get('content-security-policy')).toBe(CONTENT_SECURITY_POLICY); + expect(response.headers.get('cache-control')).toBe(cacheControl); + expect(response.headers.get('etag')).toBe('test-asset'); + expect(response.headers.get('x-edge-cache')).toBe('ASSET'); + expect(openNextFetch).not.toHaveBeenCalled(); + const body = + encoding === 'gzip' + ? await new Response(response.body?.pipeThrough(new DecompressionStream('gzip'))).text() + : await response.text(); + expect(body).toBe('

RolePatch

'); + expect(response.headers.get('content-encoding')).toBe(encoding === 'gzip' ? 'gzip' : null); + } + ); + + it('preserves an empty 304 revalidation while attaching the same policy', async () => { + const { worker, openNextFetch } = loadWorker(); + const response = await worker.fetch( + new Request('https://rolepatch.com/', { headers: { 'if-none-match': 'test-asset' } }), + { + ASSETS: { + fetch: async () => new Response(null, { status: 304, headers: { etag: 'test-asset' } }), + }, + }, + { waitUntil: () => {} } + ); + expect(response.status).toBe(304); + expect(response.headers.get('content-security-policy')).toBe(CONTENT_SECURITY_POLICY); + expect(response.headers.get('cache-control')).toBe(cacheControl); + expect(response.headers.get('etag')).toBe('test-asset'); + expect(await response.text()).toBe(''); + expect(openNextFetch).not.toHaveBeenCalled(); + }); + + it('leaves non-root and non-GET requests on the existing Next path', async () => { + for (const [path, method] of [ + ['/pricing', 'GET'], + ['/', 'POST'], + ]) { + const { worker, openNextFetch } = loadWorker(); + const assetFetch = vi.fn(async () => new Response('unexpected')); + const response = await worker.fetch( + new Request(`https://rolepatch.com${path}`, { method }), + { ASSETS: { fetch: assetFetch } }, + { waitUntil: () => {} } + ); + expect(await response.text()).toBe('Next response'); + expect(response.headers.get('content-security-policy')).toBeNull(); + expect(assetFetch).not.toHaveBeenCalled(); + expect(openNextFetch).toHaveBeenCalledOnce(); + } + }); +}); diff --git a/landing-astro/public/_headers b/landing-astro/public/_headers index 9e1e863..2131c2d 100644 --- a/landing-astro/public/_headers +++ b/landing-astro/public/_headers @@ -12,6 +12,7 @@ # Long s-maxage + browser max-age makes CF Pages mark HTML responses as # cacheable at edge (without it, responses come back as cf-cache-status: DYNAMIC). / + Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.cloudflareinsights.com https://us-assets.i.posthog.com https://www.clarity.ms https://scripts.clarity.ms https://challenges.cloudflare.com https://sassmaker.com https://health.sassmaker.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://sassmaker.com; connect-src 'self' https: https://cloudflareinsights.com; frame-src 'self' https://challenges.cloudflare.com; frame-ancestors 'none' Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800 /*.html diff --git a/package.json b/package.json index 8b23f45..2236ceb 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "js-yaml@>=4.0.0 <4.3.2": "4.3.2", "svgo@>=4.0.0 <4.1.0": "4.1.0", "sharp@>=0.35.0 <0.35.4": "0.35.4", - "@puppeteer/browsers@>=2.0.0 <3.0.0": "3.2.2" + "@puppeteer/browsers@>=2.0.0 <3.0.0": "3.2.2", + "source-map-js@>=1.2.0 <1.2.2": "1.2.2", + "proxy-addr@>=2.0.0 <2.0.8": "2.0.8" } }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d13257..7090c72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,8 @@ overrides: svgo@>=4.0.0 <4.1.0: 4.1.0 sharp@>=0.35.0 <0.35.4: 0.35.4 '@puppeteer/browsers@>=2.0.0 <3.0.0': 3.2.2 + source-map-js@>=1.2.0 <1.2.2: 1.2.2 + proxy-addr@>=2.0.0 <2.0.8: 2.0.8 importers: @@ -453,6 +455,9 @@ packages: '@aws-sdk/core@3.977.1': resolution: {integrity: sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==} engines: {node: '>=20.0.0'} + deprecated: |- + Deprecated due to Document number parsing bug in JSON, see + https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. '@aws-sdk/credential-provider-env@3.972.61': resolution: {integrity: sha512-qihs2ekMb89Nxd2JenCgVFhjbkb3EIo7HEBCBzyZACKVJdrLUZBLOmAE3xr0Sayml8n/jZSzwO/IufIiIzO7PQ==} @@ -3014,6 +3019,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vercel/cli-config@0.2.1': resolution: {integrity: sha512-RhfyXmRLHdbnry8RJqHDc+5rGxMZ0bu+fpysZjtv3bE+BubpuwxTancHOKiH5zKQREsdwFVr3mOI2kOvxlOyxA==} @@ -4957,8 +4963,8 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} pump@3.0.4: @@ -5187,8 +5193,8 @@ packages: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -8205,7 +8211,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.0 '@tailwindcss/node@4.3.3': @@ -8215,7 +8221,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.0': @@ -9032,12 +9038,12 @@ snapshots: css-tree@2.2.1: dependencies: mdn-data: 2.0.28 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-what@7.0.0: {} @@ -9402,7 +9408,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.15.3 range-parser: 1.3.0 router: 2.2.0 @@ -10009,13 +10015,13 @@ snapshots: dependencies: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - source-map-js: 1.2.1 + source-map-js: 1.2.2 magicast@0.5.4: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -10606,13 +10612,13 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postcss@8.5.28: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 posthog-js@1.407.3: dependencies: @@ -10646,7 +10652,7 @@ snapshots: property-information@7.2.0: {} - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -11033,7 +11039,7 @@ snapshots: smol-toml@1.8.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.21: dependencies: diff --git a/scripts/production-smoke.mjs b/scripts/production-smoke.mjs index 9d73aa0..e84ac7c 100644 --- a/scripts/production-smoke.mjs +++ b/scripts/production-smoke.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { pathToFileURL } from 'node:url'; +import { CONTENT_SECURITY_POLICY } from '../security-policy.mjs'; const DEFAULT_BASE_URL = 'https://rolepatch.com'; const DEFAULT_TIMEOUT_MS = 10_000; @@ -18,6 +19,7 @@ export function buildSmokeChecks({ hasSessionCookie = false } = {}) { path: '/', expectStatus: 200, requiredText: ['RolePatch'], + requiredHeaders: { 'content-security-policy': CONTENT_SECURITY_POLICY }, }, { name: 'jobs browser', @@ -125,6 +127,11 @@ async function runCheck(baseUrl, check, sessionCookie, timeoutMs = DEFAULT_TIMEO if (res.status !== check.expectStatus) { errors.push(`expected HTTP ${check.expectStatus}, got ${res.status}`); } + for (const [name, expectedValue] of Object.entries(check.requiredHeaders ?? {})) { + const actualValue = res.headers.get(name); + if (actualValue === null) errors.push(`missing response header: ${name}`); + else if (actualValue !== expectedValue) errors.push(`unexpected response header: ${name}`); + } for (const text of check.requiredText ?? []) { if (!body.includes(text)) errors.push(`missing text: ${text}`); } diff --git a/security-policy.mjs b/security-policy.mjs new file mode 100644 index 0000000..f6f064d --- /dev/null +++ b/security-policy.mjs @@ -0,0 +1,11 @@ +// Shared with the Next.js CSP contract tests; keep this policy aligned with next.config.ts. +export const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.cloudflareinsights.com https://us-assets.i.posthog.com https://www.clarity.ms https://scripts.clarity.ms https://challenges.cloudflare.com https://sassmaker.com https://health.sassmaker.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' https://sassmaker.com", + "connect-src 'self' https: https://cloudflareinsights.com", + "frame-src 'self' https://challenges.cloudflare.com", + "frame-ancestors 'none'", +].join('; '); diff --git a/worker.mjs b/worker.mjs index a8c70e1..3067bec 100644 --- a/worker.mjs +++ b/worker.mjs @@ -13,6 +13,7 @@ import openNext from './.open-next/worker.js'; import { withTiming } from './timing.mjs'; +import { CONTENT_SECURITY_POLICY } from './security-policy.mjs'; import { handleAgentEdge } from './agent-edge.mjs'; import { handleRolePatchAgentRoutes } from './rolepatch-agent-routes.mjs'; import { @@ -197,6 +198,7 @@ export default { // Pass those through — falling through would serve the wrong page. if (assetResp.status === 304) { const headers = new Headers(assetResp.headers); + headers.set('Content-Security-Policy', CONTENT_SECURITY_POLICY); headers.set('Cache-Control', CACHE_CONTROL); headers.set('x-edge-cache', 'ASSET'); return new Response(null, { status: 304, headers }); @@ -205,6 +207,7 @@ export default { const acceptEnc = request.headers.get('accept-encoding') ?? ''; const wantsGzip = acceptEnc.includes('gzip'); const headers = new Headers(assetResp.headers); + headers.set('Content-Security-Policy', CONTENT_SECURITY_POLICY); headers.set('Cache-Control', CACHE_CONTROL); headers.set('x-edge-cache', 'ASSET');