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:
1 parent
d086fc67f0
commit
13513583bc
3 files changed
+163
-63
No files matched your search
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user