Files
podcastdistributiona/middleware.ts
T
Leon Serfaty 51c541ad22 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
2026-06-20 20:59:03 -04:00

89 lines
3.3 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");
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);
}
}
// 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" },
],
},
],
};