diff --git a/.env.production.example b/.env.production.example index ecf31e293..353335f6f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -525,8 +525,10 @@ API_TOKEN_HMAC_KEY="" # it unset, or an attacker could forge the header. Off by default. # Number of trusted reverse-proxy hops in front of the app; the client IP is # read that many entries from the right of the X-Forwarded-For chain. 0 for a -# directly-exposed host, 1 (the default when unset) behind a single reverse -# proxy; X-Real-IP is read only at 1. A wrong value makes the app refuse the +# directly-exposed host (the connection's own address is used), 1 (the +# default when unset) behind a single reverse proxy; X-Real-IP is read only +# at 1. Forwarding headers are never read from a connection that comes from a +# public address: only a proxy on loopback or a private network is believed. A wrong value makes the app refuse the # headers, log one warning, and collapse anonymous callers into one # rate-limit bucket. # TRUST_PROXY_HOPS="1" diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md index 5ce2db75f..4ccbbfaf6 100644 --- a/docs/self-hosting/reverse-proxy.md +++ b/docs/self-hosting/reverse-proxy.md @@ -40,22 +40,35 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries. This guards against a client rotating `X-Forwarded-For` per request to defeat IP-based rate limits. -| Topology | `TRUST_PROXY_HOPS` | -| --------------------------------------------------------- | ------------------------------------- | -| App is internet-facing with no proxy | `0` (no forwarding header is trusted) | -| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) | -| Cloudflare → your proxy → app | `2` | -| Cloudflare → Coolify-Tunnel → Coolify → app | `3` | +| Topology | `TRUST_PROXY_HOPS` | +| --------------------------------------------------------- | ---------------------------------- | +| App is internet-facing with no proxy | `0` (the connection's own address) | +| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) | +| Cloudflare → your proxy → app | `2` | +| Cloudflare → Coolify-Tunnel → Coolify → app | `3` | A misconfigured count logs a one-shot warning to stderr and collapses every anonymous caller into one shared rate-limit bucket. Match the value to the actual hop count. +Forwarding headers are believed only from a connection that could come +from a proxy: loopback, a private or unique-local network, or CGNAT (a +Tailscale address). A request that reaches HealthLog straight from a public +address carries headers its sender wrote, so HealthLog ignores them and +uses the connection's own address, and says so once in the log. A proxy on +the same host or Docker network connects from a private address, so the +usual setups above need nothing. If your proxy reaches HealthLog over a +public address, put it on a private network with HealthLog instead (or, for +Cloudflare straight to the origin, use `TRUST_CF_CONNECTING_IP=1`). The log +line also shows up when the published port is open to the internet beside +the proxy; bind it to the proxy's network or to `127.0.0.1` then. + `X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no `X-Forwarded-For`, which covers a single proxy that sets only `X-Real-IP`. With `0` neither header is believed, because without a proxy -in front both come from the caller. With `2` or more, the hop count +in front both come from the caller; the address the connection came from +is used instead. With `2` or more, the hop count describes an `X-Forwarded-For` chain, so have your proxies send one. If requests arrive with a forwarding header the setting does not let diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 20e08db29..e7cd50243 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -18,6 +18,9 @@ async function registerOfflineGeoNoticeAtBoot(): Promise { export async function register() { // Only start the worker on the Node.js server runtime (not Edge, not build) if (process.env.NEXT_RUNTIME === "nodejs") { + // First: every request then carries its socket's address (peer-address). + (await import("@/lib/http/peer-address")).installPeerAddressStamp(); + // The event-loop monitor comes first, so the loading of the database // client and of the worker graph is inside its view, and every stall it // reports names the boot step that was running (`boot-steps.ts`). diff --git a/src/lib/__tests__/get-client-ip.test.ts b/src/lib/__tests__/get-client-ip.test.ts index 56d4aec47..05b5a318a 100644 --- a/src/lib/__tests__/get-client-ip.test.ts +++ b/src/lib/__tests__/get-client-ip.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + describe, + it, + expect, + beforeEach, + afterEach, + onTestFinished, + vi, +} from "vitest"; import { getClientIp, getClientIpOrTrustWarning, @@ -363,3 +371,70 @@ describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => { } }); }); + +/** + * A caller that reaches the server directly used to choose its own address: + * one trusted hop is the default, and the rightmost X-Forwarded-For entry is + * whatever the caller wrote when no proxy appended to it. Every per-IP limit + * became per-request. The server now stamps each request with its + * connection's peer (`@/lib/http/peer-address`), and forwarding headers are + * read only from a peer that could be a proxy. + */ +describe("getClientIp against the connection's own peer", () => { + const stamped = (peer: string, headers: Record = {}) => + makeRequest({ ...headers, "x-healthlog-peer-address": peer }); + + it("ignores forwarding headers from a public peer, however they rotate", () => { + const ips = ["203.0.113.1", "203.0.113.2", "203.0.113.3"].map((forged) => + getClientIp( + stamped("198.51.100.7", { + "x-forwarded-for": forged, + "x-real-ip": forged, + }), + ), + ); + expect(ips).toEqual(["198.51.100.7", "198.51.100.7", "198.51.100.7"]); + }); + + it("reads forwarding headers from a proxy on a private network or loopback", () => { + for (const proxy of ["172.18.0.2", "127.0.0.1", "::1", "100.101.102.103"]) { + expect( + getClientIp(stamped(proxy, { "x-forwarded-for": "203.0.113.5" })), + ).toBe("203.0.113.5"); + } + expect( + getClientIp(stamped("10.0.0.2", { "x-real-ip": "203.0.113.6" })), + ).toBe("203.0.113.6"); + }); + + it("reads an IPv4 peer that the socket reports in IPv6 form", () => { + expect( + getClientIp( + stamped("::ffff:198.51.100.7", { "x-forwarded-for": "203.0.113.1" }), + ), + ).toBe("198.51.100.7"); + }); + + it("uses the peer itself with no proxy declared, instead of nothing", () => { + process.env.TRUST_PROXY_HOPS = "0"; + expect( + getClientIp( + stamped("198.51.100.7", { "x-forwarded-for": "203.0.113.1" }), + ), + ).toBe("198.51.100.7"); + }); + + it("trusts no forwarding header on a request the server did not stamp, once it stamps", async () => { + const { installPeerAddressStamp, __uninstallPeerAddressStampForTests } = + await import("../http/peer-address"); + installPeerAddressStamp(); + onTestFinished(__uninstallPeerAddressStampForTests); + expect( + getClientIp(makeRequest({ "x-forwarded-for": "203.0.113.1" })), + ).toBeNull(); + // A stamp that is not an address (the socket was already gone). + expect( + getClientIp(stamped("", { "x-forwarded-for": "203.0.113.1" })), + ).toBeNull(); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 060a86062..d59a27564 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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 Promise>( 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(() => diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 81edb9819..f40ef67da 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -3,6 +3,11 @@ import { NextResponse } from "next/server"; import type { ZodError, ZodIssue } from "zod/v4"; import { defaultErrorCodeForStatus } from "@/lib/api/default-error-codes"; +import { + peerAddressStampInstalled, + readPeerAddress, +} from "@/lib/http/peer-address"; +import { isPublicIp } from "@/lib/validations/notifications"; export function apiSuccess(data: T, status = 200) { return NextResponse.json({ data, error: null }, { status }); @@ -485,13 +490,22 @@ export async function safeJson( * Trust model (`TRUST_PROXY_HOPS` env): * - "0" → trust no forwarding header at all, XFF or x-real-ip * (use this if HealthLog is internet-facing without a - * reverse proxy you control); the IP resolves to null + * reverse proxy you control); the IP is the connection's + * own peer address * - "1" (default)→ trust exactly one hop (typical Coolify / Caddy / * Cloudflare-Tunnel single-proxy deployment); read the * rightmost XFF entry which is the IP your proxy * observed when the request arrived. * - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry. * + * Forwarding headers are read only from a connection whose peer could be a + * proxy: loopback, a private or unique-local network, CGNAT (Tailscale). A + * connection from a public address reached the server directly, so whatever + * forwarding headers it sends are its own, and its own address is the client + * IP. The peer comes from `@/lib/http/peer-address`. Without that check, the + * default of one trusted hop let any direct caller name a fresh address per + * request and step around every per-IP limit. + * * Cloudflare opt-in (`TRUST_CF_CONNECTING_IP=1`): when the env flag is * set, the helper prefers the `cf-connecting-ip` header before walking * the XFF chain. Cloudflare strips and re-sets this header on every @@ -610,6 +624,29 @@ function warnUnreadProxyHeadersOnce(hops: number, header: string): void { ); } +/** + * Once per process: a connection from a public address carried forwarding + * headers, which are ignored. Either HealthLog is reachable without its + * proxy (the published port is open to the internet), or the proxy in front + * connects from a public address and needs `TRUST_CF_CONNECTING_IP` or a + * private network between it and HealthLog. + */ +let publicPeerForwardingWarned = false; + +/** Test-only reset for {@link warnPublicPeerForwardingOnce}. */ +export function _resetPublicPeerForwardingWarningForTests(): void { + publicPeerForwardingWarned = false; +} + +function warnPublicPeerForwardingOnce(): void { + if (publicPeerForwardingWarned) return; + publicPeerForwardingWarned = true; + console.warn( + "[getClientIp] a connection from a public address sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " + + "If HealthLog is meant to be reached only through a reverse proxy, the published port is open beyond it.", + ); +} + /** * Tagged return shape so a caller can apply a tighter universal * rate-limit when the trust chain is misconfigured. F-6 (mobile security @@ -640,6 +677,30 @@ export function getClientIpOrTrustWarning(request: Request): { const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS); + // The connection's own peer, stamped by the server for every request it + // receives. `undefined` only for a request the server did not receive (a + // suite's hand-built request) — or, once the stamp is installed, one that + // arrived before it was, whose forwarding headers nothing vouches for. + const peer = readPeerAddress(request); + if (peer === null || (peer === undefined && peerAddressStampInstalled())) { + return { ip: null, trustViolation: false }; + } + if (peer !== undefined) { + // No proxy declared: the peer is the client. + if (hops === 0) return { ip: peer, trustViolation: false }; + // A peer on a public address reached the server directly; its + // forwarding headers are its own choice, not a proxy's report. + if (isPublicIp(peer)) { + if ( + request.headers.has("x-real-ip") || + request.headers.get("x-forwarded-for")?.trim() !== peer + ) { + warnPublicPeerForwardingOnce(); + } + return { ip: peer, trustViolation: false }; + } + } + if (hops > 0) { const forwarded = request.headers.get("x-forwarded-for"); if (forwarded) { diff --git a/src/lib/http/__tests__/peer-address.test.ts b/src/lib/http/__tests__/peer-address.test.ts new file mode 100644 index 000000000..9c9b6e7bb --- /dev/null +++ b/src/lib/http/__tests__/peer-address.test.ts @@ -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 = []; + const server = createServer((req, res) => { + seen.push(req.headers[PEER_ADDRESS_HEADER]); + res.end("ok"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const { port } = server.address() as AddressInfo; + try { + await new Promise((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(); + }); +}); diff --git a/src/lib/http/peer-address.ts b/src/lib/http/peer-address.ts new file mode 100644 index 000000000..610c02437 --- /dev/null +++ b/src/lib/http/peer-address.ts @@ -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; +}