From e6fa383934918763c445ea5c74baeea25f2ad4d8 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 10:06:28 +0200 Subject: [PATCH] fix(security): eigene Rate-Limits zaehlen IPv6 je /64-Netz consumeRateLimit bekommt IPv6-Adressen als /64-Praefix (rateLimitSubject): ein Anschluss hat meist ein ganzes /64 und koennte sonst durch Adresswechsel jedes Limit umgehen. better-auth bekommt weiter die einzelne Adresse und normalisiert sie selbst auf /64 (ipv6Subnet, Standard 64). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/api.ts | 4 ++-- src/lib/rate-limit.ts | 31 +++++++++++++++++++++++++++++++ tests/client-ip.test.ts | 15 ++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 07e6946..4dc4bf3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,7 +7,7 @@ import { NextResponse } from "next/server"; import type { z } from "zod"; import { getDb } from "./db"; import { env, requireAppSecret } from "./env"; -import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit"; +import { clientIp, consumeRateLimit, rateLimitSubject, type RateLimitRule } from "./rate-limit"; export type ApiError = { code: string; message: string; fields?: Record }; @@ -71,7 +71,7 @@ export async function readJson( } export async function rateLimited(request: Request, rule: RateLimitRule): Promise { - const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret()); + const result = await consumeRateLimit(getDb(), rule, rateLimitSubject(clientIp(request.headers)), requireAppSecret()); if (result.allowed) return null; return fail( 429, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 5122351..d16c9e0 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -88,6 +88,37 @@ export function clientIp(headers: Headers): string { return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer; } +/** 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 consumeRateLimit: 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. + */ +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`; +} + /** Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */ export function withClientIp(request: Request): Request { const headers = new Headers(request.headers); diff --git a/tests/client-ip.test.ts b/tests/client-ip.test.ts index 461ba5a..e76c02c 100644 --- a/tests/client-ip.test.ts +++ b/tests/client-ip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { CLIENT_IP_HEADER, clientIp, withClientIp } from "@/lib/rate-limit"; +import { CLIENT_IP_HEADER, clientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit"; const h = (values: Record) => new Headers(values); @@ -20,6 +20,19 @@ describe("clientIp hinter Traefik", () => { 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)", () => { + const request = new Request("https://example.org/api/auth/sign-in/email", { headers: { "x-forwarded-for": "2001:db8:1:2:aaaa::1" } }); + expect(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", () => { const request = new Request("https://example.org/api/auth/sign-in/email", { method: "POST",