Verified live that adding Clerk's frontend API host to script-src does NOT resolve the CSP violation in modern browsers: with 'strict-dynamic' present, CSP3 ignores host-based entries in that directive entirely (Chrome's own violation message confirms this). The console noise is a Clerk SDK-side loading quirk, not something fixable here without dropping 'strict-dynamic'. Keeping the host entry only as the same spec-mandated pre-CSP3 fallback the existing 'unsafe-inline' entry already relies on, with the comment corrected to say so.
226 lines
9.4 KiB
TypeScript
226 lines
9.4 KiB
TypeScript
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
|
import { NextResponse } from "next/server";
|
|
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
|
|
import { isMaintenanceModeEnabled } from "@/lib/maintenance";
|
|
|
|
const MAINTENANCE_EXEMPT_PATHS = new Set(["/impressum", "/datenschutz", "/nutzungsbedingungen", "/ueber-uns"]);
|
|
|
|
const MAINTENANCE_HTML = `<!doctype html>
|
|
<html lang="de"><head><meta charset="utf-8" />
|
|
<title>Wartungsarbeiten · PawFeed</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<style>body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#0a0a0a;color:#fafafa;text-align:center;padding:1.5rem}</style>
|
|
</head><body><div><h1>Wir sind gleich zurück</h1><p>PawFeed führt gerade kurz Wartungsarbeiten durch. Bitte versuch es in wenigen Minuten erneut.</p></div></body></html>`;
|
|
|
|
const isPublicRoute = createRouteMatcher([
|
|
"/",
|
|
"/sign-up(.*)",
|
|
"/log-in(.*)",
|
|
"/forgot-password(.*)",
|
|
"/reset-password(.*)",
|
|
"/verify-email(.*)",
|
|
"/health-card/(.*)",
|
|
"/api/gdpr-export/(.*)",
|
|
"/opengraph-image",
|
|
"/impressum",
|
|
"/datenschutz",
|
|
"/nutzungsbedingungen",
|
|
"/ueber-uns",
|
|
"/join(.*)",
|
|
"/api/webhooks/(.*)",
|
|
"/api/auth/(.*)",
|
|
"/api/cron/(.*)",
|
|
"/api/csp-report",
|
|
"/api/health",
|
|
// tRPC handles its own auth per-procedure (protectedProcedure vs.
|
|
// publicProcedure) and returns a proper JSON UNAUTHORIZED error — letting
|
|
// Clerk's page-level auth.protect() intercept it first produced a 307 HTML
|
|
// redirect to the sign-in page instead, which a JSON API client can't
|
|
// parse, and incorrectly blocked publicProcedure calls (e.g.
|
|
// explore.listPets) for logged-out visitors entirely.
|
|
"/api/trpc/(.*)",
|
|
]);
|
|
|
|
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";
|
|
}
|
|
|
|
function getClerkFrontendApiHostname(): string {
|
|
// A Clerk publishable key is base64(`${frontendApiHost}$`) after its
|
|
// pk_test_/pk_live_ prefix — this middleware runs on the Edge runtime, so
|
|
// atob (not Buffer) for the decode.
|
|
const key = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
|
if (key) {
|
|
try {
|
|
const encoded = key.replace(/^pk_(test|live)_/, "");
|
|
return atob(encoded).replace(/\$$/, "");
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
return "clerk.pawfeed.org";
|
|
}
|
|
|
|
function getGlitchtipHostname(): string {
|
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
|
if (dsn) {
|
|
try {
|
|
return new URL(dsn).hostname;
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
return "glitchtip.pawfeed.org";
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
// 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 || !timingSafeStringEqual(providedSecret, adminSecret)) {
|
|
return new NextResponse(null, { status: 404 });
|
|
}
|
|
}
|
|
|
|
// Maintenance mode: admin-toggled (admin.setMaintenanceMode), blocks all
|
|
// page/app traffic except the admin panel itself (so it can be turned
|
|
// back off), API routes (webhooks/cron/csp-report must keep working),
|
|
// the fixed-language legal pages (Impressumspflicht), and sign-in/Clerk's
|
|
// own routes — an admin whose session expired must still be able to log
|
|
// back in and reach /p/* to disable maintenance mode. Fails open on a
|
|
// Redis error — an outage there must never take the whole site down.
|
|
if (
|
|
!pathname.startsWith("/p/") &&
|
|
!pathname.startsWith("/api/") &&
|
|
!pathname.startsWith("/log-in") &&
|
|
!pathname.startsWith("/sign-up") &&
|
|
!pathname.startsWith("/__clerk/") &&
|
|
!MAINTENANCE_EXEMPT_PATHS.has(pathname)
|
|
) {
|
|
const maintenanceOn = await isMaintenanceModeEnabled().catch(() => false);
|
|
if (maintenanceOn) {
|
|
return new NextResponse(MAINTENANCE_HTML, {
|
|
status: 503,
|
|
headers: { "content-type": "text/html; charset=utf-8", "retry-after": "3600" },
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!isPublicRoute(request)) {
|
|
await auth.protect();
|
|
}
|
|
},
|
|
{
|
|
// Enforced as of 2026-08-22 (Mozilla Observatory docked 25 points for
|
|
// Report-Only-only). The report-only period (since 2026-08-12/13) came
|
|
// back clean: GlitchTip shows zero CSP violations since 2026-08-16
|
|
// (verified via issue_events_issueaggregate on the self-hosted instance)
|
|
// except 6 script-src "eval" hits on 2026-08-15 from /pets/[petId] and
|
|
// /onboarding/pet — both routes use react-easy-crop for avatar cropping,
|
|
// the likely source, and it hasn't recurred since. Clerk's production
|
|
// script-src default omits 'unsafe-eval' (see
|
|
// node_modules/@clerk/nextjs/.../content-security-policy.js), so an
|
|
// enforced policy would hard-block that call if it ever recurs. Adding
|
|
// 'unsafe-eval' back here defensively avoids repeating the earlier
|
|
// incident where an enforced CSP blanked the whole app (ClerkProvider
|
|
// crashed uncaught on a blocked required resource) — it costs nothing
|
|
// extra in Mozilla's scoring since 'unsafe-inline'/https:/http: are
|
|
// already unconditionally in Clerk's default script-src. Revisit once
|
|
// the eval source is confirmed fixed and remove this line.
|
|
contentSecurityPolicy: {
|
|
reportOnly: false,
|
|
// strict: true drops http:/https: from script-src and adds
|
|
// 'strict-dynamic' + a per-request nonce instead (CSP3 best practice —
|
|
// Mozilla Observatory flags http:/https:/unsafe-inline as "overly
|
|
// broad"). Clerk forwards the resulting Content-Security-Policy header
|
|
// (with the nonce) on both the response AND the request it passes
|
|
// downstream (see setRequestHeadersOnNextResponse in
|
|
// @clerk/nextjs/.../clerkMiddleware.js), which is exactly what
|
|
// Next.js's own automatic nonce application reads during SSR — so
|
|
// Next's framework scripts and page bundles get noticed for free.
|
|
// 'unsafe-inline' stays in the header text as a spec-mandated fallback
|
|
// for pre-CSP3 browsers; browsers that understand 'strict-dynamic'
|
|
// ignore it. Any hand-written inline <script> (e.g. next-themes'
|
|
// FOUC-prevention script in layout.tsx) needs its own nonce prop —
|
|
// see RootLayout.
|
|
strict: true,
|
|
reportTo: "/api/csp-report",
|
|
directives: {
|
|
// Clerk's own middleware adds its frontend API host to connect-src
|
|
// automatically, but NOT to script-src. Added it here too, but
|
|
// verified (2026-09-01) this does NOT fix it: with 'strict-dynamic'
|
|
// present, CSP3 browsers ignore host-based entries in script-src
|
|
// entirely (same reasoning as the 'unsafe-inline' fallback comment
|
|
// above) — Chrome's own violation message confirms this ("Note that
|
|
// 'strict-dynamic' is present, so host-based allowlisting is
|
|
// disabled"). The initial (unversioned) clerk.browser.js/ui.browser.js
|
|
// load still gets blocked before its 307 redirect to the versioned
|
|
// URL, producing two harmless-but-noisy CSP violations (reported to
|
|
// /api/csp-report) on every page that renders Clerk UI — Clerk loads
|
|
// fine via the redirect regardless. Root cause is on Clerk's side
|
|
// (that script tag isn't part of a nonce-trusted load chain); fixing
|
|
// it here would mean dropping 'strict-dynamic', a real regression
|
|
// from the CSP Enforcement hardening. Kept only as the same
|
|
// spec-mandated pre-CSP3 fallback the 'unsafe-inline' entry already
|
|
// relies on.
|
|
"script-src": [`https://${getClerkFrontendApiHostname()}`, "unsafe-eval", "'report-sample'"],
|
|
"img-src": [`https://${getSupabaseHostname()}`, "https://image.mux.com", "blob:"],
|
|
"connect-src": [
|
|
`https://${getSupabaseHostname()}`,
|
|
`https://${getGlitchtipHostname()}`,
|
|
"https://stream.mux.com",
|
|
"https://image.mux.com",
|
|
"https://litix.io",
|
|
"https://*.litix.io",
|
|
"https://*.fastly.mux.com",
|
|
"https://storage.googleapis.com",
|
|
],
|
|
"media-src": [
|
|
"self",
|
|
"blob:",
|
|
"https://stream.mux.com",
|
|
"https://*.edgemv.mux.com",
|
|
"https://image.mux.com",
|
|
],
|
|
"font-src": ["self", "data:"],
|
|
"frame-ancestors": ["none"],
|
|
"object-src": ["none"],
|
|
"base-uri": ["self"],
|
|
"report-uri": ["/api/csp-report"],
|
|
},
|
|
},
|
|
}
|
|
);
|
|
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|txt|docx?|xlsx?|zip|webmanifest)).*)",
|
|
"/(api|trpc)(.*)",
|
|
"/__clerk/(.*)",
|
|
],
|
|
};
|