From 2fd2a4a71a5978df1a553e2a8fe7e1354d325930 Mon Sep 17 00:00:00 2001 From: agent-security-engineer Date: Fri, 30 Oct 2026 05:06:18 +0000 Subject: [PATCH] security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getClientIP (the rate-limit and audit key) took the LEFTMOST x-forwarded-for hop and read x-real-ip unconditionally. In our topology — client → Cloudflare → same-host origin proxy → app — both proxies APPEND, so the leftmost hop is whatever the sender typed: a rotating X-Forwarded-For minted a fresh rate-limit bucket per request, and unlimited bug-report quota via the same pattern in routes/api/the-nook/report.ts. x-real-ip is client-settable too; nothing in the chain overwrites it. It is no longer read at all. New trust order (src/server/trusted-client-ip.ts, resolveTrustedClientIp): 1. a non-loopback socket peer IS the client — its headers are ignored; 2. cf-connecting-ip (Cloudflare overwrites it at the edge; parsed as an address or rejected); 3. x-forwarded-for counted from the RIGHT, discounting one hop for the Cloudflare edge address the origin proxy appended (the topology has exactly one trusted proxy below Cloudflare); 4. "unknown" — callers key stricter shared limits on it, so resolution fails closed toward a tighter bucket, never a looser one. Addresses are never truncated: a /24-truncated key would pool attackers with the innocents behind them. Share-vs-copy, stated deliberately: this is a port of the parsing primitives FRE-366 reviewed in request-client.ts, NOT an import. resolveClientIp carries a standing rule (1d9287a) that it never feeds a security decision; lifting that rule for these callers would couple an access-control change to an analytics branch under review, and the analytics helper only exposes truncated prefixes while rate limiting needs the full address. If the two drift, trusted-client-ip.ts is the one security paths follow. The deploy-side precondition (origin reachable only via Cloudflare + the same-host proxy) is documented in the file header; if the app is ever directly internet-reachable no in-app read can recover the true client. Tests updated deliberately, not to go green: rate-limit.test.ts now pins the rightmost-minus-one order, cf-connecting-ip precedence, peer-over-headers, x-real-ip rejection, and the spoof case (a rotating leftmost hop does not change the resolved client); report.test.ts pins the quota key stability under a rotating header. New trusted-client-ip.test.ts covers the helper's own edges: APIEvent wrapper, loopback forms, unparseable hops failing closed. --- src/routes/api/the-nook/report.test.ts | 49 ++++- src/routes/api/the-nook/report.ts | 8 +- src/server/security.ts | 39 +--- src/server/security/rate-limit.test.ts | 97 +++++++++- src/server/security/test-utils.ts | 10 +- src/server/trusted-client-ip.test.ts | 171 +++++++++++++++++ src/server/trusted-client-ip.ts | 250 +++++++++++++++++++++++++ 7 files changed, 577 insertions(+), 47 deletions(-) create mode 100644 src/server/trusted-client-ip.test.ts create mode 100644 src/server/trusted-client-ip.ts diff --git a/src/routes/api/the-nook/report.test.ts b/src/routes/api/the-nook/report.test.ts index 9271566..e5c58c7 100644 --- a/src/routes/api/the-nook/report.test.ts +++ b/src/routes/api/the-nook/report.test.ts @@ -10,6 +10,8 @@ import { describe, expect, it, mock, beforeEach } from "bun:test"; const sentEmails: { to: string; subject: string; html: string }[] = []; const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = []; +/** Every rate-limit key the route derived, in order. */ +const rateKeys: string[] = []; mock.module("~/server/email", () => ({ default: async (to: string, subject: string, html: string) => { @@ -19,7 +21,10 @@ mock.module("~/server/email", () => ({ })); mock.module("~/server/bug-report-rate-limit", () => ({ - takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true } + takeBugReportToken: (key: string) => { + rateKeys.push(key); + return tokenAnswers.shift() ?? { allowed: true }; + } })); const { POST } = await import("./report"); @@ -72,6 +77,7 @@ describe("POST /api/the-nook/report", () => { beforeEach(() => { sentEmails.length = 0; tokenAnswers.length = 0; + rateKeys.length = 0; }); it("emails the owner a report containing the snapshot, escaped", async () => { @@ -133,10 +139,49 @@ describe("POST /api/the-nook/report", () => { it("still succeeds when no fingerprint header exists", async () => { const res = await POST( - request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" }) + request(validPayload, { + "cf-connecting-ip": "203.0.113.9", + "x-forwarded-for": "203.0.113.9, 10.0.0.1" + }) ); expect(res.status).toBe(200); expect(sentEmails).toHaveLength(1); + // The quota key is the Cloudflare-recorded client, not a header hop the + // sender chose. + expect(rateKeys).toEqual(["203.0.113.9"]); + }); + + it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => { + // FRE-371: the old read keyed on the leftmost hop, which the sender + // controls outright — one scripted loop with a rotating header had + // unlimited reports. With the same Cloudflare-recorded client and a + // different spoofed prefix per request, the key must be stable. + for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) { + const res = await POST( + request(validPayload, { + "cf-connecting-ip": "203.0.113.9", + "x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1` + }) + ); + expect(res.status).toBe(200); + } + + expect(rateKeys).toEqual([ + "203.0.113.9", + "203.0.113.9", + "203.0.113.9" + ]); + }); + + it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => { + const res = await POST( + request(validPayload, { + "x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1" + }) + ); + expect(res.status).toBe(200); + // Never the leftmost (sender-chosen) hop. + expect(rateKeys).toEqual(["203.0.113.9"]); }); it("delivers the landing page's beta request with only the email", async () => { diff --git a/src/routes/api/the-nook/report.ts b/src/routes/api/the-nook/report.ts index bbf7b7f..ad3973c 100644 --- a/src/routes/api/the-nook/report.ts +++ b/src/routes/api/the-nook/report.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import sendEmail from "~/server/email"; import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config"; import { takeBugReportToken } from "~/server/bug-report-rate-limit"; +import { resolveTrustedClientIp } from "~/server/trusted-client-ip"; import { json, error } from "./_lib"; /** @@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise { ) .trim() .toLowerCase(); - const forwarded = event.request.headers.get("x-forwarded-for"); - const rateKey = - fingerprint || - (forwarded ? forwarded.split(",")[0]!.trim() : "unknown"); + // FRE-371: the IP fallback must be the spoof-resistant read — a rotating + // leftmost x-forwarded-for used to mint unlimited report quota. + const rateKey = fingerprint || resolveTrustedClientIp(event); const token = takeBugReportToken(rateKey); if (!token.allowed) { return new Response(JSON.stringify({ error: "Too many reports" }), { diff --git a/src/server/security.ts b/src/server/security.ts index fefc762..cf4ed06 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -3,6 +3,7 @@ import { getCookie, setCookie } from "vinxi/http"; import type { H3Event } from "vinxi/http"; import { t } from "~/server/api/utils"; import { logAuditEvent } from "~/server/audit"; +import { resolveTrustedClientIp } from "~/server/trusted-client-ip"; import { env } from "~/env/server"; import { AUTH_CONFIG, @@ -328,39 +329,15 @@ async function cleanupExpiredRateLimits(): Promise { } /** - * Get client IP address from request headers. - * Only trusts X-Forwarded-For outside of local development (set by the Vercel - * edge network in production; trusted in tests so the header-parsing path is - * exercised). In local development, uses the socket address to prevent header - * spoofing. + * Get client IP address for a security decision (rate limiting, audit). + * + * Thin alias over `resolveTrustedClientIp` (./trusted-client-ip), which owns + * the trust order: the previous leftmost `x-forwarded-for` read was + * client-spoofable in our append-only proxy topology, and `x-real-ip` was + * read unconditionally — both fixed there (FRE-371). */ export function getClientIP(event: H3Event): string { - // In production on Vercel, X-Forwarded-For is set by the edge network - // and cannot be spoofed by clients. In dev, ignore it. - if (env.NODE_ENV !== "development") { - const forwarded = getHeaderValue(event, "x-forwarded-for"); - if (forwarded) { - return forwarded.split(",")[0].trim(); - } - const realIP = getHeaderValue(event, "x-real-ip"); - if (realIP) { - return realIP; - } - } - - // Fallback: try socket remote address - try { - const nodeReq = event.node.req; - if (nodeReq?.socket?.remoteAddress) { - const addr = nodeReq.socket.remoteAddress; - // Clean up IPv6-mapped IPv4 addresses - return addr.replace(/^::ffff:/, ""); - } - } catch { - // socket access failed - } - - return "unknown"; + return resolveTrustedClientIp(event); } /** diff --git a/src/server/security/rate-limit.test.ts b/src/server/security/rate-limit.test.ts index 595a6c5..d2f37b8 100644 --- a/src/server/security/rate-limit.test.ts +++ b/src/server/security/rate-limit.test.ts @@ -150,29 +150,100 @@ describe("Rate Limiting", () => { }); describe("getClientIP", () => { - it("should extract IP from x-forwarded-for header", () => { + // FRE-371: the read is spoof-resistant now. In the pinned topology + // (client → Cloudflare → origin proxy → app) both proxies APPEND, so + // x-forwarded-for arrives as `, , ` and the + // leftmost hop is whatever the sender typed. Trust order: + // cf-connecting-ip, then x-forwarded-for rightmost-minus-one, then the + // socket peer — never the leftmost hop, never x-real-ip. + + it("takes x-forwarded-for rightmost-minus-one, never the leftmost hop", () => { const event = createMockEvent({ - headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" } + headers: { + "x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1" + } }); const ip = getClientIP(event); expect(ip).toBe("192.168.1.1"); }); - it("should extract IP from x-real-ip header", () => { + it("prefers cf-connecting-ip (Cloudflare overwrites it at the edge)", () => { + const event = createMockEvent({ + headers: { + "cf-connecting-ip": "203.0.113.7", + "x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1" + } + }); + + const ip = getClientIP(event); + expect(ip).toBe("203.0.113.7"); + }); + + it("a rotating leftmost x-forwarded-for does not change the resolved client", () => { + // The spoof the old read fell for: same Cloudflare-recorded client, + // different sender-chosen prefix per request. The key must be stable, + // or each rotation mints a fresh rate-limit bucket. + const first = getClientIP( + createMockEvent({ + headers: { + "cf-connecting-ip": "203.0.113.7", + "x-forwarded-for": "1.1.1.1, 203.0.113.7, 10.0.0.1" + } + }) + ); + const second = getClientIP( + createMockEvent({ + headers: { + "cf-connecting-ip": "203.0.113.7", + "x-forwarded-for": "2.2.2.2, 203.0.113.7, 10.0.0.1" + } + }) + ); + + expect(first).toBe("203.0.113.7"); + expect(second).toBe(first); + }); + + it("ignores x-real-ip entirely (client-settable in this topology)", () => { const event = createMockEvent({ headers: { "x-real-ip": "192.168.1.2" } }); const ip = getClientIP(event); - expect(ip).toBe("192.168.1.2"); + expect(ip).toBe("unknown"); }); - it("should prefer x-forwarded-for over x-real-ip", () => { + it("ignores a client-supplied single-hop x-forwarded-for with no trusted proxy above it", () => { + // One hop only = the sender's own invention; discounting the trusted + // hop leaves nothing to believe. const event = createMockEvent({ + headers: { "x-forwarded-for": "6.6.6.6" } + }); + + const ip = getClientIP(event); + expect(ip).toBe("unknown"); + }); + + it("a non-loopback socket peer is the client and beats every header", () => { + const event = createMockEvent({ + remoteAddress: "198.51.100.23", headers: { - "x-forwarded-for": "192.168.1.1", - "x-real-ip": "192.168.1.2" + "cf-connecting-ip": "6.6.6.6", + "x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1", + "x-real-ip": "6.6.6.6" + } + }); + + const ip = getClientIP(event); + expect(ip).toBe("198.51.100.23"); + }); + + it("a loopback socket peer lets the forwarded headers speak", () => { + const event = createMockEvent({ + remoteAddress: "127.0.0.1", + headers: { + "x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1" } }); @@ -198,13 +269,23 @@ describe("Rate Limiting", () => { it("should handle IPv6 addresses", () => { const event = createMockEvent({ headers: { - "x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" + "x-forwarded-for": + "2001:0db8:85a3:0000:0000:8a2e:0370:7334, 10.0.0.1" } }); const ip = getClientIP(event); expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); }); + + it("rejects a candidate hop that is not an address", () => { + const event = createMockEvent({ + headers: { "x-forwarded-for": "not-an-ip, 10.0.0.1" } + }); + + const ip = getClientIP(event); + expect(ip).toBe("unknown"); + }); }); describe("rateLimitLogin", () => { diff --git a/src/server/security/test-utils.ts b/src/server/security/test-utils.ts index caac49d..90fd9ac 100644 --- a/src/server/security/test-utils.ts +++ b/src/server/security/test-utils.ts @@ -14,12 +14,15 @@ export function createMockEvent(options: { cookies?: Record; method?: string; url?: string; + /** Socket peer the server observed; omit for a peer-less synthetic event. */ + remoteAddress?: string; }): H3Event { const { headers = {}, cookies = {}, method = "POST", - url = "http://localhost:3000/" + url = "http://localhost:3000/", + remoteAddress } = options; // Build the cookie header string from the cookies object only @@ -49,7 +52,10 @@ export function createMockEvent(options: { headers: headersObj, node: { req: { - headers: allHeaders + headers: allHeaders, + ...(remoteAddress + ? { socket: { remoteAddress } } + : {}) }, res: { cookies: {} diff --git a/src/server/trusted-client-ip.test.ts b/src/server/trusted-client-ip.test.ts new file mode 100644 index 0000000..58ced10 --- /dev/null +++ b/src/server/trusted-client-ip.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "bun:test"; + +import { + resolveTrustedClientIp, + isIpAddress, + isLoopbackAddress, + UNKNOWN_CLIENT_IP, + type ClientIpEventLike +} from "./trusted-client-ip"; + +/** + * Unit coverage for the security-path client-IP read (FRE-371). The caller + * suites (rate-limit.test.ts, report.test.ts) pin the behaviour through + * getClientIP and the route; this file pins the helper's own edges: the peer + * forms a real socket can present, the APIEvent wrapper, and failing closed. + */ +function event(opts: { + headers?: Record; + remoteAddress?: string; + wrapped?: boolean; +}): ClientIpEventLike { + const inner: ClientIpEventLike = { + ...(opts.remoteAddress + ? { node: { req: { socket: { remoteAddress: opts.remoteAddress } } } } + : {}), + request: { headers: new Headers(opts.headers ?? {}) } + }; + // An APIEvent wraps its H3Event as `nativeEvent` (routes/api/* shape). + return opts.wrapped ? { nativeEvent: inner } : inner; +} + +describe("resolveTrustedClientIp", () => { + it("trust order: non-loopback peer, then cf-connecting-ip, then rightmost-minus-one x-forwarded-for", () => { + // Peer wins. + expect( + resolveTrustedClientIp( + event({ + remoteAddress: "198.51.100.23", + headers: { + "cf-connecting-ip": "203.0.113.7", + "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" + } + }) + ) + ).toBe("198.51.100.23"); + + // Loopback peer → Cloudflare's record. + expect( + resolveTrustedClientIp( + event({ + remoteAddress: "127.0.0.1", + headers: { + "cf-connecting-ip": "203.0.113.7", + "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" + } + }) + ) + ).toBe("203.0.113.7"); + + // No CF header → the hop below the trusted proxy. + expect( + resolveTrustedClientIp( + event({ + remoteAddress: "127.0.0.1", + headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" } + }) + ) + ).toBe("203.0.113.7"); + }); + + it("reads the wrapped H3Event of an APIEvent-shaped input", () => { + expect( + resolveTrustedClientIp( + event({ + wrapped: true, + remoteAddress: "127.0.0.1", + headers: { "cf-connecting-ip": "203.0.113.7" } + }) + ) + ).toBe("203.0.113.7"); + }); + + it("never returns a truncated address", () => { + // The full /128 is the bucket key; a truncated one would pool attackers + // with their neighbours. + const ip = resolveTrustedClientIp( + event({ + remoteAddress: "127.0.0.1", + headers: { "cf-connecting-ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" } + }) + ); + expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + }); + + it("fails closed to the stricter unknown key", () => { + // One hop = the sender's own invention; a non-address hop; nothing at all. + expect( + resolveTrustedClientIp( + event({ headers: { "x-forwarded-for": "6.6.6.6" } }) + ) + ).toBe(UNKNOWN_CLIENT_IP); + expect( + resolveTrustedClientIp( + event({ headers: { "cf-connecting-ip": "not-an-ip" } }) + ) + ).toBe(UNKNOWN_CLIENT_IP); + expect(resolveTrustedClientIp(event({}))).toBe(UNKNOWN_CLIENT_IP); + }); + + it("recognises the loopback forms a real peer presents", () => { + expect( + resolveTrustedClientIp( + event({ + // IPv6-mapped IPv4 loopback from a node socket. + remoteAddress: "::ffff:127.0.0.1", + headers: { "cf-connecting-ip": "203.0.113.7" } + }) + ) + ).toBe("203.0.113.7"); + expect( + resolveTrustedClientIp( + event({ + remoteAddress: "::1", + headers: { "cf-connecting-ip": "203.0.113.7" } + }) + ) + ).toBe("203.0.113.7"); + // A non-loopback peer that merely looks odd is still the client. + expect( + resolveTrustedClientIp( + event({ + remoteAddress: "::ffff:203.0.113.7", + headers: { "cf-connecting-ip": "6.6.6.6" } + }) + ) + ).toBe("203.0.113.7"); + }); + + it("never believes x-real-ip", () => { + expect( + resolveTrustedClientIp( + event({ headers: { "x-real-ip": "203.0.113.7" } }) + ) + ).toBe(UNKNOWN_CLIENT_IP); + }); +}); + +describe("address helpers", () => { + it("isIpAddress", () => { + expect(isIpAddress("203.0.113.7")).toBe(true); + expect(isIpAddress("::1")).toBe(true); + expect(isIpAddress("::ffff:203.0.113.7")).toBe(true); + expect(isIpAddress("2001:db8::1")).toBe(true); + expect(isIpAddress("not-an-ip")).toBe(false); + expect(isIpAddress("256.1.1.1")).toBe(false); + expect(isIpAddress("")).toBe(false); + expect(isIpAddress(null)).toBe(false); + expect(isIpAddress(undefined)).toBe(false); + }); + + it("isLoopbackAddress", () => { + expect(isLoopbackAddress("127.0.0.1")).toBe(true); + expect(isLoopbackAddress("127.255.0.3")).toBe(true); + expect(isLoopbackAddress("::1")).toBe(true); + expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true); + expect(isLoopbackAddress("203.0.113.7")).toBe(false); + expect(isLoopbackAddress("::2")).toBe(false); + // Unparseable input is not loopback — fails closed. + expect(isLoopbackAddress("garbage")).toBe(false); + }); +}); diff --git a/src/server/trusted-client-ip.ts b/src/server/trusted-client-ip.ts new file mode 100644 index 0000000..ecffe50 --- /dev/null +++ b/src/server/trusted-client-ip.ts @@ -0,0 +1,250 @@ +/** + * The client address, resolved for security decisions (FRE-371). + * + * This is the ONLY client-IP read allowed to feed a security decision — rate + * limiting, abuse quotas, account lockout, audit trails. It exists separately + * from `request-client.ts`'s `resolveClientIp` (the analytics read) on + * purpose: + * + * - `resolveClientIp` carries a standing rule (FRE-369 review, 1d9287a): + * it never feeds a security decision. Its blast radius is a poisoned + * analytics row; ours is granting or denying access. + * - Rate limiting needs the UN-truncated address; the analytics module only + * ever hands out truncated prefixes. + * - `request-client.ts` ships on FRE-366's branch, which is under review; + * a security fix must not merge-depend on an analytics diff. + * + * The address-parsing primitives below are therefore a deliberate port of the + * reviewed ones, not an import. If the two ever drift, this file's version is + * the one security paths follow. + * + * WHY the old read was spoofable: `security.ts`'s `getClientIP` took the + * LEFTMOST `x-forwarded-for` hop, believing the header "cannot be spoofed". + * In the pinned topology — client → Cloudflare → same-host origin proxy → + * app — both proxies *append*, so the header arrives as + * `, , ` and the + * leftmost hop is exactly the one the sender chose. Rotating + * `X-Forwarded-For: ` per request minted a fresh rate-limit bucket + * and unlimited bug-report quota. `x-real-ip` was read unconditionally too; + * nothing in this topology overwrites it, so it is equally client text and is + * no longer read at all. + * + * Trust order (matches the convention FRE-366 landed after its security + * review, re-derived here for security stakes): + * + * 1. The direct socket peer, when it is a real routable address: it IS the + * client (dev, direct hit), and any forwarded header from it is the + * client describing itself — ignored. + * 2. `cf-connecting-ip` — reachable only when the peer is the loopback + * origin proxy (or unreadable, e.g. a synthetic event in tests). + * Cloudflare *overwrites* this header at the edge, so a client-supplied + * value never survives. Must parse as an address. + * 3. `x-forwarded-for` counted from the RIGHT: drop the rightmost hop (the + * Cloudflare edge address the origin proxy appended) and take the hop + * before it. Never the leftmost. Must parse as an address. + * 4. "unknown" — callers give unknown-IP keys stricter shared limits, so + * an unresolvable client fails toward a tighter bucket, never a looser + * one. + * + * Deploy-side precondition this code cannot verify (same as the analytics + * read): the origin must be reachable only through Cloudflare and the + * same-host proxy. If the app is directly internet-reachable, a client can + * set every header read here and no in-app read can recover the truth. + */ + +/** Cloudflare's "who actually connected" header: overwritten at the edge. */ +const CF_CONNECTING_IP = "cf-connecting-ip"; +const X_FORWARDED_FOR = "x-forwarded-for"; + +/** + * Hops discounted from the right of `x-forwarded-for` before the value is + * taken: the Cloudflare edge address the origin proxy appended. Pinned to 1 + * because there is exactly one trusted proxy between Cloudflare and the app; + * raise it only if the topology gains a trusted hop. + */ +const FORWARDED_HOPS_TO_DISCOUNT = 1; + +/** Returned when no trustworthy address exists. Callers rate-limit it harder. */ +export const UNKNOWN_CLIENT_IP = "unknown"; + +/** + * The shapes this module reads: an H3Event (`security.ts` callers) or a + * SolidStart APIEvent (`routes/api/*`), which wraps its H3Event as + * `nativeEvent`. Every field is optional because test doubles are minimal; + * anything unreadable simply falls through to the next trust step. + */ +export interface ClientIpEventLike { + request?: { headers?: unknown }; + headers?: unknown; + node?: { + req?: { + headers?: Record; + socket?: { remoteAddress?: string }; + }; + }; + nativeEvent?: ClientIpEventLike; +} + +/** + * The client address for a security decision, per the trust order above. + * Never truncated: rate-limit buckets are per-address, and truncating would + * let an attacker share a bucket with the innocents behind their /24. + */ +export function resolveTrustedClientIp(event: ClientIpEventLike): string { + // An APIEvent's socket and (authoritative) headers live on the wrapped + // H3Event; a bare APIEvent-shaped double without one is read as-is. + const source = event.nativeEvent ?? event; + + const peer = directPeer(source); + if (peer && isIpAddress(peer) && !isLoopbackAddress(peer)) return peer; + + // The peer is the loopback origin proxy (or unreadable): forwarded headers + // may be believed, in the order above. + const connecting = cleanHop(headerValue(source, CF_CONNECTING_IP)); + if (isIpAddress(connecting)) return connecting; + + const hops = (headerValue(source, X_FORWARDED_FOR) ?? "") + .split(",") + .map(cleanHop) + .filter(Boolean); + const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT; + if (index >= 0 && isIpAddress(hops[index])) return hops[index]!; + + return UNKNOWN_CLIENT_IP; +} + +/** The socket peer, IPv6-mapped IPv4 flattened (`::ffff:1.2.3.4` → `1.2.3.4`). */ +function directPeer(event: ClientIpEventLike): string | null { + try { + const addr = event.node?.req?.socket?.remoteAddress; + return addr ? addr.replace(/^::ffff:/, "") : null; + } catch { + return null; + } +} + +/** + * Header read tolerant of every event shape in play: Fetch-style + * `request.headers.get`, an h3 `event.headers` (Headers or plain object), and + * the raw node request headers. Header names are case-insensitive per HTTP; + * the plain-object fallbacks lowercase to match how node and the test doubles + * store them. + */ +function headerValue(event: ClientIpEventLike, name: string): string | null { + try { + const fromHeaders = (h: unknown): string | null => { + if (!h) return null; + if (typeof (h as Headers).get === "function") { + return (h as Headers).get(name) ?? null; + } + if (typeof h === "object") { + const record = h as Record; + const value = record[name] ?? record[name.toLowerCase()]; + return typeof value === "string" ? value : null; + } + return null; + }; + + return ( + fromHeaders(event.request?.headers) ?? + fromHeaders(event.headers) ?? + fromHeaders(event.node?.req?.headers) + ); + } catch { + return null; + } +} + +function cleanHop(value: string | null | undefined): string { + return (value ?? "").trim(); +} + +// ── Addresses: parse and validate ─────────────────────────────────────────── +// Ported from the reviewed `request-client.ts` (FRE-366 @ 1d9287a); see the +// header comment for why this is a copy and not an import. + +/** Any address this file understands: a dotted quad or an IPv6 literal. */ +export function isIpAddress(value: string | null | undefined): boolean { + return value !== null && value !== undefined && addressBytes(value) !== null; +} + +/** + * Whether an address is the loopback the production proxy presents. + * + * Parses rather than string-matches, so the forms a real peer can present in — + * `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all + * recognised. An address this does not parse is *not* loopback, which fails + * closed: forwarded headers from an unrecognised peer are ignored rather than + * believed (a non-loopback peer short-circuits before headers are read). + */ +export function isLoopbackAddress(ip: string): boolean { + const bytes = addressBytes(ip); + if (!bytes) return false; + if (bytes.length === 4) return bytes[0] === 127; + + // IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too. + const mapped = ipv4FromBytes(bytes); + if (mapped) return isLoopbackAddress(mapped); + const tail = bytes[15]!; + return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1; +} + +function isIpv4(value: string): boolean { + const parts = value.split("."); + if (parts.length !== 4) return false; + return parts.every( + (part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part) + ); +} + +/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */ +function addressBytes(value: string): number[] | null { + const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!; + if (isIpv4(raw)) return raw.split(".").map(Number); + if (!raw.includes(":")) return null; + + const halves = raw.split("::"); + if (halves.length > 2) return null; + const compressed = halves.length === 2; + + const head = groupsToBytes(halves[0] ?? ""); + const tail = groupsToBytes(halves[1] ?? ""); + if (!head || !tail) return null; + + const total = head.length + tail.length; + if (compressed) { + if (total > 8) return null; + return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16); + } + if (total !== 8) return null; + return [...head, ...tail].flatMap(split16); +} + +/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */ +function groupsToBytes(side: string): number[] | null { + if (side === "") return []; + const out: number[] = []; + for (const part of side.split(":")) { + if (/^[0-9a-fA-F]{1,4}$/.test(part)) { + out.push(parseInt(part, 16)); + } else if (isIpv4(part)) { + const o = part.split(".").map(Number); + out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!); + } else { + return null; + } + } + return out; +} + +function split16(group: number): number[] { + return [(group >> 8) & 0xff, group & 0xff]; +} + +/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */ +function ipv4FromBytes(bytes: number[]): string | null { + const prefix = bytes.slice(0, 10).every((byte) => byte === 0); + const marker = bytes[10] === 0xff && bytes[11] === 0xff; + if (!prefix || !marker) return null; + return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`; +} -- 2.49.1