diff --git a/src/selfhost/trusted-client-ip.ts b/src/selfhost/trusted-client-ip.ts new file mode 100644 index 0000000000..7ac9b5bc14 --- /dev/null +++ b/src/selfhost/trusted-client-ip.ts @@ -0,0 +1,95 @@ +// Resolve the real client IP for self-host rate limiting (#477). +export function resolveTrustedClientIp( + peerAddress: string | undefined, + headers: Headers, +): string { + const peer = normalizeIpAddress(stripIpv4MappedPrefix(peerAddress)); + const xReal = normalizeIpAddress(headers.get("x-real-ip") ?? undefined); + const xff = normalizeIpAddress(headers.get("x-forwarded-for")?.split(",")[0]?.trim()); + + if (peer && isPrivateOrLinkLocal(peer)) { + return xReal ?? xff ?? peer; + } + return peer ?? "unknown-ip"; +} + +export function peerRemoteAddress(nodeEnv: unknown): string | undefined { + if (!nodeEnv || typeof nodeEnv !== "object") return undefined; + const incoming = (nodeEnv as { incoming?: unknown }).incoming; + if (!incoming || typeof incoming !== "object") return undefined; + const socket = (incoming as { socket?: unknown }).socket; + if (!socket || typeof socket !== "object") return undefined; + const remote = (socket as { remoteAddress?: unknown }).remoteAddress; + return typeof remote === "string" ? remote : undefined; +} + +export function withTrustedClientIp(request: Request, peerAddress: string | undefined): Request { + const headers = new Headers(request.headers); + headers.delete("cf-connecting-ip"); + const clientIp = resolveTrustedClientIp(peerAddress, headers); + if (clientIp !== "unknown-ip") headers.set("cf-connecting-ip", clientIp); + return new Request(request, { headers }); +} + +export function applySelfHostFetchTrustedClientIp(request: Request, nodeEnv: unknown): Request { + return withTrustedClientIp(request, peerRemoteAddress(nodeEnv)); +} + +function stripIpv4MappedPrefix(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value; +} + +function normalizeIpAddress(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || !isValidIpAddress(trimmed)) return undefined; + if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed.slice(1, -1); + return trimmed; +} + +function isValidIpAddress(value: string): boolean { + return isValidIpv4(value) || isValidIpv6(value); +} + +function isValidIpv4(value: string): boolean { + const parts = value.split("."); + if (parts.length !== 4) return false; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return false; + const octet = Number(part); + if (octet < 0 || octet > 255) return false; + } + return true; +} + +function isValidIpv6(value: string): boolean { + let candidate = value; + if (candidate.startsWith("[") && candidate.endsWith("]")) candidate = candidate.slice(1, -1); + if (!candidate.includes(":") || !/^[0-9a-fA-F:.]+$/.test(candidate)) return false; + if (candidate.split("::").length > 2) return false; + const segments = candidate.split(":"); + if (segments.length > 8) return false; + let hasHexSegment = false; + for (const segment of segments) { + if (segment === "") continue; + if (!/^[0-9a-fA-F]{1,4}$/.test(segment)) return false; + hasHexSegment = true; + } + return hasHexSegment; +} + +export function isPrivateOrLinkLocal(ip: string): boolean { + if (ip === "::1" || ip === "0:0:0:0:0:0:0:1") return true; + if (ip.startsWith("fe80:") || ip.startsWith("FE80:")) return true; + if (/^[fF][cCdD]/.test(ip)) return true; + + const parts = ip.split(".").map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n))) return false; + const [a, b] = parts as [number, number, number, number]; + if (a === 10) return true; + if (a === 127) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 169 && b === 254) return true; + return false; +} diff --git a/src/server.ts b/src/server.ts index 47461d93c2..9d7a3eaef4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import { existsSync, readdirSync, writeFileSync } from "node:fs"; import { delimiter, join } from "node:path"; import { randomUUID } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; -import { serve } from "@hono/node-server"; +import { serve, type Http2Bindings, type HttpBindings } from "@hono/node-server"; import packageJson from "../package.json"; import worker from "./index"; import { githubRestRateLimitRemainingSamples } from "./github/client"; @@ -44,6 +44,7 @@ import { createOrbRelayRegistrationState, isOrbBrokerMode, registerOrbRelayTarge import { exportOrbBatch } from "./selfhost/orb-collector"; import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter"; import { loadFileSecrets } from "./selfhost/load-file-secrets"; +import { applySelfHostFetchTrustedClientIp } from "./selfhost/trusted-client-ip"; import { backupAcknowledgedGaugeValue, buildHealthBody, @@ -893,7 +894,14 @@ async function main(): Promise { const port = Number(process.env.PORT ?? 8787); const server = serve( { - fetch: async (request: Request) => { + fetch: async (request: Request, nodeEnv: HttpBindings | Http2Bindings) => { + // Self-host rate limiting keys off cf-connecting-ip (auth/rate-limit.ts). On Workers that header is + // edge-set; on Node it is attacker-controlled unless we overwrite it from the TCP peer / Caddy hop + // here (see trusted-client-ip.ts). Health/ready/metrics below still see the rewritten request. + // peerRemoteAddress reads the documented @hono/node-server HttpBindings/Http2Bindings shape + // (`incoming.socket.remoteAddress`) — covered by unit tests so a wrong field path cannot silently + // collapse every client into unknown-ip. + request = applySelfHostFetchTrustedClientIp(request, nodeEnv); const path = new URL(request.url).pathname; if (path === "/health") return new Response( diff --git a/test/unit/trusted-client-ip.test.ts b/test/unit/trusted-client-ip.test.ts new file mode 100644 index 0000000000..f84da36b42 --- /dev/null +++ b/test/unit/trusted-client-ip.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + applySelfHostFetchTrustedClientIp, + isPrivateOrLinkLocal, + peerRemoteAddress, + resolveTrustedClientIp, + withTrustedClientIp, +} from "../../src/selfhost/trusted-client-ip"; + +describe("trusted-client-ip (self-host rate-limit identity)", () => { + it("REGRESSION: ignores client-spoofed cf-connecting-ip when the TCP peer is public (direct expose)", () => { + const headers = new Headers({ + "cf-connecting-ip": "203.0.113.1", + "x-real-ip": "198.51.100.9", + "x-forwarded-for": "198.51.100.9", + }); + expect(resolveTrustedClientIp("203.0.113.50", headers)).toBe("203.0.113.50"); + expect(resolveTrustedClientIp("203.0.113.51", headers)).toBe("203.0.113.51"); + }); + + it("REGRESSION: behind a private proxy hop (Caddy), prefers X-Real-IP over a spoofed cf-connecting-ip", () => { + const headers = new Headers({ + "cf-connecting-ip": "203.0.113.1", + "x-real-ip": "198.51.100.20", + "x-forwarded-for": "198.51.100.20", + }); + expect(resolveTrustedClientIp("10.0.0.2", headers)).toBe("198.51.100.20"); + expect(resolveTrustedClientIp("172.16.5.1", headers)).toBe("198.51.100.20"); + expect(resolveTrustedClientIp("192.168.1.1", headers)).toBe("198.51.100.20"); + }); + + it("falls back to leftmost X-Forwarded-For when X-Real-IP is absent behind a private hop", () => { + const headers = new Headers({ + "cf-connecting-ip": "203.0.113.9", + "x-forwarded-for": "198.51.100.30, 10.0.0.2", + }); + expect(resolveTrustedClientIp("10.0.0.2", headers)).toBe("198.51.100.30"); + }); + + it("uses the private peer itself when Caddy headers are missing", () => { + expect(resolveTrustedClientIp("10.0.0.2", new Headers({ "cf-connecting-ip": "1.2.3.4" }))).toBe("10.0.0.2"); + }); + + it("returns unknown-ip when no usable peer or proxy header is present", () => { + expect(resolveTrustedClientIp(undefined, new Headers({ "cf-connecting-ip": "203.0.113.1" }))).toBe("unknown-ip"); + expect(resolveTrustedClientIp("not-an-ip", new Headers())).toBe("unknown-ip"); + }); + + it("strips IPv4-mapped IPv6 peer prefixes", () => { + expect(resolveTrustedClientIp("::ffff:203.0.113.50", new Headers())).toBe("203.0.113.50"); + expect( + resolveTrustedClientIp("::ffff:10.0.0.2", new Headers({ "x-real-ip": "198.51.100.40" })), + ).toBe("198.51.100.40"); + }); + + it("withTrustedClientIp deletes spoofed cf-connecting-ip and sets the trusted value", () => { + const original = new Request("https://orb.example/v1/auth/github/session", { + headers: { + "cf-connecting-ip": "203.0.113.1", + "x-real-ip": "198.51.100.55", + }, + }); + const trusted = withTrustedClientIp(original, "10.0.0.5"); + expect(trusted.headers.get("cf-connecting-ip")).toBe("198.51.100.55"); + expect(original.headers.get("cf-connecting-ip")).toBe("203.0.113.1"); + }); + + it("withTrustedClientIp omits cf-connecting-ip when identity is unknown-ip", () => { + const trusted = withTrustedClientIp( + new Request("https://orb.example/health", { headers: { "cf-connecting-ip": "203.0.113.1" } }), + undefined, + ); + expect(trusted.headers.get("cf-connecting-ip")).toBeNull(); + }); + + it("applySelfHostFetchTrustedClientIp mirrors the server.ts serve({ fetch }) edge wiring", () => { + const original = new Request("https://orb.example/v1/auth/github/session", { + headers: { + "cf-connecting-ip": "203.0.113.1", + "x-real-ip": "198.51.100.88", + }, + }); + const trusted = applySelfHostFetchTrustedClientIp(original, { + incoming: { socket: { remoteAddress: "10.0.0.5" } }, + }); + expect(trusted.headers.get("cf-connecting-ip")).toBe("198.51.100.88"); + }); + + it("peerRemoteAddress reads the documented HttpBindings/Http2Bindings socket path", () => { + expect(peerRemoteAddress({ incoming: { socket: { remoteAddress: "10.0.0.2" } } })).toBe("10.0.0.2"); + expect(peerRemoteAddress({ incoming: { socket: { remoteAddress: "203.0.113.9" } } })).toBe("203.0.113.9"); + expect(peerRemoteAddress({ incoming: { socket: null } })).toBeUndefined(); + expect(peerRemoteAddress({ incoming: null })).toBeUndefined(); + expect(peerRemoteAddress({})).toBeUndefined(); + expect(peerRemoteAddress(null)).toBeUndefined(); + expect(peerRemoteAddress(undefined)).toBeUndefined(); + expect(peerRemoteAddress({ incoming: { socket: { remoteAddress: 123 } } })).toBeUndefined(); + }); + + it("accepts bracketed IPv6 peers/headers and rejects malformed IPv4/IPv6", () => { + expect(resolveTrustedClientIp("[2001:db8::1]", new Headers())).toBe("2001:db8::1"); + expect( + resolveTrustedClientIp("10.0.0.2", new Headers({ "x-real-ip": "[2001:db8::abcd]" })), + ).toBe("2001:db8::abcd"); + expect(resolveTrustedClientIp("1.2.3", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("1.2.3.4.5", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("1.2.3.999", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("1.2.3.a", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("gggg::1", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("2001:db8::1::2", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("1:2:3:4:5:6:7:8:9", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("2001:db8::zzzz", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("2001:db8::12345", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("2001:db8::1.2", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("notaip", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("[2001:db8::1", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp(" ", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp(" 203.0.113.50 ", new Headers())).toBe("203.0.113.50"); + }); + + it("classifies expanded loopback and unique-local IPv6 peers", () => { + expect(isPrivateOrLinkLocal("0:0:0:0:0:0:0:1")).toBe(true); + expect(isPrivateOrLinkLocal("FE80::1")).toBe(true); + expect(isPrivateOrLinkLocal("fd12::1")).toBe(true); + expect(isPrivateOrLinkLocal("not.an.ip.addr")).toBe(false); + expect(isPrivateOrLinkLocal("8.8.8.8")).toBe(false); + }); + + it("falls through XFF when X-Real-IP is present but invalid behind a private hop", () => { + expect( + resolveTrustedClientIp( + "10.0.0.2", + new Headers({ "x-real-ip": "not-an-ip", "x-forwarded-for": "198.51.100.77, 10.0.0.2" }), + ), + ).toBe("198.51.100.77"); + }); + + it("classifies private / link-local / loopback peers", () => { + expect(isPrivateOrLinkLocal("10.1.2.3")).toBe(true); + expect(isPrivateOrLinkLocal("192.168.0.1")).toBe(true); + expect(isPrivateOrLinkLocal("172.16.0.1")).toBe(true); + expect(isPrivateOrLinkLocal("172.31.255.255")).toBe(true); + expect(isPrivateOrLinkLocal("127.0.0.1")).toBe(true); + expect(isPrivateOrLinkLocal("169.254.1.1")).toBe(true); + expect(isPrivateOrLinkLocal("::1")).toBe(true); + expect(isPrivateOrLinkLocal("fe80::1")).toBe(true); + expect(isPrivateOrLinkLocal("fc00::1")).toBe(true); + expect(isPrivateOrLinkLocal("203.0.113.1")).toBe(false); + expect(isPrivateOrLinkLocal("172.15.0.1")).toBe(false); + expect(isPrivateOrLinkLocal("172.32.0.1")).toBe(false); + }); +});