From 5a88f3dcd6af31019a74ea6b75be3dbd5c3b1857 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 11:26:56 +0200 Subject: [PATCH] fix: Client-IP haerten (CF-Connecting-IP, X-Real-IP, Admin-Session) - CF-Connecting-IP gilt nur noch als oeffentliche Unicast-Adresse (nicht 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16, 240/4, Broadcast, ::, ::1, fc00::/7, fe80::/10, ::ffff:privat); sonst gilt die Gegenstelle. - X-Real-IP ohne X-Forwarded-For nur noch ausserhalb von Produktion; in Produktion ergibt das "unknown". - Neuer Helfer headersWithClientIp, von withClientIp und der Admin-Session-Pruefung genutzt: ein vom Client gesendeter x-artista-client-ip-Wert wird nie geglaubt. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/admin-auth.ts | 3 ++- src/lib/rate-limit.ts | 36 ++++++++++++++++++++++++++----- tests/client-ip.test.ts | 48 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index 38011cb..bff3cdb 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -7,6 +7,7 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; import type { Actor } from "./audit"; import { getAuth } from "./auth"; +import { headersWithClientIp } from "./rate-limit"; export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean }; @@ -15,7 +16,7 @@ async function currentAdmin(): Promise { // Sonst versucht "next build" sie vorab zu rendern und scheitert ohne // Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET). const requestHeaders = await headers(); - const session = await getAuth().api.getSession({ headers: requestHeaders }); + const session = await getAuth().api.getSession({ headers: headersWithClientIp(requestHeaders) }); if (!session) return null; const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null }; return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) }; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d16c9e0..2cfcb68 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -67,6 +67,26 @@ const CLOUDFLARE: [string, number, "ipv4" | "ipv6"][] = [ const cloudflare = new BlockList(); for (const [net, prefix, family] of CLOUDFLARE) cloudflare.addSubnet(net, prefix, family); +/** Nicht oeffentliche Bereiche; 240/4 nutzt Cloudflare fuer "Pseudo IPv4". */ +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 nonPublic = new BlockList(); +for (const cidr of NON_PUBLIC_V4) nonPublic.addSubnet(cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4"); +nonPublic.addAddress("255.255.255.255", "ipv4"); +nonPublic.addAddress("::", "ipv6"); +nonPublic.addAddress("::1", "ipv6"); +nonPublic.addSubnet("fc00::", 7, "ipv6"); +nonPublic.addSubnet("fe80::", 10, "ipv6"); + +function isPublicUnicast(ip: string): boolean { + if (isIP(ip) === 4) return !nonPublic.check(ip, "ipv4"); + 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 !nonPublic.check([high >> 8, high & 0xff, low >> 8, low & 0xff].join("."), "ipv4"); + } + return !nonPublic.check(ip, "ipv6"); +} + function validIp(value: string | null | undefined): string | null { const ip = value?.trim(); return ip && isIP(ip) ? ip : null; @@ -82,10 +102,11 @@ function validIp(value: string | null | undefined): string | null { */ export function clientIp(headers: Headers): string { const forwarded = headers.get("x-forwarded-for"); - const peer = forwarded === null ? validIp(headers.get("x-real-ip")) : validIp(forwarded.split(",").at(-1)); + const realIp = process.env.NODE_ENV === "production" ? null : validIp(headers.get("x-real-ip")); + const peer = forwarded === null ? realIp : 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; + return viaCloudflare && isPublicUnicast(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. */ @@ -119,9 +140,14 @@ export function rateLimitSubject(ip: string): string { return `${groups.slice(0, 4).map((group) => group.toString(16)).join(":")}::/64`; } +/** Kopie der Header mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */ +export function headersWithClientIp(source: Headers): Headers { + const headers = new Headers(source); + headers.set(CLIENT_IP_HEADER, clientIp(source)); + return headers; +} + /** 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 }); + return new Request(request, { headers: headersWithClientIp(request.headers) }); } diff --git a/tests/client-ip.test.ts b/tests/client-ip.test.ts index e76c02c..a844c6b 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, rateLimitSubject, withClientIp } from "@/lib/rate-limit"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit"; const h = (values: Record) => new Headers(values); @@ -42,3 +42,47 @@ describe("clientIp hinter Traefik", () => { expect(withClientIp(request).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9"); }); }); + +describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => { + const edge = "172.70.1.2"; + const viaEdge = (cf: string) => clientIp(h({ "x-forwarded-for": `1.2.3.4, ${edge}`, "cf-connecting-ip": cf })); + + test.each([ + "0.1.2.3", "10.0.0.5", "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::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:192.168.0.1", + ])("%s wird verworfen, die Gegenstelle gilt", (cf) => { + expect(viaEdge(cf)).toBe(edge); + }); + + test.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "2001:db8::1", "::ffff:203.0.113.7"])("%s wird übernommen", (cf) => { + expect(viaEdge(cf)).toBe(cf); + }); +}); + +describe("X-Real-IP in Produktion", () => { + afterEach(() => vi.unstubAllEnvs()); + + test("ohne X-Forwarded-For ergibt X-Real-IP in Produktion „unknown“", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown"); + expect(clientIp(h({ "x-forwarded-for": "198.51.100.9" }))).toBe("198.51.100.9"); + }); + + test("außerhalb von Produktion gilt X-Real-IP weiter", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9"); + }); +}); + +describe("headersWithClientIp", () => { + test("überschreibt einen mitgeschickten Wert und lässt das Original unverändert", () => { + const original = h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" }); + const result = headersWithClientIp(original); + expect(result.get(CLIENT_IP_HEADER)).toBe("198.51.100.9"); + expect(original.get(CLIENT_IP_HEADER)).toBe("1.2.3.4"); + }); + + test("ohne ermittelbare IP steht „unknown“ im Header, nie der Clientwert", () => { + expect(headersWithClientIp(h({ [CLIENT_IP_HEADER]: "1.2.3.4" })).get(CLIENT_IP_HEADER)).toBe("unknown"); + }); +});