From 13513583bc6026fb5fc2350ebe2bb4d9c6cc944a Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Wed, 12 Aug 2026 13:42:08 +0200 Subject: [PATCH] feat(security): ship CSP in Report-Only mode via Clerk's middleware option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- next.config.ts | 42 -------------- src/app/api/csp-report/route.ts | 98 +++++++++++++++++++++++++++++++++ src/proxy.ts | 86 ++++++++++++++++++++++------- 3 files changed, 163 insertions(+), 63 deletions(-) create mode 100644 src/app/api/csp-report/route.ts diff --git a/next.config.ts b/next.config.ts index ed99223..a448737 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 = { - "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"], diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts new file mode 100644 index 0000000..dd8832e --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -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 => typeof entry === "object" && entry !== null) + .map((entry) => { + const body = (entry.body ?? {}) as Record; + 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)["csp-report"] as Record; + 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 }); +} diff --git a/src/proxy.ts b/src/proxy.ts index 990c7c1..356bfb2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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: [