Next.js 16 builds with Turbopack, which does NOT stamp the middleware CSP nonce onto its inline hydration scripts (self.__next_f.push). The nonce-based `script-src 'self' 'nonce-…'` therefore blocked those inline scripts, React never hydrated, and the marketing/app pages rendered as a blank/black shell (header + framer-motion sections stuck at opacity:0). Switch `script-src` to 'self' 'unsafe-inline' (Turbopack-compatible) and drop the now-unused nonce plumbing. All other CSP directives stay strict (object-src 'none', frame-ancestors 'none', locked connect-src/frame-src). Verified in a local production container: served script-src is correct and the page hydrates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server"
|
|
import { getSessionCookie } from "better-auth/cookies"
|
|
|
|
const PROTECTED_PATHS = [
|
|
"/admin",
|
|
"/dashboard",
|
|
"/properties",
|
|
"/tenants",
|
|
"/rent",
|
|
"/maintenance",
|
|
"/leases",
|
|
"/expenses",
|
|
"/settings",
|
|
"/onboarding",
|
|
"/calendar",
|
|
"/inspections",
|
|
"/vendors",
|
|
"/reports",
|
|
"/activity",
|
|
"/ai",
|
|
"/ai-dashboard",
|
|
"/predictions",
|
|
"/recommendations",
|
|
"/impact",
|
|
"/follow-ups",
|
|
"/team",
|
|
]
|
|
|
|
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
|
|
|
|
// Origin of the Sentry ingest endpoint, derived from the public DSN so the
|
|
// CSP stays in sync with whatever project/region the DSN points at. Returns
|
|
// null when Sentry is not configured.
|
|
function sentryIngestOrigin(): string | null {
|
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
|
|
if (!dsn) return null
|
|
try {
|
|
return new URL(dsn).origin
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Build the Content-Security-Policy. `script-src` uses 'unsafe-inline' because
|
|
// Next.js 16's Turbopack build does NOT stamp a per-request nonce onto its
|
|
// inline hydration scripts (`self.__next_f.push(...)`). A nonce-based policy
|
|
// therefore blocks those inline scripts and the app never hydrates (blank page).
|
|
// `style-src` also keeps 'unsafe-inline' (Radix / Tailwind / framer-motion inject
|
|
// inline styles). NOTE: to restore the stricter nonce-based script policy, build
|
|
// with webpack (`next build --webpack`) so Next applies the nonce to its scripts.
|
|
function buildCsp(): string {
|
|
const isDev = process.env.NODE_ENV !== "production"
|
|
const sentry = sentryIngestOrigin()
|
|
|
|
// Dev additionally needs 'unsafe-eval' (Turbopack HMR) plus a dev websocket
|
|
// (added to connect-src below).
|
|
const scriptSrc = isDev
|
|
? `script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com`
|
|
: `script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com`
|
|
const connectSrc = [
|
|
"connect-src 'self'",
|
|
isDev ? "ws: wss:" : "",
|
|
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
|
|
sentry ?? "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
|
|
return [
|
|
"default-src 'self'",
|
|
"img-src 'self' data: blob: https:",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
scriptSrc,
|
|
"font-src 'self' data:",
|
|
connectSrc,
|
|
// Sentry Session Replay spins up its compression worker from a blob: URL;
|
|
// without worker-src the browser falls back to script-src and blocks it.
|
|
"worker-src 'self' blob:",
|
|
"frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
"object-src 'none'",
|
|
].join("; ")
|
|
}
|
|
|
|
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
|
|
// an auth page we confirm the session is actually alive — otherwise a stale
|
|
// cookie loops forever: /dashboard → /login (server sees no session) →
|
|
// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS.
|
|
// "unknown" (auth service unreachable / rate-limited) renders the auth page
|
|
// without touching cookies, which is safe in both directions.
|
|
async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> {
|
|
try {
|
|
const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin
|
|
const res = await fetch(new URL("/api/auth/get-session", base), {
|
|
headers: { cookie: request.headers.get("cookie") ?? "" },
|
|
cache: "no-store",
|
|
})
|
|
if (!res.ok) return "unknown"
|
|
// Better Auth returns JSON `null` when the session is missing or revoked.
|
|
const session = await res.json()
|
|
return session ? "valid" : "invalid"
|
|
} catch {
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
export async function proxy(request: NextRequest) {
|
|
const pathname = request.nextUrl.pathname
|
|
|
|
// Optimistic check based on the presence of the session cookie. Real
|
|
// enforcement happens in routes / server components via getSessionUser().
|
|
const sessionCookie = getSessionCookie(request)
|
|
|
|
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p))
|
|
if (isProtected && !sessionCookie) {
|
|
const url = request.nextUrl.clone()
|
|
url.pathname = "/login"
|
|
return NextResponse.redirect(url)
|
|
}
|
|
|
|
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
|
|
let dropStaleSessionCookie = false
|
|
if (isAuthPage && sessionCookie) {
|
|
const state = await sessionState(request)
|
|
if (state === "valid") {
|
|
const url = request.nextUrl.clone()
|
|
url.pathname = "/dashboard"
|
|
return NextResponse.redirect(url)
|
|
}
|
|
// Dead cookie (session revoked or expired): render the auth page and drop
|
|
// the cookie below so protected paths stop treating this visitor as
|
|
// signed in. On "unknown", render the page but keep the cookie.
|
|
dropStaleSessionCookie = state === "invalid"
|
|
}
|
|
|
|
const csp = buildCsp()
|
|
|
|
const response = NextResponse.next()
|
|
// Set the CSP on the outgoing response so the browser enforces it.
|
|
response.headers.set("Content-Security-Policy", csp)
|
|
|
|
if (dropStaleSessionCookie) {
|
|
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
|
|
for (const cookie of request.cookies.getAll()) {
|
|
if (!cookie.name.includes("better-auth.session_token")) continue
|
|
response.cookies.set(cookie.name, "", {
|
|
maxAge: 0,
|
|
path: "/",
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: cookie.name.startsWith("__Secure-"),
|
|
})
|
|
}
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
],
|
|
}
|