/** * 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]}`; }