import net from "node:net"; export type ClientAddressMode = "direct" | "trusted-proxy"; export type ClientAddressConfigErrorCode = | "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION" | "CLIENT_ADDRESS_HEADER_INVALID" | "CLIENT_ADDRESS_HEADER_MISSING" | "CLIENT_ADDRESS_MODE_INVALID" | "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY" | "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"; const clientAddressConfigErrorMessages: Readonly> = Object.freeze({ CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION: "Direct client-address mode must not also configure a header or trusted proxies.", CLIENT_ADDRESS_HEADER_INVALID: "The client-address header must be a valid HTTP header name.", CLIENT_ADDRESS_HEADER_MISSING: "Trusted-proxy client-address mode requires an explicit header name.", CLIENT_ADDRESS_MODE_INVALID: 'The client-address mode must be "direct" or "trusted-proxy".', CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY: "Trusted-proxy client-address mode requires at least one trusted proxy address or range.", CLIENT_ADDRESS_TRUSTED_PROXY_INVALID: "Every trusted proxy entry must be a valid IPv4/IPv6 address or CIDR range.", }); /** Never carries the offending mode, header name, or trusted-proxy value. */ export class ClientAddressConfigError extends Error { readonly code: ClientAddressConfigErrorCode; constructor(code: ClientAddressConfigErrorCode) { super(clientAddressConfigErrorMessages[code]); this.name = "ClientAddressConfigError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export interface DirectClientAddressConfig { readonly mode: "direct"; } /** * `header` is the one header name the reverse proxy is trusted to set — the platform transport * boundary (`apps/api/src/client-address.ts`) overwrites any externally supplied copy of it before * this policy ever sees the request, so it never needs to be `X-Forwarded-For` specifically, though * that remains the common choice. `trustedProxies` lists the proxy's own address(es)/range(s), never * a broad private range that also covers real clients. */ export interface TrustedProxyClientAddressConfig { readonly mode: "trusted-proxy"; readonly header: string; readonly trustedProxies: readonly string[]; } export type ClientAddressConfig = DirectClientAddressConfig | TrustedProxyClientAddressConfig; export interface ClientAddressConfigInput { readonly mode?: unknown; readonly header?: unknown; readonly trustedProxies?: unknown; } const HEADER_TOKEN_PATTERN = /^[a-z0-9-]+$/u; const CIDR_PREFIX_PATTERN = /^\d+$/u; const IPV4_MAPPED_PATTERN = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/iu; /** * Normalizes a single address token for consistent identity/comparison: plain IPv4 is returned * as-is, an IPv4-mapped IPv6 address (`::ffff:a.b.c.d` — the form Node's own `socket.remoteAddress` * and real reverse proxies actually emit for an IPv4 peer on a dual-stack listener) collapses to its * embedded IPv4 form, and any other valid IPv6 address is lowercased. A zone id (`%eth0`) is * rejected as ambiguous outside its own interface. Anything that is not a syntactically valid IPv4 * or IPv6 address returns `undefined` rather than being guessed at. */ export function normalizeClientAddress(address: string): string | undefined { const trimmed = address.trim(); if (trimmed.length === 0) { return undefined; } if (net.isIPv4(trimmed)) { return trimmed; } if (trimmed.includes("%")) { return undefined; } if (net.isIPv6(trimmed)) { const mapped = IPV4_MAPPED_PATTERN.exec(trimmed); if (mapped) { const embedded = mapped[1] as string; return net.isIPv4(embedded) ? embedded : undefined; } return trimmed.toLowerCase(); } return undefined; } interface ParsedTrustedProxyEntry { readonly network: string; readonly prefix: number; readonly family: "ipv4" | "ipv6"; } function parseTrustedProxyEntry(entry: string): ParsedTrustedProxyEntry | undefined { const slashIndex = entry.indexOf("/"); const addressPart = slashIndex === -1 ? entry : entry.slice(0, slashIndex); const network = normalizeClientAddress(addressPart); if (!network) { return undefined; } const family: "ipv4" | "ipv6" = net.isIPv4(network) ? "ipv4" : "ipv6"; const maxPrefix = family === "ipv4" ? 32 : 128; if (slashIndex === -1) { return { family, network, prefix: maxPrefix }; } const prefixPart = entry.slice(slashIndex + 1); if (!CIDR_PREFIX_PATTERN.test(prefixPart)) { return undefined; } const prefix = Number(prefixPart); return prefix <= maxPrefix ? { family, network, prefix } : undefined; } /** Trusted-proxy entries that are not a valid IPv4/IPv6 address or CIDR range — used both by config * validation and, if ever needed diagnostically, by Doctor. */ export function findInvalidTrustedProxies(entries: readonly string[]): readonly string[] { return entries.filter((entry) => parseTrustedProxyEntry(entry) === undefined); } /** * Validates explicit, typed client-address configuration. The safe default (`mode: "direct"`) is * used whenever `mode` is entirely omitted; every other field is validated strictly, and an * incomplete or contradictory combination (proxy details without proxy mode, proxy mode without * proxy details) is rejected outright rather than guessed at. `internalHeaderName` is the one * platform-owned header (`TRUSTED_CLIENT_ADDRESS_HEADER`, `./runtime.js`) a caller must never be * able to configure as its own "trusted" source header. */ export function createClientAddressConfig( input: ClientAddressConfigInput = {}, internalHeaderName?: string, ): ClientAddressConfig { const mode = input.mode ?? "direct"; if (mode !== "direct" && mode !== "trusted-proxy") { throw new ClientAddressConfigError("CLIENT_ADDRESS_MODE_INVALID"); } if (mode === "direct") { if (input.header !== undefined || input.trustedProxies !== undefined) { throw new ClientAddressConfigError("CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION"); } return Object.freeze({ mode: "direct" }); } if (typeof input.header !== "string" || input.header.trim().length === 0) { throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_MISSING"); } const header = input.header.trim().toLowerCase(); if (!HEADER_TOKEN_PATTERN.test(header)) { throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID"); } if (internalHeaderName !== undefined && header === internalHeaderName) { // The internal sanitized header is never a valid *source* header — trusting it as the // caller-forwarded value would let a caller set its own "resolved" address directly. throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID"); } if (!Array.isArray(input.trustedProxies) || input.trustedProxies.length === 0) { throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY"); } const trustedProxies = input.trustedProxies.map((entry) => { if (typeof entry !== "string" || entry.trim().length === 0) { throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"); } return entry.trim(); }); if (findInvalidTrustedProxies(trustedProxies).length > 0) { throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"); } return Object.freeze({ header, mode: "trusted-proxy", trustedProxies: Object.freeze(trustedProxies), }); } export type ClientAddressFailureReason = | "FORWARDED_CHAIN_EXHAUSTED" | "FORWARDED_HEADER_MALFORMED" | "FORWARDED_HEADER_MISSING" | "PEER_ADDRESS_UNAVAILABLE"; export type ClientAddressResolution = | { readonly ok: true; readonly address: string } | { readonly ok: false; readonly reason: ClientAddressFailureReason }; export interface ClientAddressResolver { resolve(input: { readonly peerAddress: string | undefined; readonly headers: Headers; }): ClientAddressResolution; } function buildTrustedProxyMatcher(trustedProxies: readonly string[]): (address: string) => boolean { const blockList = new net.BlockList(); for (const entry of trustedProxies) { const parsed = parseTrustedProxyEntry(entry); if (!parsed) { // Config validation already rejects this at startup; a matcher is only ever built from an // already-validated config, so this can only mean the config was constructed unsafely. throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"); } blockList.addSubnet(parsed.network, parsed.prefix, parsed.family); } return (address: string): boolean => blockList.check(address, net.isIPv4(address) ? "ipv4" : "ipv6"); } /** * Builds the one server-owned client-address resolution policy this API runtime uses for every * request, from already-validated configuration. Built once at startup (never per-request) so the * trusted-proxy `BlockList` is compiled a single time. Depends only on `node:net` and the standard * Fetch `Headers` type — never Hono or `@hono/node-server` — so it stays reusable by Core's own * identity composition without pulling an HTTP framework into Core; the Node/Hono transport glue * that reads the actual socket peer and rewrites the request lives in `apps/api` instead * (`apps/api/src/client-address.ts`). * * Direct mode trusts only the actual immediate socket peer — never any request header. Trusted-proxy * mode trusts the configured forwarded header's content only when the actual immediate socket peer * matches a configured trusted-proxy address/range; otherwise it falls back to direct-mode behavior * (the peer address itself), exactly as if no forwarded header existed at all. A forwarded chain is * walked right to left: trailing hops that match a trusted proxy are skipped, and the first hop from * the right that does not match is the resolved client address. A chain that is missing, empty, * contains any malformed token, or consists entirely of trusted-proxy hops (no genuine client * address left to resolve) fails closed rather than guessing — never a caller-chosen bucket, and * never a silent collapse onto the proxy's own address. */ export function createClientAddressResolver(config: ClientAddressConfig): ClientAddressResolver { const isTrustedProxy = config.mode === "trusted-proxy" ? buildTrustedProxyMatcher(config.trustedProxies) : undefined; return { resolve({ peerAddress, headers }): ClientAddressResolution { const normalizedPeer = peerAddress === undefined ? undefined : normalizeClientAddress(peerAddress); if (!normalizedPeer) { return { ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" }; } if (config.mode === "direct" || !isTrustedProxy?.(normalizedPeer)) { return { ok: true, address: normalizedPeer }; } const headerValue = headers.get(config.header); if (headerValue === null) { return { ok: false, reason: "FORWARDED_HEADER_MISSING" }; } const tokens = headerValue.split(",").map((token) => token.trim()); if (tokens.length === 0 || tokens.some((token) => token.length === 0)) { return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" }; } const normalizedTokens: string[] = []; for (const token of tokens) { const normalized = normalizeClientAddress(token); if (!normalized) { return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" }; } normalizedTokens.push(normalized); } for (let index = normalizedTokens.length - 1; index >= 0; index -= 1) { const candidate = normalizedTokens[index]; if (candidate !== undefined && !isTrustedProxy(candidate)) { return { ok: true, address: candidate }; } } return { ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" }; }, }; }