src/proxy.ts
Rama main · réplica de lectura del repositorio original.
import { NextRequest, NextResponse } from "next/server";
import { apiCspSource } from "@/lib/api-base";
import { API_REFERENCE_STYLE_HASHES } from "@/lib/api-reference-csp";
import { basemapOrigins } from "@/lib/basemap";
import { runtimeConfig } from "@/lib/runtime-config";
// Nonce-based, strict Content-Security-Policy. This is computed fresh per
// request (the nonce must never be reused/predictable) and threaded through
// to Server Components via the `x-nonce` request header, so
// `app/layout.tsx` can hand it to anything that legitimately needs to
// render an inline <script>/<style> (currently just next-themes'
// no-flash-of-wrong-theme bootstrap script). Any *other* inline or
// injected script - e.g. one smuggled in via an XSS bug - won't carry this
// nonce and the browser will simply refuse to execute it.
//
// This only works because the app runs as a persistent Node server
// (`output: "standalone"`, see the Dockerfile) - it would silently do
// nothing under a static export, which has no per-request server code to
// generate a fresh nonce.
// Empty by default, and that is the shipped topology: the API is same-origin behind
// `app/api/v1/[...path]/route.ts`, which `'self'` already covers. It becomes a real
// origin only for a split-origin build, where `NEXT_PUBLIC_API_URL` is absolute - and
// that one genuinely is fixed at build time, which is why it is read here at module
// scope while everything below is not.
const API_ORIGIN_SOURCE = apiCspSource(process.env.NEXT_PUBLIC_API_URL) ?? "";
// The basemap's hosts, the one host source this app's configuration decides on.
// Derived on first request rather than at module load - reading the environment while
// the module is being evaluated risks doing it at build time, and the whole point of
// `lib/runtime-config.ts` is that a published image reads it in the container it runs
// in. Derived *once* rather than per request because the environment cannot change
// while the process lives, and `basemapOrigins`' warning for a malformed value would
// otherwise repeat on every request, burying the one diagnostic it exists to give.
//
// The policy names Google in no configuration at all: "Continue with Google" is a
// top-level navigation (`lib/google-oauth.ts`), which none of the fetch directives
// govern, so the CSP does not disclose whether an instance has Google sign-in on.
//
// It reaches `connect-src` and nothing else. MapLibre fetches every tile, in both of
// its modes, so `img-src` names no third party.
//
// Every `<img>` in this app points at this instance - its own origin, or `apiOrigin`
// where the API is split off onto another one, which `img-src` already lists. Species
// photos are the case that makes the distinction worth stating rather than saying
// "own origin": they are `<img src>` elements pointed straight at the API, because the
// route serving them is deliberately unauthenticated, so in a split-origin build - which
// local dev is - they resolve to `apiOrigin` and not to the page's origin. The bytes are
// Wikimedia's, fetched by the server and stored here; no browser ever asks
// Wikimedia for them, which is the whole point of serving them ourselves.
//
// `undefined` rather than a falsy check, since an instance whose every basemap value is
// malformed legitimately derives the empty string and must not re-derive it per request.
let basemapSources: string | undefined;
function cspBasemapSources(): string {
if (basemapSources === undefined) {
basemapSources = basemapOrigins(runtimeConfig().basemap).join(" ");
}
return basemapSources;
}
// `Strict-Transport-Security` is set here rather than in `next.config.js`'s `headers()`
// for two reasons that only middleware can serve: the value depends on the request, and
// the switch behind it is read at runtime. `headers()` is evaluated once, during the
// build, and applied to every response of every deployment identically - which for a
// published image means the build machine decides it for someone else's domain.
//
// It is only sent for a request that already arrived over HTTPS: a LAN instance on plain
// HTTP would otherwise hand out a pin it cannot honour, and a browser that recorded one
// stops being able to reach it at all. And it carries no `preload` - entering a domain into the browsers' preload list commits every host under
// it to HTTPS for years and is deliberately hard to undo, which is a decision for whoever
// owns the domain rather than for the app running on it. `WEB_HSTS=off` drops the header
// entirely.
//
// Only responses the matcher below covers get it, which is documents and their data
// requests rather than static assets. HSTS is recorded per *host*, not per path, so one
// covered response is all a browser needs.
const HSTS_VALUE = "max-age=63072000; includeSubDomains";
// Whether this request reached us over HTTPS. Behind the shipped Caddy - or any other
// proxy - TLS is terminated a hop earlier and the only evidence is the forwarded header;
// a chain appends to it, so the client-facing hop is the first entry, not the last.
function isHttps(request: NextRequest): boolean {
const forwarded = request.headers.get("x-forwarded-proto");
if (forwarded) {
return forwarded.split(",")[0]!.trim().toLowerCase() === "https";
}
return request.nextUrl.protocol === "https:";
}
// A directive and its sources, with the empty ones dropped. Every source below the
// literal ones can vanish - the API origin whenever the API is same-origin, the basemap
// origins when every configured value is malformed - and a stray double space in a
// CSP is the kind of thing that reads as a typo forever after.
function cspList(directive: string, ...sources: string[]): string {
return [directive, ...sources.filter(Boolean)].join(" ");
}
export function proxy(request: NextRequest) {
// 16 random bytes, because CSP Level 3 asks a nonce for "at least 128 bits of
// entropy". A v4 UUID carries 122 - not a weakness at that size, but the
// spec-exact form is no more code and is shorter on the wire besides. `crypto.getRandomValues` and `Buffer` are both available in
// either runtime, so this does not depend on which one Proxy runs in (Node, as of
// Next 16, which forbids the `runtime` export here outright).
const nonce = Buffer.from(
crypto.getRandomValues(new Uint8Array(16)),
).toString("base64");
const isDev = process.env.NODE_ENV !== "production";
// Empty string for the default, same-origin API - see `API_ORIGIN_SOURCE` above.
// `lib/api-base.ts` is what reduces an absolute value to an origin and what knows a
// relative one can't go through `new URL` at all.
const apiOrigin = API_ORIGIN_SOURCE;
// The basemap MapLibre draws, which is a `connect-src` source in *both* of its
// modes. That is not a choice about vector tiles: MapLibre's image decoder
// takes an `ArrayBuffer` and goes `Blob` -> `createImageBitmap`, so raster
// tile bytes arrive by `fetch` too. See `basemapOrigins`, which also says why
// nothing may set `refreshExpiredTiles`. Derived by the same module the
// renderer takes its URLs from, for the origin-not-path reason above: a host
// named in one place and not the other fails as a silently blocked request,
// which is a much worse thing to debug than a wrong URL.
const basemapOriginSources = cspBasemapSources();
const cspDirectives = [
"default-src 'self'",
// `'unsafe-inline'` and `https:` are no-ops in any browser that
// understands `'nonce-...'`/`'strict-dynamic'` (CSP Level 3) - such
// browsers ignore them - but keep the site from breaking outright in
// older browsers that don't. This is the "strict CSP with graceful
// fallback" pattern from Google's CSP guide, not a real weakening.
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`,
// `style-src` (governs `<style>`/`<link>` elements) is nonce-gated in
// production only. In dev, Next's own tooling - Fast Refresh, the
// dev/error overlay, and webpack/Turbopack's CSS hot-injection - injects
// inline `<style>` tags with no nonce at all (a documented Next.js
// limitation, e.g. vercel/next.js#87343), so a strict nonce here just
// breaks dev-mode styling. Production doesn't do any of that: real CSS
// ships as static, hashed `<link rel="stylesheet">` files (covered by
// `'self'`), so the nonce requirement costs nothing there.
// `style-src-attr` is deliberately its own, non-nonce'd directive: per
// spec, listing a nonce/hash in a directive disables the `'unsafe-inline'`
// fallback for *that* directive, and inline `style="..."` attributes from
// Base UI/Floating UI/etc. (positioning, `pointer-events`, animation state)
// can't practically carry a matching nonce - there's no way to tag every
// element a third-party library renders. CSS-attribute injection can't
// execute script in any modern browser, so this is a deliberate, narrow
// relaxation - `style-src-elem`/`style-src` (actual `<style>` blocks,
// where CSS-exfiltration attacks are more feasible) take only the nonce
// or one of the listed hashes in production.
// No host source: nothing third-party is loaded as a stylesheet. The hashes are
// the two stylesheets Scalar injects on `/developers` without a nonce (see
// `lib/api-reference-csp.ts`), and are on every page's policy because a client
// navigation to `/developers` keeps the policy of the document it started from.
isDev
? "style-src 'self' 'unsafe-inline'"
: cspList(
"style-src",
"'self'",
`'nonce-${nonce}'`,
...API_REFERENCE_STYLE_HASHES,
),
"style-src-attr 'unsafe-inline'",
// `blob:` - certification card images and both of a diver's pictures are
// private, so on the diver's own pages they're fetched with an `Authorization`
// header and rendered from an object URL rather than pointed at directly (see
// `hooks/useAuthedBlobUrl.ts`).
// Blob URLs are *not* covered by `'self'`, so without this the `<img>` is
// blocked. It widens nothing an attacker could reach: a `blob:` URL can only name
// data this document already created. The pictures take that same path, which
// is why no avatar host is named here: they are served by this app's own API.
//
// Two image kinds do *not* take that path, and are a plain `<img src>` at the
// API: species photos, public bytes on an unauthenticated route, and a check-in
// link's portrait and card fronts, whose route takes the link's token in its
// path rather than a header. `apiOrigin` is therefore load-bearing for them
// rather than incidental: it is empty in the shipped same-origin topology, where
// `'self'` covers them, and a real origin in a split-origin build, where nothing
// else would.
//
// No third-party host at all: MapLibre fetches every map tile, in both of
// its modes, under `connect-src`.
cspList("img-src", "'self'", "data:", "blob:", apiOrigin),
"font-src 'self' data:",
// **Load-bearing, not declarative.** `worker-src` has no `child-src` above
// it in this policy, so without this line a worker falls back to
// `script-src` - whose `'strict-dynamic'` short-circuits the source-list
// check entirely for anything not parser-inserted, and a `new Worker(url)`
// never is. The policy would therefore permit a `blob:` worker without a
// violation, which is precisely the hazard MapLibre was rejected over.
// `worker-src`'s own pre-request check has no such carve-out, so this is the
// one thing that makes the blob path fail loudly if MapLibre ever falls
// through to `importAsBlobUrl` instead of using the same-origin copy
// `components/map/map-canvas.tsx` points it at.
"worker-src 'self'",
// The API and the basemap are the cross-origin destinations this app fetches
// from. "Continue with Google"
// (`components/auth/google-auth-button.tsx`) needs nothing in either of these:
// it is a top-level navigation to Google, which the fetch directives do not
// govern. Nor does it need a `Cross-Origin-Opener-Policy` in their place -
// Google documents `same-origin-allow-popups` as a requirement for its *popup*
// flows, and this one opens no popup.
cspList("connect-src", "'self'", apiOrigin, basemapOriginSources),
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
];
const contentSecurityPolicyHeaderValue = cspDirectives.join("; ");
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
// `runtimeConfig()` memoizes, so these two are one environment read for the life of
// the process rather than one per request.
const { hstsEnabled, noindex } = runtimeConfig();
if (hstsEnabled && isHttps(request)) {
// A TLS-terminating proxy in front may set its own; where both are present the
// proxy's wins, which is the right way round - it is the one that knows the domain.
response.headers.set("Strict-Transport-Security", HSTS_VALUE);
}
if (noindex) {
// `app/robots.ts` asks crawlers not to fetch; this is what keeps a URL they heard
// about elsewhere out of an index anyway. A disallowed path can still be listed - a
// crawler that obeys robots.txt never fetches it and so never sees a `noindex` in the
// page, which is exactly why the header exists as well.
response.headers.set("X-Robots-Tag", "noindex, nofollow");
}
return response;
}
export const config = {
matcher: [
{
// The exclusions are anchored with a trailing `/` (or `$`) on purpose:
// an unanchored `api` also excludes any future route that merely *starts*
// with those letters - `/api-docs`, `/apidemo` - which would then be
// served with no CSP at all, silently.
//
// `api/v1/` and not `api/`: the proxy below `/api/v1/` is excluded only so its
// bodies stream unbuffered (`lib/api-proxy.ts`). Every other path under `/api`,
// the bare `/api/v1` included, is Next's not-found *page*, scripts and all, and
// gets the policy like any other page.
//
// `healthz` is excluded for the same reason the API routes are: it answers a
// container healthcheck with two words of plain text, and a policy governing
// scripts and styles has nothing to say about it. Skipping it also keeps the
// per-request nonce off a path that is hit every few seconds forever.
//
// There is no `missing:` clause here, and its absence is deliberate. Next's own
// CSP guide (`node_modules/next/dist/docs/01-app/02-guides/content-security-policy.md`)
// puts one in at this exact spot, listing `next-router-prefetch` and
// `purpose: prefetch`, and says only that prefetches "don't need the CSP header".
// The concern it is usually explained by is a stale nonce: one baked into a
// prefetched RSC payload, parked in the client router cache, replayed on a later
// navigation under a document whose CSP carries a different nonce.
//
// That cannot happen here, because a real prefetch payload has no nonce to go
// stale. A `<Link>` prefetch of `/privacy` (`RSC: 1` plus `next-router-prefetch: 1`)
// comes back as a 267-byte router-tree stub - no `<script>`, no nonce anywhere in
// it; the same URL with `RSC: 1` alone returns 36 KB carrying the request's nonce.
// What the clause did buy was an opt-out: one request header - `purpose: prefetch`,
// or any value at all of `next-router-prefetch` - and this middleware never ran, so
// a full 66 KB HTML document with 22 `<script>` tags was served with no CSP, no
// HSTS and no `X-Robots-Tag`. Cache poisoning was never the risk - these responses
// are `private, no-cache, no-store, max-age=0, must-revalidate` with no `ETag`, so
// no shared cache may store the CSP-less document and no 304 path exists - the
// header-stripping primitive itself was. Without the clause, no request header
// yields a CSP-less page; only the excluded paths go without one, and none of
// them answers with HTML.
//
// It never covered the prefetches browsers send on their own, either. `matchHas`
// (`node_modules/next/dist/shared/lib/router/utils/prepare-destination.js`) looks a
// header up by exact lowercased key, so the `Sec-Purpose: prefetch` that Chrome's
// speculation rules and Google's prefetch proxy actually send never matched the
// `purpose` key and kept the CSP regardless. Only a hand-written header stripped it.
//
// `cacheComponents`/PPR is the switch expected to reopen this, and it is on
// without doing so. App-shell prefetches (`next-router-prefetch: 3`,
// `FetchStrategy.RuntimeShell`) do carry the nonce of the request that produced
// them, and the guide calls PPR incompatible with a nonce-based CSP because
// "static shell scripts won't have access to the nonce" - but nothing here serves
// a static shell, and the document that runs those scripts mints its own nonce.
// See "Cache Components asks for one opt-out, and leaves the nonce CSP alone" in
// DECISIONS.md; this file is not what the flags change.
source:
"/((?!api/v1/|_next/static/|_next/image/|favicon.ico$|healthz$).*)",
},
],
};