diff --git a/src/lib/rate-limit-ip.test.ts b/src/lib/rate-limit-ip.test.ts index 85f94f3..88fd4b8 100644 --- a/src/lib/rate-limit-ip.test.ts +++ b/src/lib/rate-limit-ip.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from "vitest"; +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"); @@ -18,10 +22,38 @@ describe("resolveClientIp (Client-IP hinter Cloudflare und Traefik)", () => { 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 auf X-Real-IP zurück, sonst „unknown“", () => { + 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)", () => { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 4b9ecb1..360d6a0 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -95,6 +95,24 @@ for (const [cidrs, family] of [[CLOUDFLARE_V4, "ipv4"], [CLOUDFLARE_V6, "ipv6"]] } } +/** Nicht oeffentlich routbare Bereiche; CF-Connecting-IP daraus waere nie ein echter Client (u.a. Cloudflare "Pseudo IPv4" 240/4). */ +const NON_PUBLIC_V4 = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "240.0.0.0/4"]; +const NON_PUBLIC_V6 = ["::/128", "::1/128", "fc00::/7", "fe80::/10"]; +const nonPublic = new BlockList(); +for (const [cidrs, family] of [[NON_PUBLIC_V4, "ipv4"], [NON_PUBLIC_V6, "ipv6"]] as const) { + for (const cidr of cidrs) { + const [net = "", prefix = ""] = cidr.split("/"); + nonPublic.addSubnet(net, Number(prefix), family); + } +} + +/** True nur fuer oeffentliche Unicast-Adressen; IPv4-gemappte IPv6 wird wie die IPv4-Adresse geprueft. */ +function isPublicUnicast(ip: string): boolean { + const subject = rateLimitSubject(ip); // entpackt ::ffff:a.b.c.d zu a.b.c.d + if (isIP(subject) === 4) return subject !== "255.255.255.255" && !nonPublic.check(subject, "ipv4"); + return !nonPublic.check(ip, "ipv6"); +} + function validIp(value: string | null | undefined): string | null { const ip = value?.trim(); return ip && isIP(ip) ? ip : null; @@ -109,8 +127,11 @@ function validIp(value: string | null | undefined): string | null { */ export function resolveClientIp(list: Headers): string { const forwarded = list.get("x-forwarded-for"); - const peer = forwarded === null ? validIp(list.get("x-real-ip")) : validIp(forwarded.split(",").at(-1)); + // x-real-ip ist nur ausserhalb von Produktion ein Fallback; in Produktion setzt Traefik immer x-forwarded-for. + const fallback = process.env.NODE_ENV === "production" ? null : validIp(list.get("x-real-ip")); + const peer = forwarded === null ? fallback : validIp(forwarded.split(",").at(-1)); if (!peer) return "unknown"; - const viaCloudflare = validIp(list.get("cf-connecting-ip")); + const candidate = validIp(list.get("cf-connecting-ip")); + const viaCloudflare = candidate && isPublicUnicast(candidate) ? candidate : null; return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer; }