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:
2026-10-30 05:06:18 +00:00
committed by Paperclip Agent
parent 134f41eac4
commit 2fd2a4a71a
7 changed files with 577 additions and 47 deletions

View File

@@ -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<string, string>;
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);
});
});