fix(http): read forwarding headers only from a connection a proxy could make

The client-IP resolver trusted one forwarding hop by default and read the
rightmost X-Forwarded-For entry. Next fills that header with the socket's
address only when the request carries none, so a caller reaching the
published port directly could send its own and choose a fresh address per
request: every per-IP limit (sign-in, second factor, passkey, share unlock,
webhooks) became per-request, and sessions, the audit log and sign-in
notices recorded the chosen address.

The server now writes each connection's own peer address into an internal
header from Node's http.server.request.start diagnostics channel, installed
at boot and overwriting anything a caller sent under that name. The resolver
reads forwarding headers only when that peer is loopback, private,
unique-local or CGNAT, where a reverse proxy sits; a public peer is its own
client address, and a log line says once that its forwarding headers were
ignored. With TRUST_PROXY_HOPS=0 the peer is used instead of no address, so
a directly exposed host no longer shares one bucket across all anonymous
callers. Proxies on the same host, Docker network or tailnet need no change.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:52:04 +01:00
1 parent ba22932101
commit d6d0f7e73f
8 files changed
+309 -20

No files matched your search

+4 -9
View File
@@ -23,7 +23,7 @@ import { ConsentRequiredError } from "./ai/consent-guard";
import { attachRecordContextEcho } from "./sharing/record-session-fence";
import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal";
import { acceptIncomingRequestId } from "./http/request-id";
import { withErrorEnvelopeDefaults } from "./api-response";
import { getClientIp, withErrorEnvelopeDefaults } from "./api-response";
import {
capturedRateLimit,
rateLimitResponseHeaders,
@@ -190,14 +190,9 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
user_agent:
safeRequestProp(request, (r) => r.headers.get("user-agent"), null) ??
undefined,
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,
// The resolved client address, not the raw leftmost forwarding entry
// a direct caller could have written itself.
ip: safeRequestProp(request, (r) => getClientIp(r), null) ?? undefined,
});
return runWithRateLimitCapture(() =>