fix(security): Client-IP nicht aus X-Real-IP, better-auth nur mit Server-Wert
Docker-Images bauen und veroeffentlichen / build-and-push (push) Canceled after 0s

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 09:49:58 +02:00
1 parent c3fdfbdc77
commit f75a5f7769
4 files changed
+77 -10

No files matched your search

+3 -2
View File
@@ -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));
}
+4 -1
View File
@@ -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()],
});
+39 -7
View File
@@ -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<number> {
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 });
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { CLIENT_IP_HEADER, clientIp, withClientIp } from "@/lib/rate-limit";
const h = (values: Record<string, string>) => 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");
});
});