mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user