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
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user