import { afterEach, describe, expect, test, vi } from "vitest"; import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit"; const h = (values: Record) => new Headers(values); describe("clientIp hinter Traefik", () => { test("Gegenstelle ist der letzte Eintrag in X-Forwarded-For, nie ein Eintrag des Clients", () => { expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", "x-real-ip": "1.2.3.4" }))).toBe("198.51.100.9"); }); test("über Cloudflare gefälschtes X-Real-IP zählt nicht, CF-Connecting-IP nur von Cloudflare-Edges", () => { const viaCloudflare = { "x-real-ip": "1.2.3.4", "x-forwarded-for": "1.2.3.4, 203.0.113.7, 172.70.1.2", "cf-connecting-ip": "203.0.113.7" }; expect(clientIp(h(viaCloudflare))).toBe("203.0.113.7"); expect(clientIp(h({ "x-forwarded-for": "198.51.100.9", "cf-connecting-ip": "203.0.113.7" }))).toBe("198.51.100.9"); }); test("ohne X-Forwarded-For (lokal) gilt X-Real-IP, ungültige Werte ergeben „unknown“", () => { expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9"); expect(clientIp(h({}))).toBe("unknown"); expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, kein-ip" }))).toBe("unknown"); }); test("Rate-Limits zählen IPv6 je /64-Netz, IPv4-gemappte Adressen wie IPv4", () => { expect(rateLimitSubject(clientIp(h({ "x-forwarded-for": "2001:db8:1:2:aaaa::1" })))).toBe("2001:db8:1:2::/64"); expect(rateLimitSubject("2001:0DB8:0001:0002:ffff:ffff:ffff:ffff")).toBe("2001:db8:1:2::/64"); expect(rateLimitSubject("2001:db8:1:3::1")).not.toBe(rateLimitSubject("2001:db8:1:2::1")); expect(rateLimitSubject("::ffff:198.51.100.9")).toBe("198.51.100.9"); expect(rateLimitSubject("198.51.100.9")).toBe("198.51.100.9"); }); test("better-auth bekommt eine einzelne gültige IP (es normalisiert IPv6 selbst auf /64)", async () => { const request = new Request("https://example.org/api/auth/sign-in/email", { headers: { "x-forwarded-for": "2001:db8:1:2:aaaa::1" } }); expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("2001:db8:1:2:aaaa::1"); }); test("withClientIp setzt den Header für better-auth und überschreibt einen mitgeschickten Wert", async () => { const request = new Request("https://example.org/api/auth/sign-in/email", { method: "POST", headers: { "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" }, body: "{}", }); expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9"); }); test("withClientIp verträgt Nexts Proxy um NextRequest (Node 24, undici 7) und behält Methode und Body", async () => { // Next 16 reicht Route-Handlern einen Proxy, dessen Methoden an das Original gebunden sind. // undici 7 prüft private Felder (#state); ein Proxy besteht diese Prüfung nicht. const original = new Request("https://example.org/api/auth/sign-in/email", { method: "POST", headers: { "x-forwarded-for": "198.51.100.9", "content-type": "application/json" }, body: '{"email":"a@example.org"}', }); const wieNext = new Proxy(original, { get(target, key) { const wert = Reflect.get(target, key, target); return typeof wert === "function" ? wert.bind(target) : wert; }, }); const kopie = await withClientIp(wieNext); expect(kopie.method).toBe("POST"); expect(kopie.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9"); expect(await kopie.text()).toBe('{"email":"a@example.org"}'); }); }); describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => { const edge = "172.70.1.2"; const viaEdge = (cf: string) => clientIp(h({ "x-forwarded-for": `1.2.3.4, ${edge}`, "cf-connecting-ip": cf })); test.each([ "0.1.2.3", "10.0.0.5", "100.64.0.1", "100.127.255.255", "127.0.0.1", "169.254.1.1", "172.16.0.1", "172.31.255.255", "192.168.1.1", "240.0.0.1", "255.255.255.255", "::", "::1", "fc00::1", "fd12::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:192.168.0.1", ])("%s wird verworfen, die Gegenstelle gilt", (cf) => { expect(viaEdge(cf)).toBe(edge); }); test.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "2001:db8::1", "::ffff:203.0.113.7"])("%s wird übernommen", (cf) => { expect(viaEdge(cf)).toBe(cf); }); }); describe("X-Real-IP in Produktion", () => { afterEach(() => vi.unstubAllEnvs()); test("ohne X-Forwarded-For ergibt X-Real-IP in Produktion „unknown“", () => { vi.stubEnv("NODE_ENV", "production"); expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown"); expect(clientIp(h({ "x-forwarded-for": "198.51.100.9" }))).toBe("198.51.100.9"); }); test("außerhalb von Produktion gilt X-Real-IP weiter", () => { vi.stubEnv("NODE_ENV", "development"); expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9"); }); }); describe("headersWithClientIp", () => { test("überschreibt einen mitgeschickten Wert und lässt das Original unverändert", () => { const original = h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" }); const result = headersWithClientIp(original); expect(result.get(CLIENT_IP_HEADER)).toBe("198.51.100.9"); expect(original.get(CLIENT_IP_HEADER)).toBe("1.2.3.4"); }); test("ohne ermittelbare IP steht „unknown“ im Header, nie der Clientwert", () => { expect(headersWithClientIp(h({ [CLIENT_IP_HEADER]: "1.2.3.4" })).get(CLIENT_IP_HEADER)).toBe("unknown"); }); });