Security & robustness hardening pass
Cross-cutting input-validation, isolation, and DoS-resistance fixes across the app, API, billing, queue, and infra layers. - Runtime validation (zod) for client-supplied admin actions (role/plan/ limits), series generation index, and all pg-boss queue payloads - Auth: require email verification before sign-in; reject weak/placeholder/ short BETTER_AUTH_SECRET in production - Billing: sanitize Stripe/PayPal errors (log server-side, generic to client); race-safe subscription upsert; only count "processed" webhook events as handled; verify org membership in getEffectivePlan to block plan escalation - Series generation: reserve usage up front and refund on failure; bill the owning org, not the caller's active org - Injection defenses: HTML-escape user fields in emails, strip CR/LF from subject/recipient, validate ElevenLabs voiceId before URL interpolation - Media routes: stream off disk instead of buffering whole files; rate-limit anonymous public audio/cover endpoints by client IP
This commit is contained in:
+69
-19
@@ -4,35 +4,85 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
// "__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",
|
||||
];
|
||||
|
||||
/**
|
||||
* Optimistic edge gate: 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. Reading the cookie
|
||||
* directly keeps the middleware bundle free of the auth/jose internals.
|
||||
* 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 hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
|
||||
const { pathname, search } = req.nextUrl;
|
||||
|
||||
if (!hasSession) {
|
||||
const signIn = new URL("/sign-in", req.url);
|
||||
signIn.searchParams.set("redirect", pathname + search);
|
||||
return NextResponse.redirect(signIn);
|
||||
// Per-request nonce (base64). randomUUID is Edge-runtime safe and unguessable.
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||
"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'",
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
// 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: [
|
||||
"/dashboard/:path*",
|
||||
"/episodes/:path*",
|
||||
"/series/:path*",
|
||||
"/usage/:path*",
|
||||
"/billing/:path*",
|
||||
"/team/:path*",
|
||||
"/api-keys/:path*",
|
||||
"/settings/:path*",
|
||||
"/admin/:path*",
|
||||
// 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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user