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

-42
View File
@@ -12,48 +12,6 @@ function getSupabaseHostname(): string {
return "*.supabase.co";
}
// Drafted but NOT wired into headers() yet — enabling it blanked the entire
// app (ClerkProvider wraps the whole tree in layout.tsx; a blocked resource
// during its client-side init throws, and nothing catches it, so the app
// crashes to a blank page instead of just breaking the login widget).
// Confirmed live on 2026-08-10 that disabling this CSP fixes it; adding
// wss://clerk.pawfeed.org to connect-src did NOT fix it, so the real blocked
// directive is still unidentified. Next attempt should ship as
// Content-Security-Policy-Report-Only first (with a /api/csp-report endpoint)
// to see actual violation reports before enforcing on production again.
function buildCsp(): string {
const supabase = `https://${getSupabaseHostname()}`;
const directives: Record<string, string[]> = {
"default-src": ["'self'"],
"script-src": ["'self'", "https://clerk.pawfeed.org"],
// Radix/shadcn primitives set inline style="" attributes for positioning —
// no nonce mechanism covers style attributes, so 'unsafe-inline' is required here.
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", supabase, "https://image.mux.com", "https://img.clerk.com"],
"font-src": ["'self'", "data:"],
"media-src": ["'self'", "blob:", "https://stream.mux.com"],
"worker-src": ["'self'", "blob:"],
"connect-src": [
"'self'",
"https://clerk.pawfeed.org",
"wss://clerk.pawfeed.org",
"https://accounts.pawfeed.org",
supabase,
"https://stream.mux.com",
"https://image.mux.com",
"https://litix.io",
"https://storage.googleapis.com",
],
"frame-src": ["'self'", "https://clerk.pawfeed.org"],
"frame-ancestors": ["'none'"],
"object-src": ["'none'"],
"base-uri": ["'self'"],
};
return Object.entries(directives)
.map(([key, values]) => `${key} ${values.join(" ")}`)
.join("; ");
}
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.92"],
+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 });
}
+65 -21
View File
@@ -18,36 +18,80 @@ const isPublicRoute = createRouteMatcher([
"/api/webhooks/(.*)",
"/api/auth/(.*)",
"/api/cron/(.*)",
"/api/csp-report",
]);
export default clerkMiddleware(async (auth, request) => {
const { pathname } = request.nextUrl;
// Invite gate: block /sign-up unless the visitor has a valid invite cookie.
// Set INVITE_REQUIRED=false in env to disable for the live/post-beta launch.
if (pathname.startsWith("/sign-up")) {
const inviteRequired = process.env.INVITE_REQUIRED !== "false";
if (inviteRequired && !request.cookies.has("pf_invite")) {
return NextResponse.redirect(new URL("/join", request.url));
function getSupabaseHostname(): string {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
if (url) {
try {
return new URL(url).hostname;
} catch {
/* fall through */
}
}
return "*.supabase.co";
}
// Admin panel: validate secret path segment before anything else.
// Returns 404 (not 403) — the panel's existence must not be revealed.
if (pathname.startsWith("/p/")) {
const segments = pathname.split("/");
const providedSecret = segments[2] ?? "";
const adminSecret = process.env.ADMIN_SECRET ?? "";
export default clerkMiddleware(
async (auth, request) => {
const { pathname } = request.nextUrl;
if (!adminSecret || providedSecret !== adminSecret) {
return new NextResponse(null, { status: 404 });
// Invite gate: block /sign-up unless the visitor has a valid invite cookie.
// Set INVITE_REQUIRED=false in env to disable for the live/post-beta launch.
if (pathname.startsWith("/sign-up")) {
const inviteRequired = process.env.INVITE_REQUIRED !== "false";
if (inviteRequired && !request.cookies.has("pf_invite")) {
return NextResponse.redirect(new URL("/join", request.url));
}
}
}
if (!isPublicRoute(request)) {
await auth.protect();
// Admin panel: validate secret path segment before anything else.
// Returns 404 (not 403) — the panel's existence must not be revealed.
if (pathname.startsWith("/p/")) {
const segments = pathname.split("/");
const providedSecret = segments[2] ?? "";
const adminSecret = process.env.ADMIN_SECRET ?? "";
if (!adminSecret || providedSecret !== adminSecret) {
return new NextResponse(null, { status: 404 });
}
}
if (!isPublicRoute(request)) {
await auth.protect();
}
},
{
// Report-Only for now — an earlier enforced attempt blanked the whole
// app because ClerkProvider crashes uncaught when a required resource
// gets blocked mid-init. Clerk's own contentSecurityPolicy option (not
// the hand-rolled next.config.ts draft this replaces) already ships the
// 'unsafe-inline'/https: script-src Next.js needs to hydrate, which is
// what the hand-rolled version was missing. Move to enforced only after
// a clean report-only period — see /api/csp-report for the violations.
contentSecurityPolicy: {
reportOnly: true,
reportTo: "/api/csp-report",
directives: {
"img-src": [`https://${getSupabaseHostname()}`, "https://image.mux.com"],
"connect-src": [
`https://${getSupabaseHostname()}`,
"https://stream.mux.com",
"https://image.mux.com",
"https://litix.io",
"https://storage.googleapis.com",
],
"media-src": ["self", "blob:", "https://stream.mux.com"],
"font-src": ["self", "data:"],
"frame-ancestors": ["none"],
"object-src": ["none"],
"base-uri": ["self"],
"report-uri": ["/api/csp-report"],
},
},
}
});
);
export const config = {
matcher: [