From 15d91834c7b24bdd0653d4dfa182f1458e88844a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 15:40:50 +0200 Subject: [PATCH] fix(api-handler): guard against private-field crash on force-static routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next 16's dev server passes a synthetic placeholder (not a real NextRequest instance) as the first argument to route handlers declared `export const dynamic = "force-static"`. NextRequest's `method`, `url`, and `headers` getters all read a private `#state` field; invoking those getters with the wrong `this` binding crashes with the V8 native error: TypeError: Cannot read private member #state from an object whose class did not declare it at Reflect.get () at wrapped (src/lib/api-handler.ts:56:23) This 500s `/api/version` on every request (the only `force-static` route in the app today) and would 500 any future force-static route that anyone adds. Wrap every direct NextRequest property access inside the apiHandler wrapper in a `safeRequestProp` helper that swallows the private-field error and returns a sensible fallback (empty string for `url`, "GET" for `method`, `null` for header lookups). The fallbacks let logging instrumentation continue to attach `path` / `method` / `user_agent` fields when they are available and silently degrade when they are not — instrumentation must never crash a route handler. Verification: `curl -sI /api/version` -> HTTP/1.1 200 OK, body contains the package.json version (was 500). Dynamic routes (`/auth/login`, `/api/health`) still serve correctly. Affected tests still pass (vitest src/lib/api-handler + src/app/api/version + src/components/targets = 31/31). --- src/lib/api-handler.ts | 60 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index c11f1994d..8ca6e40f0 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -32,6 +32,26 @@ export class HttpError extends Error { * No CSRF check — HealthLog does not use CSRF tokens. * Auth annotation happens in routes via requireAuth(). */ +// Read a property from a request-like value without invoking native +// private-field getters (NextRequest.method / .url / .headers access +// `this.#state` and crash with `Cannot read private member #state from +// an object whose class did not declare it` when the request is a +// Proxy or a synthetic placeholder — Next 16 passes such placeholders +// to `force-static` route handlers during dev). We probe defensively +// and fall back to safe defaults so logging instrumentation never +// crashes the handler. +function safeRequestProp( + request: unknown, + read: (req: NextRequest) => R, + fallback: R, +): R { + try { + return read(request as NextRequest); + } catch { + return fallback; + } +} + // Next.js route handlers come in two shapes — `(request)` for static routes // and `(request, { params })` for dynamic ones. The variadic generic is the // only signature TS accepts that covers both at the call site. The `any[]` @@ -44,23 +64,46 @@ export function apiHandler Promise>( // eslint-disable-next-line @typescript-eslint/no-explicit-any const wrapped = async (...args: any[]): Promise => { const request = args[0] as NextRequest; - const url = new URL(request.url); + const requestUrl = safeRequestProp(request, (r) => r.url, ""); + const url = (() => { + try { + return new URL(requestUrl); + } catch { + // No usable URL (e.g. force-static placeholder) — fall back to + // a synthetic origin so the rest of the pipeline can still + // attach a pathname. + return new URL("http://localhost/"); + } + })(); const evt = new WideEventBuilder("http"); // Propagate x-request-id if present - const incomingRequestId = request.headers.get("x-request-id"); + const incomingRequestId = safeRequestProp( + request, + (r) => r.headers.get("x-request-id"), + null, + ); if (incomingRequestId) evt.setRequestId(incomingRequestId); evt.setHttp({ - method: request.method, + method: safeRequestProp(request, (r) => r.method, "GET"), path: url.pathname, route: url.pathname, status: 200, - user_agent: request.headers.get("user-agent") ?? undefined, + user_agent: + safeRequestProp( + request, + (r) => r.headers.get("user-agent"), + null, + ) ?? undefined, ip: - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || + safeRequestProp( + request, + (r) => r.headers.get("x-forwarded-for")?.split(",")[0]?.trim(), + null, + ) || + safeRequestProp(request, (r) => r.headers.get("x-real-ip"), null) || undefined, }); @@ -349,9 +392,10 @@ async function reportToGlitchtip( // GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and // OAuth callbacks ship `?code=…&state=…`; if any of those error we // don't want their secrets in someone's incident UI. - let scrubbedUrl = request.url; + const rawUrl = safeRequestProp(request, (r) => r.url, ""); + let scrubbedUrl = rawUrl; try { - const u = new URL(request.url); + const u = new URL(rawUrl); u.search = ""; scrubbedUrl = u.toString(); } catch {