diff --git a/next.config.ts b/next.config.ts index faaf535..a0c06cb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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: [ @@ -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*', @@ -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 }, ], }, ]; diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts new file mode 100644 index 0000000..3f6811b --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -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 }); +} diff --git a/src/lib/__tests__/csp-report.test.ts b/src/lib/__tests__/csp-report.test.ts new file mode 100644 index 0000000..47b1914 --- /dev/null +++ b/src/lib/__tests__/csp-report.test.ts @@ -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(); + }); +}); diff --git a/src/lib/csp-report.ts b/src/lib/csp-report.ts new file mode 100644 index 0000000..6b7bbd3 --- /dev/null +++ b/src/lib/csp-report.ts @@ -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): 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; + if (rec.type !== 'csp-violation') continue; + const body = rec.body; + if (body && typeof body === 'object') { + const c = compact(body as Record); + if (c) out.push(c); + } + } + } else if (data && typeof data === 'object') { + const body = (data as Record)['csp-report']; + if (body && typeof body === 'object') { + const c = compact(body as Record); + 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 | null, + maxBytes = CSP_REPORT_MAX_BYTES +): Promise { + 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); +}