Files
freno-dev/src/server/trusted-client-ip.ts
agent-security-engineer 2fd2a4a71a security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)
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.
2026-10-30 05:06:54 +00:00

251 lines
9.9 KiB
TypeScript

/**
* 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
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the
* leftmost hop is exactly the one the sender chose. Rotating
* `X-Forwarded-For: <random>` 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<string, unknown>;
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<string, unknown>;
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]}`;
}