feat(security): ship CSP in Report-Only mode via Clerk's middleware option

Replaces the dead hand-rolled buildCsp() draft in next.config.ts (never
wired in — its script-src had no 'unsafe-inline'/nonce, which blocked
Next.js's own hydration script and blanked the whole app when tested
live on 2026-08-10) with Clerk's official contentSecurityPolicy option
on clerkMiddleware(). This is Clerk's documented workaround: its default
directives already include the permissive script-src Next.js App Router
needs, merged with our app-specific img-src/connect-src/media-src/
font-src/frame-ancestors/object-src/base-uri additions (Supabase, Mux,
litix).

Ships as Content-Security-Policy-Report-Only (reportOnly: true) with
both reporting mechanisms wired to a new /api/csp-report route handler:
Clerk's reportTo (Reporting API) and a classic report-uri (for browsers
without Reporting API support). The route normalizes both report shapes,
rate-limits by IP, and forwards violations to Sentry/GlitchTip as
warnings — never blocks the request.

Deliberately NOT enforced yet — move to reportOnly: false only after a
clean observation period with zero unexpected violations.

Gitea #5
This commit is contained in:
admin committed 2026-08-12 13:42:08 +02:00
1 parent d086fc67f0
commit 13513583bc
3 files changed
+163 -63

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { checkRateLimit } from "@/lib/rate-limit";
/**
* POST /api/csp-report
*
* Receives CSP violation reports during the Report-Only rollout (see
* contentSecurityPolicy in src/proxy.ts). Two report shapes land here
* simultaneously since Clerk's middleware wires up both mechanisms:
* - legacy `report-uri`: Content-Type: application/csp-report, body is
* { "csp-report": {...} }
* - Reporting API `report-to`: Content-Type: application/reports+json,
* body is an array of { type: "csp-violation", body: {...} }
*
* Purely observational — never blocks the request, always returns 204.
*/
interface NormalizedViolation {
[key: string]: string | undefined;
documentUri?: string;
violatedDirective?: string;
blockedUri?: string;
disposition?: string;
sourceFile?: string;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function normalizeReports(payload: unknown): NormalizedViolation[] {
if (Array.isArray(payload)) {
return payload
.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
.map((entry) => {
const body = (entry.body ?? {}) as Record<string, unknown>;
return {
documentUri: asString(body.documentURL),
violatedDirective: asString(body.effectiveDirective),
blockedUri: asString(body.blockedURL),
disposition: asString(body.disposition),
sourceFile: asString(body.sourceFile),
};
});
}
if (payload && typeof payload === "object" && "csp-report" in payload) {
const report = (payload as Record<string, unknown>)["csp-report"] as Record<string, unknown>;
return [
{
documentUri: asString(report["document-uri"]),
violatedDirective: asString(report["violated-directive"]),
blockedUri: asString(report["blocked-uri"]),
disposition: asString(report.disposition),
sourceFile: asString(report["source-file"]),
},
];
}
return [];
}
const MAX_BODY_BYTES = 20_000;
export async function POST(req: NextRequest) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const { success } = await checkRateLimit({
key: `csp-report:${ip}`,
limit: 30,
windowSeconds: 60,
});
if (!success) {
return new NextResponse(null, { status: 429 });
}
const raw = await req.text();
if (raw.length > MAX_BODY_BYTES) {
return new NextResponse(null, { status: 413 });
}
let payload: unknown;
try {
payload = raw ? JSON.parse(raw) : null;
} catch {
return new NextResponse(null, { status: 204 });
}
for (const violation of normalizeReports(payload)) {
Sentry.captureMessage(`CSP violation: ${violation.violatedDirective ?? "unknown directive"}`, {
level: "warning",
tags: { source: "csp-report" },
extra: violation,
});
}
return new NextResponse(null, { status: 204 });
}