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

@@ -0,0 +1,68 @@
import { request as httpRequest, createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it, onTestFinished } from "vitest";
import {
__uninstallPeerAddressStampForTests,
installPeerAddressStamp,
PEER_ADDRESS_HEADER,
readPeerAddress,
} from "../peer-address";
/**
* The stamp is what the client-IP resolver trusts, so a caller must not be
* able to set it: the server writes the socket's address over whatever the
* request carried under that name.
*/
describe("installPeerAddressStamp", () => {
it("writes the socket's address over a header the caller sent", async () => {
installPeerAddressStamp();
installPeerAddressStamp(); // once per process, however often it is called
onTestFinished(__uninstallPeerAddressStampForTests);
const seen: Array<string | string[] | undefined> = [];
const server = createServer((req, res) => {
seen.push(req.headers[PEER_ADDRESS_HEADER]);
res.end("ok");
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const { port } = server.address() as AddressInfo;
try {
await new Promise<void>((resolve, reject) => {
const req = httpRequest(
{
host: "127.0.0.1",
port,
path: "/",
headers: { [PEER_ADDRESS_HEADER]: "203.0.113.9" },
},
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end();
});
} finally {
await new Promise((resolve) => server.close(resolve));
}
expect(seen).toEqual(["127.0.0.1"]);
});
it("reads the stamp back, normalised", () => {
const read = (value?: string) =>
readPeerAddress(
new Request("http://x/", {
headers: value === undefined ? {} : { [PEER_ADDRESS_HEADER]: value },
}),
);
expect(read()).toBeUndefined();
expect(read("::ffff:10.0.0.1")).toBe("10.0.0.1");
expect(read("2001:db8::1")).toBe("2001:db8::1");
expect(read("not-an-ip")).toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* The address a request's TCP connection came from, as a header the route
* handlers can read.
*
* Next does not hand a route the socket. It does fill `X-Forwarded-For` with
* the socket's address, but only when the request carries none, so a caller
* that reaches the server directly and sends its own `X-Forwarded-For`
* chooses the address every per-IP limit, the audit log and the sign-in
* notices see. To tell a forwarded request from a forged one, the client-IP
* resolver (`getClientIpOrTrustWarning` in `@/lib/api-response`) needs the
* connection's own peer, and this provides it.
*
* `installPeerAddressStamp` subscribes to Node's `http.server.request.start`
* diagnostics channel, which fires for every request before the server's
* request handler runs, and writes the socket's remote address into
* `PEER_ADDRESS_HEADER`, overwriting whatever the caller sent under that
* name. It is installed once, at boot, from `src/instrumentation.ts`.
*/
import { subscribe, unsubscribe } from "node:diagnostics_channel";
import type { IncomingMessage } from "node:http";
import { isIP } from "node:net";
import type { Socket } from "node:net";
/** The header the socket's address is written to. Never trusted from a caller. */
export const PEER_ADDRESS_HEADER = "x-healthlog-peer-address";
const INSTALLED = Symbol.for("healthlog.peerAddressStamp");
type StampGlobal = typeof globalThis & { [INSTALLED]?: boolean };
const CHANNEL = "http.server.request.start";
function stamp(message: unknown): void {
const { request, socket } = message as {
request: IncomingMessage;
socket: Socket;
};
request.headers[PEER_ADDRESS_HEADER] = socket.remoteAddress ?? "";
}
/** Start writing each request's socket address into `PEER_ADDRESS_HEADER`. */
export function installPeerAddressStamp(): void {
const g = globalThis as StampGlobal;
if (g[INSTALLED]) return;
subscribe(CHANNEL, stamp);
g[INSTALLED] = true;
}
/** Test hook: a suite that installs the stamp takes it out again. */
export function __uninstallPeerAddressStampForTests(): void {
unsubscribe(CHANNEL, stamp);
(globalThis as StampGlobal)[INSTALLED] = false;
}
/** Whether this process stamps requests (it does once booted). */
export function peerAddressStampInstalled(): boolean {
return (globalThis as StampGlobal)[INSTALLED] === true;
}
/**
* The connection's peer, normalised (an IPv4-mapped IPv6 address reads as
* IPv4). `undefined` when the request carries no stamp, `null` when it
* carries one that is not an address (a socket already gone).
*/
export function readPeerAddress(request: Request): string | null | undefined {
const raw = request.headers.get(PEER_ADDRESS_HEADER);
if (raw === null) return undefined;
const trimmed = raw.trim();
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(trimmed);
const address = mapped ? mapped[1]! : trimmed;
return isIP(address) !== 0 ? address : null;
}