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
68 changes: 66 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,69 @@
import type { NextConfig } from "next";

// Content-Security-Policy, currently shipped in REPORT-ONLY mode. Violations
// are POSTed to /api/csp-report and logged as `[csp]` lines in Vercel logs.
// Once a week or so of logs is clean, rename the header below to
// `Content-Security-Policy` to enforce.
//
// 'unsafe-inline' in script-src covers Next's inline bootstrap/RSC payload
// scripts, the theme script + JSON-LD in layout.tsx, and the inline gtag
// config. Moving to nonces would require a proxy (middleware) and dynamic
// rendering for every page, so it is deliberately out of scope for now.
const csp = [
"default-src 'self'",
[
"script-src 'self' 'unsafe-inline'",
// Google Analytics (gtag) + Google Maps/Places + Google Identity Services
'https://www.googletagmanager.com',
'https://*.googletagmanager.com',
'https://maps.googleapis.com',
'https://accounts.google.com',
// Chart.js on /admin/analytics
'https://cdn.jsdelivr.net',
// Vercel toolbar / analytics on preview deployments
'https://vercel.live',
].join(' '),
[
"connect-src 'self'",
'https://*.supabase.co',
'wss://*.supabase.co',
'https://api.mapbox.com',
'https://events.mapbox.com',
'https://*.google-analytics.com',
'https://*.analytics.google.com',
'https://www.google.com',
'https://*.googletagmanager.com',
'https://maps.googleapis.com',
'https://places.googleapis.com',
'https://accounts.google.com',
'https://www.googleapis.com',
'https://cdn.jsdelivr.net',
'https://vercel.live',
'wss://ws-us3.pusher.com',
].join(' '),
"img-src 'self' data: blob: https:",
"style-src 'self' 'unsafe-inline' https://accounts.google.com https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com https://vercel.live",
[
"frame-src 'self'",
'https://lu.ma',
'https://luma.com',
'https://*.luma.com',
'https://accounts.google.com',
'https://vercel.live',
].join(' '),
"worker-src 'self' blob:",
"child-src 'self' blob:",
"manifest-src 'self'",
"media-src 'self' https:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
'report-uri /api/csp-report',
'report-to csp-endpoint',
].join('; ');

