import { afterEach, describe, expect, it, vi } from "vitest"; import { rateLimitSubject, resolveClientIp } from "./rate-limit"; const h = (values: Record) => new Headers(values); afterEach(() => { vi.unstubAllEnvs(); }); describe("resolveClientIp (Client-IP hinter Cloudflare und Traefik)", () => { it("nimmt den letzten X-Forwarded-For-Eintrag, nie einen vom Client gesetzten", () => { expect(resolveClientIp(h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9" }))).toBe("198.51.100.9"); }); it("nimmt CF-Connecting-IP, wenn die Gegenstelle eine Cloudflare-Edge ist", () => { const viaCloudflare = { "x-forwarded-for": "1.2.3.4, 203.0.113.7, 172.70.1.2", "cf-connecting-ip": "203.0.113.7" }; expect(resolveClientIp(h(viaCloudflare))).toBe("203.0.113.7"); expect(resolveClientIp(h({ "x-forwarded-for": "2606:4700:10::1", "cf-connecting-ip": "2001:db8::5" }))).toBe("2001:db8::5"); }); it("ignoriert CF-Connecting-IP bei direktem Zugriff an Cloudflare vorbei", () => { expect(resolveClientIp(h({ "x-forwarded-for": "198.51.100.9", "cf-connecting-ip": "203.0.113.7" }))).toBe("198.51.100.9"); }); it("fällt ohne X-Forwarded-For außerhalb von Produktion auf X-Real-IP zurück, sonst „unknown“", () => { vi.stubEnv("NODE_ENV", "development"); expect(resolveClientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9"); expect(resolveClientIp(h({}))).toBe("unknown"); }); it("ignoriert X-Real-IP in Produktion, wenn X-Forwarded-For fehlt", () => { vi.stubEnv("NODE_ENV", "production"); expect(resolveClientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown"); expect(resolveClientIp(h({ "x-forwarded-for": "198.51.100.9", "x-real-ip": "203.0.113.1" }))).toBe("198.51.100.9"); }); describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => { const edge = "172.70.1.2"; const nonPublic = [ "0.0.0.0", "0.1.2.3", "10.0.0.1", "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:3456::1", "fe80::1", "febf::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1", "::ffff:192.168.1.1", "::ffff:240.0.0.1", "::ffff:0.0.0.0", ]; it.each(nonPublic)("verwirft %s und nutzt die Cloudflare-Edge als Gegenstelle", (ip) => { expect(resolveClientIp(h({ "x-forwarded-for": edge, "cf-connecting-ip": ip }))).toBe(edge); }); it.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "239.1.1.1", "2001:db8::5", "::ffff:203.0.113.7"])( "akzeptiert öffentliche Adresse %s", (ip) => { expect(resolveClientIp(h({ "x-forwarded-for": edge, "cf-connecting-ip": ip }))).toBe(ip); }, ); }); }); describe("rateLimitSubject (IPv6 je /64)", () => { it("fasst alle Adressen eines /64-Netzes zusammen, sonst ließe sich jedes Limit durch Adresswechsel umgehen", () => { expect(rateLimitSubject("2001:db8:1:2::1")).toBe("2001:db8:1:2::/64"); expect(rateLimitSubject("2001:db8:1:2:aaaa:bbbb:cccc:dddd")).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")); }); it("lässt IPv4 unverändert und behandelt IPv4-gemappte Adressen wie IPv4", () => { expect(rateLimitSubject("198.51.100.9")).toBe("198.51.100.9"); expect(rateLimitSubject("::ffff:198.51.100.9")).toBe("198.51.100.9"); expect(rateLimitSubject("unknown")).toBe("unknown"); }); });