From 5a8ff270907e4a4f66332481fe57922df52995a7 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 09:59:09 +0200 Subject: [PATCH] fix(security): Rate-Limits zaehlen IPv6 je /64-Netz Seit CF-Connecting-IP gilt, sieht der Limiter echte IPv6-Adressen. Ein Anschluss bekommt meist ein ganzes /64 und koennte durch Adresswechsel jedes Limit umgehen (Login, 2FA, Checkout). getClientIp liefert IPv6 deshalb als /64-Praefix, IPv4-gemappte Adressen wie IPv4 (wie VyCept, #94). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/rate-limit-ip.test.ts | 17 ++++++++++++++++- src/lib/rate-limit.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/lib/rate-limit-ip.test.ts b/src/lib/rate-limit-ip.test.ts index 672656e..85f94f3 100644 --- a/src/lib/rate-limit-ip.test.ts +++ b/src/lib/rate-limit-ip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveClientIp } from "./rate-limit"; +import { rateLimitSubject, resolveClientIp } from "./rate-limit"; const h = (values: Record) => new Headers(values); @@ -23,3 +23,18 @@ describe("resolveClientIp (Client-IP hinter Cloudflare und Traefik)", () => { expect(resolveClientIp(h({}))).toBe("unknown"); }); }); + +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"); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index e724f23..4b9ecb1 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -49,7 +49,39 @@ export function rateLimit(key: string, limit: number, windowMs: number): boolean * Rate-Limits in dieser Datei faelschen (Login, 2FA, Checkout, Reset, ...). */ export async function getClientIp(): Promise { - return resolveClientIp(await headers()); + return rateLimitSubject(resolveClientIp(await headers())); +} + +/** Die acht 16-Bit-Gruppen einer gueltigen IPv6-Adresse; eine IPv4-Endung (::ffff:a.b.c.d) wird umgerechnet. */ +function ipv6Groups(ip: string): number[] { + let text = ip.toLowerCase().split("%")[0] ?? ""; + const dotted = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text); + if (dotted) { + const [a = 0, b = 0, c = 0, d = 0] = dotted.slice(1).map(Number); + text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; + } + const parse = (part: string) => (part ? part.split(":").map((group) => Number.parseInt(group, 16)) : []); + const [head = "", tail] = text.split("::"); + const left = parse(head); + if (tail === undefined) return left; + const right = parse(tail); + return [...left, ...new Array(8 - left.length - right.length).fill(0), ...right]; +} + +/** + * Schluessel fuer Rate-Limits: IPv4 unveraendert, IPv6 als /64-Netz. Ein + * Anschluss bekommt meist ein ganzes /64; einzeln gezaehlt liesse sich jedes + * Limit durch Wechsel der Adresse innerhalb des eigenen Netzes umgehen. + * IPv4-gemappte Adressen zaehlen wie die IPv4-Adresse selbst. + */ +export function rateLimitSubject(ip: string): string { + if (isIP(ip) !== 6) return ip; + const groups = ipv6Groups(ip); + if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { + const [high = 0, low = 0] = groups.slice(6); + return [high >> 8, high & 0xff, low >> 8, low & 0xff].join("."); + } + return `${groups.slice(0, 4).map((group) => group.toString(16)).join(":")}::/64`; } /** https://www.cloudflare.com/ips/ (Stand 2026-10). */