const nextConfig: NextConfig = {
images: {
remotePatterns: [
Expand All @@ -17,8 +81,6 @@ const nextConfig: NextConfig = {
async headers() {
// Baseline security headers. The app is never embedded by third parties
// (it only embeds Luma iframes itself), so SAMEORIGIN framing is safe.
// TODO: add a Content-Security-Policy once it can be tested against
// Mapbox, Google Analytics, Supabase, Luma embeds and inline scripts.
return [
{
source: '/:path*',
Expand All @@ -27,6 +89,8 @@ const nextConfig: NextConfig = {
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Permissions-Policy', value: 'geolocation=(self)' },
{ key: 'Reporting-Endpoints', value: 'csp-endpoint="/api/csp-report"' },
{ key: 'Content-Security-Policy-Report-Only', value: csp },
],
},
];
Expand Down
28 changes: 28 additions & 0 deletions src/app/api/csp-report/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextRequest } from 'next/server';
import { CSP_REPORT_MAX_BYTES, parseCspReports, readCappedBody } from '@/lib/csp-report';

/**
* Receives CSP violation reports (report-uri and report-to formats) and logs a
* compact `[csp]` line per violation to the function logs. No DB writes.
*/
const ACCEPTED_TYPES = ['application/csp-report', 'application/reports+json', 'application/json'];

export async function POST(req: NextRequest) {
const type = (req.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
if (!ACCEPTED_TYPES.includes(type)) {
return new Response(null, { status: 415 });
}
const declared = Number(req.headers.get('content-length') ?? '0');
if (declared > CSP_REPORT_MAX_BYTES) {
return new Response(null, { status: 413 });
}
const raw = await readCappedBody(req.body);
if (raw === null) {
return new Response(null, { status: 413 });
}
const ua = (req.headers.get('user-agent') ?? '').slice(0, 120);
for (const v of parseCspReports(raw)) {
console.warn(`[csp] ${JSON.stringify({ ...v, ua })}`);
}
return new Response(null, { status: 204 });
}
72 changes: 72 additions & 0 deletions src/lib/__tests__/csp-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { parseCspReports, readCappedBody, stripUrl } from '../csp-report';

function stream(s: string) {
return new Response(s).body;
}

describe('parseCspReports', () => {
it('parses legacy application/csp-report bodies', () => {
const raw = JSON.stringify({
'csp-report': {
'document-uri': 'https://plan.wtf/kbw2026?x=secret',
'violated-directive': 'script-src-elem',
'blocked-uri': 'https://evil.example/a.js?token=1',
'source-file': 'https://plan.wtf/_next/x.js',
'line-number': 12,
},
});
expect(parseCspReports(raw)).toEqual([
{
directive: 'script-src-elem',
blocked: 'https://evil.example/a.js',
page: 'https://plan.wtf/kbw2026',
source: 'https://plan.wtf/_next/x.js:12',
},
]);
});

it('parses application/reports+json arrays and ignores other report types', () => {
const raw = JSON.stringify([
{ type: 'deprecation', body: { id: 'x' } },
{
type: 'csp-violation',
body: {
documentURL: 'https://plan.wtf/',
effectiveDirective: 'img-src',
blockedURL: 'inline',
disposition: 'report',
},
},
]);
expect(parseCspReports(raw)).toEqual([
{ directive: 'img-src', blocked: 'inline', page: 'https://plan.wtf/', disposition: 'report' },
]);
});

it('returns [] for garbage', () => {
expect(parseCspReports('not json')).toEqual([]);
expect(parseCspReports('{"foo":1}')).toEqual([]);
expect(parseCspReports('[1,null,"x"]')).toEqual([]);
});

it('truncates long fields', () => {
const raw = JSON.stringify({ 'csp-report': { 'violated-directive': 'a'.repeat(1000) } });
expect(parseCspReports(raw)[0].directive.length).toBe(200);
});
});

describe('stripUrl', () => {
it('leaves non-URLs alone', () => {
expect(stripUrl('inline')).toBe('inline');
});
});

describe('readCappedBody', () => {
it('reads small bodies', async () => {
expect(await readCappedBody(stream('hello'))).toBe('hello');
});
it('returns null for oversized bodies', async () => {
expect(await readCappedBody(stream('x'.repeat(20)), 10)).toBeNull();
});
});
110 changes: 110 additions & 0 deletions src/lib/csp-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Parsing helpers for Content-Security-Policy violation reports.
*
* Browsers send two formats:
* - legacy `report-uri`: Content-Type `application/csp-report`, body
* `{"csp-report": {"document-uri": ..., "violated-directive": ..., ...}}`
* - Reporting API `report-to`: Content-Type `application/reports+json`, body
* `[{"type": "csp-violation", "url": ..., "body": {"documentURL": ..., "effectiveDirective": ..., ...}}]`
*/

export const CSP_REPORT_MAX_BYTES = 10 * 1024;
const MAX_REPORTS = 10;
const MAX_FIELD = 200;

export interface CompactCspViolation {
directive: string;
blocked: string;
page: string;
source?: string;
disposition?: string;
}

function str(v: unknown): string {
if (typeof v === 'string') return v.slice(0, MAX_FIELD);
if (typeof v === 'number') return String(v);
return '';
}

/** Strip query/fragment from URLs so tokens and PII in query strings aren't logged. */
export function stripUrl(v: string): string {
if (!/^https?:\/\//i.test(v)) return v;
try {
const u = new URL(v);
return `${u.origin}${u.pathname}`.slice(0, MAX_FIELD);
} catch {
return v.slice(0, MAX_FIELD);
}
}

function compact(r: Record<string, unknown>): CompactCspViolation | null {
const directive = str(r['effectiveDirective'] ?? r['effective-directive'] ?? r['violated-directive']);
if (!directive) return null;
const blocked = stripUrl(str(r['blockedURL'] ?? r['blocked-uri']));
const page = stripUrl(str(r['documentURL'] ?? r['document-uri']));
const file = stripUrl(str(r['sourceFile'] ?? r['source-file']));
const line = str(r['lineNumber'] ?? r['line-number']);
const out: CompactCspViolation = { directive, blocked: blocked || '(none)', page };
if (file) out.source = line ? `${file}:${line}` : file;
const disposition = str(r['disposition']);
if (disposition) out.disposition = disposition;
return out;
}

/** Parse a raw CSP report body (either format) into compact violations. */
export function parseCspReports(raw: string): CompactCspViolation[] {
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
return [];
}
const out: CompactCspViolation[] = [];
if (Array.isArray(data)) {
for (const item of data.slice(0, MAX_REPORTS)) {
if (!item || typeof item !== 'object') continue;
const rec = item as Record<string, unknown>;
if (rec.type !== 'csp-violation') continue;
const body = rec.body;
if (body && typeof body === 'object') {
const c = compact(body as Record<string, unknown>);
if (c) out.push(c);
}
}
} else if (data && typeof data === 'object') {
const body = (data as Record<string, unknown>)['csp-report'];
if (body && typeof body === 'object') {
const c = compact(body as Record<string, unknown>);
if (c) out.push(c);
}
}
return out;
}

/** Read a request body as text, aborting once it exceeds maxBytes. Returns null if too large. */
export async function readCappedBody(
body: ReadableStream<Uint8Array> | null,
maxBytes = CSP_REPORT_MAX_BYTES
): Promise<string | null> {
if (!body) return '';
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => {});
return null;
}
chunks.push(value);
}
const buf = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
buf.set(c, off);
off += c.byteLength;
}
return new TextDecoder().decode(buf);
}
Loading