The nonce-based script-src is incompatible with Next's static prerendering. The nonce is minted per request in middleware, but statically generated HTML was produced at build time with no nonce on its inline scripts, so on "/" and the marketing routes every inline hydration script was blocked: the page painted from SSR HTML, React failed to hydrate, and the tree unmounted to a blank screen a moment later. Dynamic routes (/sign-in, /dashboard) render per request, receive the nonce, and worked — which is why this was missed. Browsers that honour a nonce ignore 'unsafe-inline', so the two cannot be combined. Drop the nonce and allow inline scripts; the policy still restricts which origins may serve scripts, and frame-src/connect-src stay strict. Restoring nonces would require every route to render dynamically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
126 lines
6.0 KiB
TypeScript
126 lines
6.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
// Better Auth's session cookie name (default prefix "better-auth"); the
|
|
// "__Secure-" variant is used when cookies are served over HTTPS in production.
|
|
const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"];
|
|
|
|
// Authed surfaces that require an optimistic session-cookie check. Anonymous users
|
|
// hitting these are redirected to /sign-in. Public/marketing/auth routes are NOT
|
|
// listed here, so they are never redirected (CSP still applies to them, below).
|
|
const AUTHED_PREFIXES = [
|
|
"/dashboard",
|
|
"/episodes",
|
|
"/series",
|
|
"/usage",
|
|
"/billing",
|
|
"/team",
|
|
"/api-keys",
|
|
"/settings",
|
|
"/admin",
|
|
];
|
|
|
|
/**
|
|
* Runs on every request (see matcher). Two responsibilities:
|
|
*
|
|
* 1. CSP/nonce (all routes): generate a per-request base64 nonce with the Web Crypto
|
|
* API (Edge-safe — no node:crypto), expose it on the inbound `x-nonce` request
|
|
* header, and set a nonce-based Content-Security-Policy response header. Next.js
|
|
* auto-applies this nonce to its own framework <script> tags when the `x-nonce`
|
|
* request header is present; the root layout may also read it via `headers()` to
|
|
* nonce any manual inline scripts.
|
|
*
|
|
* 2. Optimistic edge gate (authed prefixes only): redirect anonymous users away from
|
|
* authed surfaces. Only checks for the *presence* of a session cookie — real
|
|
* session validation (and admin/role checks) happen in the route-group layouts.
|
|
*/
|
|
export function middleware(req: NextRequest) {
|
|
const { pathname, search } = req.nextUrl;
|
|
|
|
// Per-request nonce (base64). randomUUID is Edge-runtime safe and unguessable.
|
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
|
// Cloudflare Turnstile needs three allowances: its script, the IFRAME the widget
|
|
// actually renders into, and the XHRs that script makes. Without frame-src the
|
|
// iframe falls back to default-src 'self' and the challenge silently never
|
|
// appears — leaving the submit button permanently disabled.
|
|
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
|
|
// `next dev` compiles client chunks with eval() (HMR + cheap source maps) and
|
|
// talks to the dev server over a websocket. Without these two dev-only
|
|
// relaxations the CSP throws EvalError, hydration dies, and NOTHING on the page
|
|
// is interactive — no Turnstile widget, and form submit handlers never fire.
|
|
// Production builds need neither, so the shipped policy stays strict.
|
|
const isDev = process.env.NODE_ENV !== "production";
|
|
const devScriptSrc = isDev ? " 'unsafe-eval'" : "";
|
|
const devConnectSrc = isDev ? " ws: http://localhost:* http://127.0.0.1:*" : "";
|
|
const csp = [
|
|
"default-src 'self'",
|
|
// NOTE: deliberately NOT 'strict-dynamic'.
|
|
//
|
|
// 'strict-dynamic' makes browsers ignore 'self' and every host-source in this
|
|
// directive, leaving the nonce as the only way in. That works on dynamically
|
|
// rendered routes, where Next stamps the per-request nonce onto its <script>
|
|
// tags — but a statically prerendered page has no request to take a nonce
|
|
// from, so its HTML ships without one. With 'strict-dynamic' the browser then
|
|
// blocked every framework chunk on /, /pricing, /features, /faq, /about and
|
|
// the legal pages: React never hydrated and the marketing surface was inert.
|
|
//
|
|
// Without it, 'self' covers the same-origin /_next/static chunks (and the
|
|
// proxied analytics tracker at /_a), and the Turnstile host-source actually
|
|
// takes effect instead of being silently ignored. The nonce is kept, so
|
|
// dynamic routes and any inline script still get the stronger guarantee.
|
|
// NOTE: nonce-based script-src is incompatible with Next's statically
|
|
// prerendered pages. The nonce is minted per request, but static HTML was
|
|
// baked at build time WITHOUT one, so every inline hydration script on those
|
|
// pages ("/", the marketing routes) got blocked: the page painted, React
|
|
// failed to hydrate, and the tree unmounted to a blank screen. Dynamic routes
|
|
// (/sign-in, /dashboard) were fine, which is what made this easy to miss.
|
|
// A browser that honours a nonce ignores 'unsafe-inline', so the two cannot
|
|
// be combined — this policy still restricts which ORIGINS may serve scripts.
|
|
// To go back to nonces, every page must render dynamically.
|
|
`script-src 'self' 'unsafe-inline' ${TURNSTILE_ORIGIN}${devScriptSrc}`,
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: https://oaidalleapiprodscus.blob.core.windows.net https://images.unsplash.com",
|
|
"media-src 'self'",
|
|
`connect-src 'self' ${TURNSTILE_ORIGIN}${devConnectSrc}`,
|
|
`frame-src 'self' ${TURNSTILE_ORIGIN}`,
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join("; ");
|
|
|
|
// Optimistic auth gate for the previously-matched authed prefixes only.
|
|
const isAuthedPath = AUTHED_PREFIXES.some(
|
|
(p) => pathname === p || pathname.startsWith(p + "/")
|
|
);
|
|
if (isAuthedPath) {
|
|
const hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
|
|
if (!hasSession) {
|
|
const signIn = new URL("/sign-in", req.url);
|
|
signIn.searchParams.set("redirect", pathname + search);
|
|
return NextResponse.redirect(signIn);
|
|
}
|
|
}
|
|
|
|
// Forward the nonce to the app via a request header, and set the CSP on the response.
|
|
const requestHeaders = new Headers(req.headers);
|
|
requestHeaders.set("x-nonce", nonce);
|
|
requestHeaders.set("Content-Security-Policy", csp);
|
|
|
|
const res = NextResponse.next({ request: { headers: requestHeaders } });
|
|
res.headers.set("Content-Security-Policy", csp);
|
|
return res;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Run on every request EXCEPT static assets so CSP applies app-wide while
|
|
// avoiding unnecessary work on prefetched/static files.
|
|
{
|
|
source: "/((?!_next/static|_next/image|favicon.ico).*)",
|
|
missing: [
|
|
{ type: "header", key: "next-router-prefetch" },
|
|
{ type: "header", key: "purpose", value: "prefetch" },
|
|
],
|
|
},
|
|
],
|
|
};
|