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
47 changes: 42 additions & 5 deletions __tests__/production-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -45,7 +46,9 @@ describe('production smoke harness', () => {
if (url.endsWith('/settings')) {
return new Response('<h1>Operational readiness</h1><h2>Chrome extension</h2>');
}
return new Response('<h1>RolePatch</h1>');
return new Response('<h1>RolePatch</h1>', {
headers: { 'content-security-policy': CONTENT_SECURITY_POLICY },
});
});

const summary = await runProductionSmoke({
Expand All @@ -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('<h1>RolePatch</h1>', {
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);
Expand All @@ -88,7 +115,9 @@ describe('production smoke harness', () => {
if (url.endsWith('/settings')) {
return new Response('<h1>Operational readiness</h1><h2>Chrome extension</h2>');
}
return new Response('<h1>RolePatch</h1>');
return new Response('<h1>RolePatch</h1>', {
headers: { 'content-security-policy': CONTENT_SECURITY_POLICY },
});
});

const summary = await runProductionSmoke({
Expand Down Expand Up @@ -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(
{
'/': '<h1>RolePatch</h1>',
Expand All @@ -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;
Expand Down
140 changes: 140 additions & 0 deletions __tests__/root-static-csp.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response> } },
ctx: { waitUntil: () => void }
) => Promise<Response>;
};

// 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('<h1>RolePatch</h1>', {
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('<h1>RolePatch</h1>');
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();
}
});
});
1 change: 1 addition & 0 deletions landing-astro/public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
36 changes: 21 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading