import { describe, expect, test } from "bun:test"; import { getClientIp } from "../../src/lib/client-ip.ts"; import { rateLimitByIp } from "../../src/lib/rate-limit.ts"; // Caddy runs on the same box, so every production request reaches us from // loopback with the address Caddy saw appended to X-Forwarded-For. const CADDY = "::ffff:127.0.0.1"; const CLOUDFLARE_EDGE = "172.68.0.1"; function peer(address: string | null) { return address ? { requestIP: () => ({ address }) } : null; } function req(headers: Record = {}) { return new Request("https://lichen.wiki/", { headers }); } describe("getClientIp", () => { test("keys on the visitor Cloudflare forwarded, not the edge", () => { const ip = getClientIp( req({ "x-forwarded-for": `203.0.113.7, ${CLOUDFLARE_EDGE}` }), peer(CADDY), ); expect(ip).toBe("203.0.113.7"); }); test("ignores addresses the client prepended to the chain", () => { const forged = getClientIp( req({ "x-forwarded-for": `1.1.1.1, 203.0.113.7, ${CLOUDFLARE_EDGE}`, "cf-connecting-ip": "1.1.1.1", }), peer(CADDY), ); expect(forged).toBe("203.0.113.7"); }); test("a request straight to the origin keys on its own address", () => { // Caddy appends the real peer; everything left of it is the caller's. const ip = getClientIp( req({ "x-forwarded-for": "9.9.9.9, 203.0.113.7", "cf-connecting-ip": "8.8.8.8", }), peer(CADDY), ); expect(ip).toBe("203.0.113.7"); }); test("falls back to cf-connecting-ip when the whole chain is trusted", () => { // A visitor whose own address is inside a Cloudflare range. const ip = getClientIp( req({ "x-forwarded-for": `104.16.0.9, ${CLOUDFLARE_EDGE}`, "cf-connecting-ip": "104.16.0.9", }), peer(CADDY), ); expect(ip).toBe("104.16.0.9"); }); test("unmaps ::ffff: so one visitor gets one identity", () => { expect(getClientIp(req(), peer("::ffff:203.0.113.7"))).toBe("203.0.113.7"); }); test("skips chain entries that are not addresses", () => { const ip = getClientIp( req({ "x-forwarded-for": `not-an-ip, ${CLOUDFLARE_EDGE}` }), peer(CADDY), ); expect(ip).toBe("127.0.0.1"); }); test("no socket means no identity, headers notwithstanding", () => { expect(getClientIp(req({ "cf-connecting-ip": "203.0.113.7" }), null)).toBe( null, ); }); }); describe("rateLimitByIp", () => { const once = { limit: 1, windowMs: 60_000 }; test("a forged header cannot mint a fresh bucket", () => { const forge = (claim: string) => rateLimitByIp( "test-forge", req({ "cf-connecting-ip": claim, "x-forwarded-for": claim }), peer("203.0.113.7"), once, ); expect(forge("1.1.1.1")).toBe(null); expect(forge("2.2.2.2")?.status).toBe(429); }); test("separate visitors get separate buckets", () => { const visit = (address: string) => rateLimitByIp("test-visitors", req(), peer(address), once); expect(visit("203.0.113.7")).toBe(null); expect(visit("203.0.113.8")).toBe(null); expect(visit("203.0.113.7")?.status).toBe(429); }); test("unidentifiable clients are not collapsed onto a shared bucket", () => { expect(rateLimitByIp("test-unknown", req(), null, once)).toBe(null); expect(rateLimitByIp("test-unknown", req(), null, once)).toBe(null); }); });