From f75a5f7769f283fe323a1f8bc88a069e004a53db Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 09:49:58 +0200 Subject: [PATCH] fix(security): Client-IP nicht aus X-Real-IP, better-auth nur mit Server-Wert Traefik vertraut seit 2026-10-12 den weitergeleiteten Headern aus Cloudflare-Netzen. Wer eine eigene Cloudflare-Zone auf den Server zeigen laesst, konnte damit X-Real-IP und den ersten X-Forwarded-For-Eintrag faelschen und die Rate-Limits (Buchung, Kontakt, Login, 2FA) umgehen. - clientIp: Gegenstelle ist der letzte X-Forwarded-For-Eintrag (von Traefik angehaengt); CF-Connecting-IP nur, wenn sie eine Cloudflare-Edge ist. - better-auth liest nur noch x-artista-client-ip, den der Auth-Endpunkt per withClientIp setzt (ein mitgeschickter Wert wird ersetzt). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/api/auth/[...all]/route.ts | 5 ++-- src/lib/auth.ts | 5 +++- src/lib/rate-limit.ts | 46 +++++++++++++++++++++++++----- tests/client-ip.test.ts | 31 ++++++++++++++++++++ 4 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 tests/client-ip.test.ts diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index cb91831..face8f3 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,10 +1,11 @@ import { toNextJsHandler } from "better-auth/next-js"; import { getAuth } from "@/lib/auth"; +import { withClientIp } from "@/lib/rate-limit"; export async function GET(request: Request) { - return toNextJsHandler(getAuth()).GET(request); + return toNextJsHandler(getAuth()).GET(withClientIp(request)); } export async function POST(request: Request) { - return toNextJsHandler(getAuth()).POST(request); + return toNextJsHandler(getAuth()).POST(withClientIp(request)); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index cea5860..745cffb 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -17,6 +17,7 @@ import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned"; import { twoFactor } from "better-auth/plugins/two-factor"; import { getDb } from "./db"; import { env } from "./env"; +import { CLIENT_IP_HEADER } from "./rate-limit"; function createAuth(options: { allowSignUp?: boolean } = {}) { const e = env(); @@ -51,7 +52,9 @@ function createAuth(options: { allowSignUp?: boolean } = {}) { }, advanced: { useSecureCookies: e.APP_URL.startsWith("https://"), - ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] }, + // Nur die vom Server ermittelte IP (withClientIp im Auth-Endpunkt), nie X-Real-IP + // oder X-Forwarded-For direkt: ueber Cloudflare kaemen sie vom Client. + ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] }, }, plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()], }); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 32d433c..5122351 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -5,6 +5,7 @@ * * Die IP-Adresse wird nie gespeichert, nur ein HMAC davon. */ +import { BlockList, isIP } from "node:net"; import type { Db } from "./db"; import { hmacHex } from "./tokens"; @@ -51,14 +52,45 @@ export async function purgeRateLimits(db: Db): Promise { return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`; } +/** Vom Server gesetzte Client-IP fuer better-auth (withClientIp), nie vom Client uebernommen. */ +export const CLIENT_IP_HEADER = "x-artista-client-ip"; + +/** https://www.cloudflare.com/ips/ (Stand 2026-10). */ +const CLOUDFLARE: [string, number, "ipv4" | "ipv6"][] = [ + ...["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22"].map( + (cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4"] as [string, number, "ipv4"], + ), + ...["2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"].map( + (cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv6"] as [string, number, "ipv6"], + ), +]; +const cloudflare = new BlockList(); +for (const [net, prefix, family] of CLOUDFLARE) cloudflare.addSubnet(net, prefix, family); + +function validIp(value: string | null | undefined): string | null { + const ip = value?.trim(); + return ip && isIP(ip) ? ip : null; +} + /** - * Client-IP hinter Traefik. Traefik setzt X-Real-Ip selbst und ersetzt - * X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint- - * Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md). + * Client-IP hinter Traefik. Gegenstelle ist der letzte Eintrag in + * X-Forwarded-For, den Traefik aus der TCP-Verbindung anhaengt; alles links + * davon und X-Real-IP kann vom Client stammen, sobald die Anfrage ueber + * Cloudflare kommt (Traefik vertraut Cloudflare, forwardedHeaders.trustedIPs). + * Ist die Gegenstelle eine Cloudflare-Edge, gilt CF-Connecting-IP. Ohne + * X-Forwarded-For (kein Traefik davor, lokal) gilt X-Real-IP. */ export function clientIp(headers: Headers): string { - const realIp = headers.get("x-real-ip")?.trim(); - if (realIp) return realIp; - const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim(); - return forwarded || "unknown"; + const forwarded = headers.get("x-forwarded-for"); + const peer = forwarded === null ? validIp(headers.get("x-real-ip")) : validIp(forwarded.split(",").at(-1)); + if (!peer) return "unknown"; + const viaCloudflare = validIp(headers.get("cf-connecting-ip")); + return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer; +} + +/** 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); + headers.set(CLIENT_IP_HEADER, clientIp(request.headers)); + return new Request(request, { headers }); } diff --git a/tests/client-ip.test.ts b/tests/client-ip.test.ts new file mode 100644 index 0000000..461ba5a --- /dev/null +++ b/tests/client-ip.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { CLIENT_IP_HEADER, clientIp, 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("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", + headers: { "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" }, + body: "{}", + }); + expect(withClientIp(request).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9"); + }); +});