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');