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.
172 lines
5.2 KiB
TypeScript
172 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|