forked from Mike/freno-dev
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.
This commit is contained in:
250
src/server/trusted-client-ip.ts
Normal file
250
src/server/trusted-client-ip.ts
Normal file
@@ -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
|
||||
* `<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]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user