diff --git a/next.config.ts b/next.config.ts index 5e16817..11d675e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,17 +6,25 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const discordInviteUrl = "https://discord.gg/MV3E9dSUD"; const localePattern = locales.join("|"); -// Report-Only CSP: logs violations to the browser console without blocking +// Report-Only CSP: reports violations to /api/csp-report without blocking // anything. Origins cover Stripe, PayPal, PostHog, Sentry, and the WidgetBot // Discord embed. Promote to an enforcing Content-Security-Policy header once // it has been verified clean against real checkout/analytics traffic. // +// A report-only policy with no reporting directive is inert (the browser warns +// "the policy will have no effect"), so both reporting mechanisms are wired up: +// - report-to → the "csp-endpoint" named in the Reporting-Endpoints header +// below (Chromium / Reporting API) +// - report-uri → a direct path fallback for Safari/Firefox, which don't yet +// honour report-to. Browsers that support report-to ignore report-uri. +// // PostHog is self-hosted at ph.wcpos.com (NEXT_PUBLIC_POSTHOG_HOST) — the // browser never talks to PostHog Cloud (*.posthog.com), so that origin is not // listed. The remote-config/recorder scripts (script) and capture/flags calls // (connect) both target the self-hosted origin directly; there is no /ingest // first-party proxy. analytics.wcpos.com is the Umami instance — nothing on // the site loads it today, but it stays allowed for a future re-add. +const CSP_REPORT_PATH = "/api/csp-report"; const cspReportOnly = [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://www.paypalobjects.com https://ph.wcpos.com https://analytics.wcpos.com https://challenges.cloudflare.com", @@ -28,6 +36,8 @@ const cspReportOnly = [ "object-src 'none'", "base-uri 'self'", "form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com", + `report-uri ${CSP_REPORT_PATH}`, + "report-to csp-endpoint", ].join("; "); const securityHeaders = [ @@ -47,6 +57,13 @@ const securityHeaders = [ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), browsing-topics=()", }, + // Names the "csp-endpoint" group that the CSP's report-to directive targets + // (Reporting API). Same-origin path so it follows whichever host served the + // page (wcpos.com / www). + { + key: "Reporting-Endpoints", + value: `csp-endpoint="${CSP_REPORT_PATH}"`, + }, { key: "Content-Security-Policy-Report-Only", value: cspReportOnly }, ]; diff --git a/src/app/api/csp-report/route.test.ts b/src/app/api/csp-report/route.test.ts new file mode 100644 index 0000000..f78fb9e --- /dev/null +++ b/src/app/api/csp-report/route.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// vi.mock is hoisted above module-scope consts, so the mock fns must be +// created inside vi.hoisted() to be available when the factory runs. +const { warnMock, consumeMock } = vi.hoisted(() => ({ + warnMock: vi.fn(), + consumeMock: vi.fn(), +})) +vi.mock('@/lib/logger', () => ({ + infraLogger: { warn: warnMock }, +})) +vi.mock('@/lib/rate-limit', () => ({ + createRateLimiter: () => ({ consume: (...args: unknown[]) => consumeMock(...args) }), + clientIp: () => '1.2.3.4', +})) + +import { POST } from './route' + +function req(body: unknown, contentType = 'application/csp-report') { + return new Request('http://localhost/api/csp-report', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) +} + +function oversizedStreamingReq() { + const encoder = new TextEncoder() + let pulledPastOversize = false + const body = new ReadableStream( + { + start(controller) { + controller.enqueue(encoder.encode('x'.repeat(8_193))) + }, + pull(controller) { + pulledPastOversize = true + controller.enqueue( + encoder.encode(JSON.stringify({ 'csp-report': { 'violated-directive': 'script-src' } })) + ) + controller.close() + }, + }, + { highWaterMark: 0 } + ) + + const request = new Request('http://localhost/api/csp-report', { + method: 'POST', + headers: { 'Content-Type': 'application/csp-report' }, + body, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) + + return { request, pulledPastOversize: () => pulledPastOversize } +} + +// Concatenated tagged-template args (strings + interpolated values) for call N. +function loggedText(n = 0): string { + return warnMock.mock.calls[n].flat().join('|') +} + +describe('POST /api/csp-report', () => { + beforeEach(() => { + warnMock.mockReset() + consumeMock.mockReset() + consumeMock.mockResolvedValue({ success: true, remaining: 59 }) + }) + + it('logs a legacy report-uri (application/csp-report) violation and returns 204', async () => { + const res = await POST( + req({ + 'csp-report': { + 'document-uri': 'https://wcpos.com/pro', + 'violated-directive': 'script-src', + 'blocked-uri': 'https://evil.example/x.js', + }, + }) + ) + expect(res.status).toBe(204) + expect(warnMock).toHaveBeenCalledTimes(1) + const text = loggedText() + expect(text).toContain('script-src') + expect(text).toContain('https://evil.example/x.js') + expect(text).toContain('https://wcpos.com/pro') + }) + + it('logs each violation in a modern report-to (Reporting API) array', async () => { + const res = await POST( + req( + [ + { + type: 'csp-violation', + body: { + documentURL: 'https://wcpos.com/', + effectiveDirective: 'connect-src', + blockedURL: 'https://evil.example/beacon', + }, + }, + { type: 'deprecation', body: {} }, + { + type: 'csp-violation', + body: { effectiveDirective: 'img-src', blockedURL: 'http://insecure/pic.png' }, + }, + ], + 'application/reports+json' + ) + ) + expect(res.status).toBe(204) + // Only the two csp-violation entries are logged; the deprecation is ignored. + expect(warnMock).toHaveBeenCalledTimes(2) + expect(loggedText(0)).toContain('connect-src') + expect(loggedText(1)).toContain('img-src') + }) + + it('strips newlines/control chars from report fields (log-injection guard)', async () => { + await POST( + req({ + 'csp-report': { + 'document-uri': 'https://wcpos.com/\n@everyone', + 'violated-directive': 'script-src', + 'blocked-uri': 'inline', + }, + }) + ) + expect(warnMock).toHaveBeenCalledTimes(1) + expect(loggedText()).not.toMatch(/[\r\n]/) + }) + + it('strips query strings and fragments from reported URLs before logging', async () => { + await POST( + req({ + 'csp-report': { + 'document-uri': 'https://wcpos.com/reset-password?token=secret&email=user@example.com#frag', + 'violated-directive': 'script-src', + 'blocked-uri': 'https://evil.example/x.js?session=secret#frag', + }, + }) + ) + expect(warnMock).toHaveBeenCalledTimes(1) + const text = loggedText() + expect(text).toContain('https://wcpos.com/reset-password') + expect(text).toContain('https://evil.example/x.js') + expect(text).not.toContain('token=secret') + expect(text).not.toContain('email=user@example.com') + expect(text).not.toContain('session=secret') + expect(text).not.toContain('#frag') + }) + + it('caps logged violations from a modern batched report', async () => { + const violations = Array.from({ length: 12 }, (_, i) => ({ + type: 'csp-violation', + body: { + effectiveDirective: `script-src-${i}`, + blockedURL: `https://evil.example/${i}.js`, + }, + })) + + const res = await POST(req(violations, 'application/reports+json')) + + expect(res.status).toBe(204) + expect(warnMock).toHaveBeenCalledTimes(10) + expect(loggedText(9)).toContain('script-src-9') + }) + + it('drops over-limit callers silently (204, no log)', async () => { + consumeMock.mockResolvedValueOnce({ success: false, remaining: 0 }) + const res = await POST( + req({ 'csp-report': { 'violated-directive': 'script-src', 'blocked-uri': 'x' } }) + ) + expect(res.status).toBe(204) + expect(warnMock).not.toHaveBeenCalled() + }) + + it('tolerates malformed JSON', async () => { + const res = await POST(req('{', 'application/csp-report')) + expect(res.status).toBe(204) + expect(warnMock).not.toHaveBeenCalled() + }) + + it('ignores an unrecognised payload shape without logging', async () => { + const res = await POST(req({ hello: 'world' })) + expect(res.status).toBe(204) + expect(warnMock).not.toHaveBeenCalled() + }) + + it('drops payloads over the declared content-length cap', async () => { + const big = new Request('http://localhost/api/csp-report', { + method: 'POST', + headers: { 'Content-Type': 'application/csp-report', 'content-length': '99999' }, + body: JSON.stringify({ 'csp-report': { 'blocked-uri': 'x'.repeat(50) } }), + }) + const res = await POST(big) + expect(res.status).toBe(204) + expect(warnMock).not.toHaveBeenCalled() + }) + + it('stops reading an actually oversized streamed body after the byte cap', async () => { + const { request, pulledPastOversize } = oversizedStreamingReq() + + const res = await POST(request) + + expect(res.status).toBe(204) + expect(warnMock).not.toHaveBeenCalled() + expect(pulledPastOversize()).toBe(false) + }) +}) diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts new file mode 100644 index 0000000..6005b7c --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -0,0 +1,147 @@ +import { NextResponse } from 'next/server' +import { infraLogger } from '@/lib/logger' +import { createRateLimiter, clientIp } from '@/lib/rate-limit' + +/** + * CSP violation report sink. + * + * The site ships a Content-Security-Policy-Report-Only header (see + * next.config.ts). Report-only never blocks — its only job is to POST a report + * here whenever the page does something the policy would forbid, so we can + * verify the policy is clean against real checkout/analytics traffic before + * promoting it to an enforcing header. + * + * Two wire formats reach this route: + * - report-uri (Safari/Firefox, legacy) → Content-Type application/csp-report, + * body { "csp-report": { "violated-directive", "blocked-uri", ... } } + * - report-to (Chromium, Reporting API) → Content-Type application/reports+json, + * body [ { "type": "csp-violation", "body": { "effectiveDirective", "blockedURL", ... } } ] + * + * Reports go to infraLogger (Loki/Sentry) — NOT the sale/Discord path — since a + * CSP violation is diagnostic, not something to page anyone about. Always + * returns 204: a broken/abusive report must never surface to the visitor. + */ + +// Real reports are a few hundred bytes. Reject anything larger so this +// unauthenticated endpoint can't be used to push large bodies into the logs. +const MAX_BODY_BYTES = 8_192 +const MAX_VIOLATIONS_PER_REPORT = 10 + +// Unauthenticated, browser-driven endpoint — cap abuse per IP. A page with +// violations may emit a small burst on load; 60/min leaves headroom while +// stopping a spammer from flooding the log sink. Fail-open. +const limiter = createRateLimiter({ + prefix: 'csp:report:ip', + limit: 60, + window: '1 m', +}) + +/** + * Collapse an attacker-influenced report field to a single bounded line so a + * caller can't inject newlines/control chars into the log message. + */ +function sanitizeField(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) return 'unknown' + return value.replace(/[\r\n\t]+/g, ' ').replace(/[^\x20-\x7e]/g, '').slice(0, 256) +} + +function sanitizeUrlField(value: unknown): string { + const sanitized = sanitizeField(value) + try { + const url = new URL(sanitized) + url.search = '' + url.hash = '' + return url.toString() + } catch { + return sanitized + } +} + +async function readBodyUnderLimit(request: Request): Promise { + if (!request.body) return '' + + const reader = request.body.getReader() + const decoder = new TextDecoder() + let bytes = 0 + let raw = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + bytes += value.byteLength + if (bytes > MAX_BODY_BYTES) { + await reader.cancel() + return null + } + raw += decoder.decode(value, { stream: true }) + } + } finally { + reader.releaseLock() + } + + return raw + decoder.decode() +} + +interface NormalizedViolation { + directive: string + blockedUri: string + documentUri: string +} + +/** Normalize both the legacy report-uri and modern report-to payload shapes. */ +function extractViolations(body: unknown): NormalizedViolation[] { + // Legacy report-uri: { "csp-report": {...} } + if (body && typeof body === 'object' && 'csp-report' in body) { + const r = (body as Record>)['csp-report'] ?? {} + return [ + { + directive: sanitizeField(r['violated-directive'] ?? r['effective-directive']), + blockedUri: sanitizeUrlField(r['blocked-uri']), + documentUri: sanitizeUrlField(r['document-uri']), + }, + ] + } + + // Reporting API report-to: an array of report objects. + if (Array.isArray(body)) { + return body + .filter((r) => r && typeof r === 'object' && r.type === 'csp-violation') + .slice(0, MAX_VIOLATIONS_PER_REPORT) + .map((r) => { + const b = (r.body ?? {}) as Record + return { + directive: sanitizeField(b.effectiveDirective ?? b.violatedDirective), + blockedUri: sanitizeUrlField(b.blockedURL), + documentUri: sanitizeUrlField(b.documentURL), + } + }) + } + + return [] +} + +export async function POST(request: Request): Promise { + const noContent = new NextResponse(null, { status: 204 }) + try { + const declaredLength = Number(request.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) { + return noContent + } + + // Over-limit callers are silently dropped (still 204) — never log them. + const { success } = await limiter.consume(clientIp(request)) + if (!success) return noContent + + const raw = await readBodyUnderLimit(request) + if (raw === null) return noContent + + const violations = extractViolations(JSON.parse(raw)) + for (const v of violations) { + infraLogger.warn`CSP violation: directive=${v.directive} blocked=${v.blockedUri} document=${v.documentUri}` + } + } catch { + // Malformed body — swallow. Report collection must never 500 the browser. + } + return noContent +}