diff --git a/src/selfhost/cf-workers-shim.ts b/src/selfhost/cf-workers-shim.ts index 4307219150..1a066a47d0 100644 --- a/src/selfhost/cf-workers-shim.ts +++ b/src/selfhost/cf-workers-shim.ts @@ -1,8 +1,8 @@ // Minimal stand-in for the `cloudflare:workers` module on the Node self-host runtime. The only import of it -// in the codebase is `DurableObject` (auth/rate-limit.ts → the RateLimiter DO). That DO is NEVER instantiated -// on self-host — env.RATE_LIMITER is undefined, so enforceRateLimit returns null before any DO is touched — -// so this base class only needs to make the import + `extends DurableObject` resolve. The self-host esbuild -// build aliases `cloudflare:workers` to this file (see the Docker build / build:selfhost script). +// in the codebase is `DurableObject` (auth/rate-limit.ts → the RateLimiter DO). Self-host binds a Redis +// RATE_LIMITER (server.ts) that speaks the same DO fetch surface — this base class only needs to make the +// import + `extends DurableObject` resolve. The self-host esbuild build aliases `cloudflare:workers` to this +// file (see the Docker build / build:selfhost script). export class DurableObject { constructor( protected ctx?: unknown, diff --git a/src/selfhost/trusted-client-ip.ts b/src/selfhost/trusted-client-ip.ts new file mode 100644 index 0000000000..e6dbdd511f --- /dev/null +++ b/src/selfhost/trusted-client-ip.ts @@ -0,0 +1,109 @@ +// Resolve the real client IP for self-host rate limiting (#critical: cf-connecting-ip spoof). +// +// On Cloudflare Workers, `cf-connecting-ip` is edge-set and safe for `clientIp()` in auth/rate-limit.ts. +// On Node self-host the same header is just another request header — fully attacker-controlled — while +// Redis RATE_LIMITER IS bound (server.ts). Caddy (caddy/Caddyfile) injects X-Real-IP / X-Forwarded-For +// from `{remote_host}` but does not set or strip `cf-connecting-ip`. +// +// This module overwrites `cf-connecting-ip` at the Node edge before the Worker fetch runs: +// • Always delete any client-supplied `cf-connecting-ip` (never trust it on Node). +// • If the TCP peer is a private/link-local/loopback hop (compose Caddy → app), prefer Caddy's +// X-Real-IP, then the leftmost X-Forwarded-For hop. +// • Otherwise use the TCP peer (direct :8787 expose) and ignore proxy headers (client-spoofable). +// Workers remain unchanged: they never call this helper. +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"; +} + +/** Read the TCP peer from @hono/node-server's documented fetch second argument (`HttpBindings` / + * `Http2Bindings`: `incoming.socket.remoteAddress`). Exported so server.ts wiring is unit-testable + * without booting serve(). Accepts `unknown` so HttpBindings | Http2Bindings both type-check. */ +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; +} + +/** Return a Request whose `cf-connecting-ip` is the Node-edge-trusted client IP (see resolveTrustedClientIp). */ +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 }); +} + +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; +} + +/** RFC1918 / link-local / loopback — the hop we see when Caddy (or another compose proxy) fronts the app. */ +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; + // Unique-local IPv6 (fc00::/7) + 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 76554442d5..6b7f55b8da 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import { delimiter, join } from "node:path"; import { randomUUID } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; import { serve } from "@hono/node-server"; +import type { Http2Bindings, HttpBindings } from "@hono/node-server"; import packageJson from "../package.json"; import worker from "./index"; import { githubRestRateLimitRemainingSamples } from "./github/client"; @@ -44,6 +45,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 { peerRemoteAddress, withTrustedClientIp } from "./selfhost/trusted-client-ip"; import { backupAcknowledgedGaugeValue, buildHealthBody, @@ -890,7 +892,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 = withTrustedClientIp(request, peerRemoteAddress(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..0cf8d6dfe8 --- /dev/null +++ b/test/unit/trusted-client-ip.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + 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", + }); + // Public peer → use peer; proxy/CF headers are client-controlled on a direct :8787 path. + 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("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"); + // Segment longer than 4 hex digits passes the outer charset but fails per-segment validation. + expect(resolveTrustedClientIp("2001:db8::12345", new Headers())).toBe("unknown-ip"); + // Dot-containing IPv6 form: outer charset allows `.`, per-segment hex check rejects. + 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"); + // Whitespace-only / trim edge cases. + 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); + }); +});