From 7b499bb564765cb01eec9fc8799f17b74cc01f8c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:39:25 +0700 Subject: [PATCH 1/2] feat(web): report-only content security policy with a per-request script nonce Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- apps/web/src/app/api/csp-report/route.ts | 51 +++++++++ apps/web/src/lib/__tests__/csp.test.ts | 126 +++++++++++++++++++++++ apps/web/src/lib/csp.ts | 68 ++++++++++++ apps/web/src/proxy.ts | 16 ++- 5 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/csp-report/route.ts create mode 100644 apps/web/src/lib/__tests__/csp.test.ts create mode 100644 apps/web/src/lib/csp.ts diff --git a/apps/web/package.json b/apps/web/package.json index 7aa1380c..6613417f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/api/csp-report/route.ts b/apps/web/src/app/api/csp-report/route.ts new file mode 100644 index 00000000..5191e103 --- /dev/null +++ b/apps/web/src/app/api/csp-report/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('csp'); + +// Browsers post a few hundred bytes; anything larger is not a report. +const MAX_REPORT_BYTES = 8 * 1024; + +/** The path of a URL, or the scheme/keyword a CSP report gives ("inline", "eval"). */ +function shortUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || !value) return undefined; + try { + const url = new URL(value); + return `${url.origin}${url.pathname}`.slice(0, 200); + } catch { + return value.slice(0, 40); + } +} + +/** + * POST /api/csp-report: violation reports of the report-only Content Security + * Policy (DOS/Rate#1311). Logged without query strings, so the report window + * can be read from the logs before the policy is enforced. Always 204. + */ +export async function POST(request: Request) { + try { + const length = Number(request.headers.get('content-length') ?? 0); + if (length > MAX_REPORT_BYTES) { + return new NextResponse(null, { status: 204 }); + } + const text = (await request.text()).slice(0, MAX_REPORT_BYTES); + const parsed = JSON.parse(text) as { + 'csp-report'?: Record; + }; + const report = parsed?.['csp-report']; + if (report && typeof report === 'object') { + log.warn('csp violation', { + directive: String( + report['effective-directive'] ?? report['violated-directive'] ?? '' + ).slice(0, 60), + blocked: shortUrl(report['blocked-uri']), + page: shortUrl(report['document-uri']), + source: shortUrl(report['source-file']), + line: Number(report['line-number']) || undefined, + }); + } + } catch { + // Not a report: nothing to log. + } + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/src/lib/__tests__/csp.test.ts b/apps/web/src/lib/__tests__/csp.test.ts new file mode 100644 index 00000000..73e46b90 --- /dev/null +++ b/apps/web/src/lib/__tests__/csp.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; + +// The report-only Content Security Policy (DOS/Rate#1311): a nonce per request on +// both the request (for Next.js) and the response, a script-src with no inline +// allowance, and a report endpoint that only logs. + +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://abc.supabase.co'; + +async function main() { + const { buildContentSecurityPolicy, createCspNonce, CSP_HEADER } = + await import('../csp'); + + const a = createCspNonce(); + const b = createCspNonce(); + assert.notEqual(a, b, 'a fresh nonce per request'); + assert.match(a, /^[A-Za-z0-9+/]{22}==$/, '128 random bits, base64'); + + const policy = buildContentSecurityPolicy('n0nce', { + supabaseUrl: 'https://abc.supabase.co/rest/v1', + }); + const directive = (name: string) => + policy + .split('; ') + .find((d) => d.startsWith(`${name} `)) + ?.slice(name.length + 1); + assert.equal( + directive('script-src'), + "'self' 'nonce-n0nce' 'strict-dynamic'", + 'scripts: the nonce, never unsafe-inline or unsafe-eval in production' + ); + assert.equal(directive('default-src'), "'self'"); + assert.equal( + directive('connect-src'), + "'self' https://abc.supabase.co wss://abc.supabase.co" + ); + assert.equal(directive('object-src'), "'none'"); + assert.equal(directive('frame-ancestors'), "'self'"); + assert.equal(directive('report-uri'), '/api/csp-report'); + assert.match( + buildContentSecurityPolicy('x', { dev: true }), + /script-src 'self' 'nonce-x' 'strict-dynamic' 'unsafe-eval'/ + ); + assert.doesNotMatch( + buildContentSecurityPolicy('x', { supabaseUrl: 'javascript:alert(1)' }), + /javascript/, + 'only an http(s) Supabase URL is added' + ); + + // The proxy: the same policy on the response and on the forwarded request. + const { NextRequest } = await import('next/server'); + const { localeProxy } = await import('../../proxy'); + const res = localeProxy( + new NextRequest('https://rate.vn/smartphones', { + headers: { host: 'rate.vn', 'x-forwarded-proto': 'https' }, + }) + ); + const sent = res.headers.get(CSP_HEADER); + assert.ok(sent, 'pages carry the report-only policy'); + assert.match( + sent, + /script-src 'self' 'nonce-[A-Za-z0-9+/=]+' 'strict-dynamic'/ + ); + assert.equal( + res.headers.get(`x-middleware-request-${CSP_HEADER.toLowerCase()}`), + sent, + 'Next.js reads the same nonce from the request' + ); + assert.equal( + res.headers.get('content-security-policy'), + null, + 'nothing is enforced yet' + ); + const res2 = localeProxy( + new NextRequest('https://rate.vn/smartphones', { + headers: { host: 'rate.vn', 'x-forwarded-proto': 'https' }, + }) + ); + assert.notEqual(res2.headers.get(CSP_HEADER), sent, 'one nonce per request'); + + // The report endpoint logs a report and answers 204 whatever it gets. + const { POST } = await import('../../app/api/csp-report/route'); + const lines: string[] = []; + // The logger writes warnings to stderr. + const original = console.error; + console.error = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + try { + const ok = await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + headers: { 'content-type': 'application/csp-report' }, + body: JSON.stringify({ + 'csp-report': { + 'document-uri': 'https://rate.vn/smartphones?email=a@b.c', + 'effective-directive': 'script-src-elem', + 'blocked-uri': 'inline', + 'line-number': 3, + }, + }), + }) + ); + assert.equal(ok.status, 204); + const bad = await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: 'not json', + }) + ); + assert.equal(bad.status, 204); + } finally { + console.error = original; + } + const logged = lines.join('\n'); + assert.match(logged, /csp violation/); + assert.match(logged, /script-src-elem/); + assert.match(logged, /https:\/\/rate\.vn\/smartphones/); + assert.doesNotMatch(logged, /email=/, 'no query string is logged'); + + console.warn(' ✅ report-only CSP with a per-request nonce (#1311)'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/lib/csp.ts b/apps/web/src/lib/csp.ts new file mode 100644 index 00000000..e3358b55 --- /dev/null +++ b/apps/web/src/lib/csp.ts @@ -0,0 +1,68 @@ +/** + * Content Security Policy for the pages (DOS/Rate#1311). The proxy sends it as + * `Content-Security-Policy-Report-Only` with a fresh nonce per request, and puts + * the same header on the request so Next.js adds the nonce to its own scripts. + * Enforce it (rename the header) only after a clean report window. + * + * - `script-src`: the nonce plus `'strict-dynamic'`, so a script the nonced + * Next.js runtime loads (chunks, `/_vercel/*` analytics if added) is allowed + * and no other inline or injected script is. JSON-LD blocks + * (`type="application/ld+json"`) are data, not scripts, and need nothing. + * - Styles stay `'unsafe-inline'`: React `style` attributes and Next.js inline + * styles have no nonce. + * - Images come from any HTTPS shop CDN (as `images.remotePatterns`). + * - `connect-src` adds the Supabase project for the browser auth client. + */ +export const CSP_HEADER = 'Content-Security-Policy-Report-Only'; +export const CSP_REPORT_PATH = '/api/csp-report'; + +/** A base64 nonce from 128 random bits. */ +export function createCspNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +/** The origin of an http(s) URL, or null. */ +function originOf(url: string | undefined): string | null { + if (!url) return null; + try { + const { origin, protocol } = new URL(url); + return protocol === 'https:' || protocol === 'http:' ? origin : null; + } catch { + return null; + } +} + +export function buildContentSecurityPolicy( + nonce: string, + options: { dev?: boolean; supabaseUrl?: string } = {} +): string { + const supabase = originOf(options.supabaseUrl); + const connect = ["'self'"]; + if (supabase) { + connect.push(supabase, supabase.replace(/^http/, 'ws')); + } + if (options.dev) connect.push('ws:'); + const script = ["'self'", `'nonce-${nonce}'`, "'strict-dynamic'"]; + // React Refresh evaluates code in development. + if (options.dev) script.push("'unsafe-eval'"); + return [ + "default-src 'self'", + `script-src ${script.join(' ')}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + `connect-src ${connect.join(' ')}`, + "frame-src 'self'", + "worker-src 'self' blob:", + "manifest-src 'self'", + "frame-ancestors 'self'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'none'", + `report-uri ${CSP_REPORT_PATH}`, + ].join('; '); +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 5f1619c5..d428e4bc 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import createMiddleware from 'next-intl/middleware'; +import { + CSP_HEADER, + buildContentSecurityPolicy, + createCspNonce, +} from './lib/csp'; import { isDevelopment } from './lib/general-helpers'; import { routing } from './lib/navigation'; import { @@ -88,7 +93,16 @@ export function localeProxy(req: NextRequest): NextResponse { req.cookies.set('NEXT_LOCALE', routing.defaultLocale); } - return intlMiddleware(req); + // A nonce per request: on the request so Next.js puts it on its scripts (it + // reads the report-only header too), and on the response (DOS/Rate#1311). + const csp = buildContentSecurityPolicy(createCspNonce(), { + dev: isDevelopment(), + supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, + }); + req.headers.set(CSP_HEADER, csp); + const response = intlMiddleware(req); + response.headers.set(CSP_HEADER, csp); + return response; } export async function proxy(req: NextRequest): Promise { From e9bf3a0630ab76f5b3d68ec0433485e8f181544b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:45:52 +0700 Subject: [PATCH 2/2] fix(web): csp report endpoint caps streamed bodies, validates and dedupes before logging Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/api/csp-report/route.ts | 89 ++++++++++++++++++++---- apps/web/src/lib/__tests__/csp.test.ts | 48 +++++++++++++ 2 files changed, 123 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/api/csp-report/route.ts b/apps/web/src/app/api/csp-report/route.ts index 5191e103..b7cc7566 100644 --- a/apps/web/src/app/api/csp-report/route.ts +++ b/apps/web/src/app/api/csp-report/route.ts @@ -5,6 +5,11 @@ const log = createLogger('csp'); // Browsers post a few hundred bytes; anything larger is not a report. const MAX_REPORT_BYTES = 8 * 1024; +// One log line per distinct violation per window and instance, at most this many +// distinct ones: an endpoint anyone can post to must not flood the logs. +const DEDUPE_WINDOW_MS = 10 * 60 * 1000; +const MAX_DISTINCT = 200; +const seen = new Map(); /** The path of a URL, or the scheme/keyword a CSP report gives ("inline", "eval"). */ function shortUrl(value: unknown): string | undefined { @@ -17,30 +22,86 @@ function shortUrl(value: unknown): string | undefined { } } +/** The body as text, or null once it passes `limit` bytes (stops reading there). */ +async function readCapped( + request: Request, + limit: number +): Promise { + const declared = Number(request.headers.get('content-length') ?? 0); + if (declared > limit) return null; + if (!request.body) return ''; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + const all = new Uint8Array(size); + let at = 0; + for (const c of chunks) { + all.set(c, at); + at += c.byteLength; + } + return new TextDecoder().decode(all); +} + +/** True the first time a key is seen in the window, while the table has room. */ +function firstInWindow(key: string, now: number): boolean { + const last = seen.get(key); + if (last !== undefined && now - last < DEDUPE_WINDOW_MS) return false; + if (last === undefined && seen.size >= MAX_DISTINCT) { + for (const [k, t] of seen) { + if (now - t >= DEDUPE_WINDOW_MS) seen.delete(k); + } + if (seen.size >= MAX_DISTINCT) return false; + } + seen.set(key, now); + return true; +} + /** * POST /api/csp-report: violation reports of the report-only Content Security - * Policy (DOS/Rate#1311). Logged without query strings, so the report window - * can be read from the logs before the policy is enforced. Always 204. + * Policy (DOS/Rate#1311). A report needs a directive and a page on this site's + * origin; each distinct one is logged once per window, without query strings, so + * the report window can be read from the logs before the policy is enforced. + * Always 204. */ export async function POST(request: Request) { try { - const length = Number(request.headers.get('content-length') ?? 0); - if (length > MAX_REPORT_BYTES) { - return new NextResponse(null, { status: 204 }); - } - const text = (await request.text()).slice(0, MAX_REPORT_BYTES); + const text = await readCapped(request, MAX_REPORT_BYTES); + if (!text) return new NextResponse(null, { status: 204 }); const parsed = JSON.parse(text) as { 'csp-report'?: Record; }; const report = parsed?.['csp-report']; - if (report && typeof report === 'object') { + if (!report || typeof report !== 'object') { + return new NextResponse(null, { status: 204 }); + } + const directive = String( + report['effective-directive'] ?? report['violated-directive'] ?? '' + ).slice(0, 60); + const page = shortUrl(report['document-uri']); + const origin = new URL(request.url).origin; + if (!/^[a-z-]+$/.test(directive) || !page?.startsWith(`${origin}/`)) { + return new NextResponse(null, { status: 204 }); + } + const blocked = shortUrl(report['blocked-uri']); + const source = shortUrl(report['source-file']); + if ( + firstInWindow(`${directive}|${blocked}|${page}|${source}`, Date.now()) + ) { log.warn('csp violation', { - directive: String( - report['effective-directive'] ?? report['violated-directive'] ?? '' - ).slice(0, 60), - blocked: shortUrl(report['blocked-uri']), - page: shortUrl(report['document-uri']), - source: shortUrl(report['source-file']), + directive, + blocked, + page, + source, line: Number(report['line-number']) || undefined, }); } diff --git a/apps/web/src/lib/__tests__/csp.test.ts b/apps/web/src/lib/__tests__/csp.test.ts index 73e46b90..7cb3e499 100644 --- a/apps/web/src/lib/__tests__/csp.test.ts +++ b/apps/web/src/lib/__tests__/csp.test.ts @@ -108,6 +108,54 @@ async function main() { }) ); assert.equal(bad.status, 204); + const before = lines.length; + // The same violation again is not logged twice. + await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: JSON.stringify({ + 'csp-report': { + 'document-uri': 'https://rate.vn/smartphones', + 'effective-directive': 'script-src-elem', + 'blocked-uri': 'inline', + }, + }), + }) + ); + // A report without a directive, or for another site's page, is not logged. + for (const report of [ + {}, + { + 'document-uri': 'https://evil.example/x', + 'effective-directive': 'script-src-elem', + }, + ]) { + await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: JSON.stringify({ 'csp-report': report }), + }) + ); + } + // A streamed body with no Content-Length stops being read past 8 KB. + let pulled = 0; + const big = new ReadableStream({ + pull(controller) { + pulled += 1024; + if (pulled > 256 * 1024) controller.close(); + else controller.enqueue(new Uint8Array(1024).fill(32)); + }, + }); + const huge = await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: big, + duplex: 'half', + } as RequestInit) + ); + assert.equal(huge.status, 204); + assert.ok(pulled <= 16 * 1024, `read ${pulled} bytes of an oversized body`); + assert.equal(lines.length, before, 'no further log line'); } finally { console.error = original; }