proxy.ts
Rama main · réplica de lectura del repositorio original.
/**
* proxy.ts
*
* Next.js Proxy - the Next 16 successor to the `middleware` file convention -
* runs on every request. It applies the security headers - most importantly a
* per-request CSP nonce - generates and propagates a request id for log + audit
* correlation, and forwards the nonce and request pathname to server components
* via request headers.
*
* Why a per-request nonce instead of static CSP: Next.js streams inline scripts
* for hydration data. A static `script-src 'self'` CSP would block them; the only
* alternatives are `'unsafe-inline'` (which defeats most of CSP's value) or
* per-request nonces. Nonces win. See ADR-0006.
*/
import { type NextRequest, NextResponse } from "next/server";
import { buildCsp } from "@/lib/security/csp";
// Cryptographically random nonce. 16 random bytes → 24 base64 chars. Generated on
// every request because that's the whole point - a stale nonce defeats CSP.
function generateNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// Base64 without padding fits CSP's nonce-source format.
return btoa(String.fromCharCode(...bytes)).replace(/=+$/, "");
}
/**
* Allow an upstream-set `x-request-id` only when it looks like a sane
* identifier - UUID, ULID, KSUID, or any opaque token of safe characters.
* Pathological inputs (newlines, control bytes, header-injection attempts)
* get replaced by a fresh UUID instead.
*/
function isPlausibleRequestId(value: string): boolean {
return value.length > 0 && value.length <= 200 && /^[A-Za-z0-9_.:-]+$/.test(value);
}
/**
* The proxy itself.
*
* Next 16 accepts the proxy as a default export or a named `proxy` export; we
* use the default export, one of the small set of files exempt from our
* "no default exports" rule (see eslint.config.mjs).
*/
export default function proxy(request: NextRequest): NextResponse {
const isDev = process.env.NODE_ENV === "development";
const turnstileEnabled = Boolean(process.env["TURNSTILE_SITE_KEY"]);
const nonce = generateNonce();
const csp = buildCsp(nonce, isDev, turnstileEnabled);
// Request-ID for log/audit correlation. Honor an upstream proxy's value
// when it looks sane (so distributed traces stay joined); otherwise mint
// a fresh UUID. Forwarded to route handlers via the request headers AND
// echoed back to the client via response so support can quote it.
const upstreamId = request.headers.get("x-request-id");
const requestId =
upstreamId && isPlausibleRequestId(upstreamId) ? upstreamId : crypto.randomUUID();
// Forward the nonce to the route handler / server components via a request
// header. Next.js' framework code reads `x-nonce` automatically and attaches
// it to its own inline scripts.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("x-request-id", requestId);
// Forward the request pathname so server components can branch on
// the current route - Next.js doesn't expose `usePathname()` on the
// server side without a hook. Used today by `(app)/layout.tsx` to
// allowlist MFA-enrollment routes when redirecting non-compliant
// operators. Set verbatim from `nextUrl.pathname` (no query string).
requestHeaders.set("x-pathname", request.nextUrl.pathname);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
// === Response security headers ===
response.headers.set("Content-Security-Policy", csp);
// Declares the `csp-endpoint` group referenced by the CSP `report-to`
// directive. The Reporting API is the modern replacement for the
// deprecated `report-uri`; both target the same handler so reports land
// regardless of which mechanism a given browser supports.
response.headers.set("Reporting-Endpoints", 'csp-endpoint="/api/csp-report"');
response.headers.set("x-request-id", requestId);
// Hide our stack from passive fingerprinting.
response.headers.set("X-Content-Type-Options", "nosniff");
// Defense in depth on top of CSP `frame-ancestors`.
response.headers.set("X-Frame-Options", "DENY");
// Limit how much referrer info leaks to external origins.
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
// Lock down browser features we don't use. Add only what we need over time.
response.headers.set(
"Permissions-Policy",
[
"accelerometer=()",
"autoplay=()",
"camera=()",
"clipboard-read=()",
"clipboard-write=(self)",
"fullscreen=(self)",
"geolocation=()",
"gyroscope=()",
"magnetometer=()",
"microphone=()",
"midi=()",
"payment=()",
"usb=()",
].join(", "),
);
// Cross-origin isolation. Strict by default; relaxed when integrating an embed.
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
// HSTS - production only. We never want to send this from a dev server (would
// make `localhost` redirect to HTTPS forever).
if (!isDev) {
response.headers.set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload",
);
}
return response;
}
// Skip the proxy for static assets and Next's internal routes.
export const config = {
matcher: [
/*
* Run on every path except:
* _next/static - bundled assets, hashed, served with long-cache headers
* _next/image - next/image optimizer responses (CSP would break inline svg)
* favicon.ico - static asset
* robots.txt - static
* sitemap.xml - static
* Health endpoints DO run through the proxy - we want their responses to
* carry the security headers too.
*/
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
],
};