Files
Nick Szaunig 9785eb221f
Docker-Images bauen und veroeffentlichen / lint (push) Successful in 1m15s
Docker-Images bauen und veroeffentlichen / typecheck (push) Successful in 1m20s
Docker-Images bauen und veroeffentlichen / test (push) Successful in 1m19s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m14s
fix(auth): Anmeldung unter Node 24 (Proxy um NextRequest)
Next reicht Route-Handlern einen Proxy um NextRequest. undici 7 (Node 24)
prueft private Felder (#state), die ein Proxy nicht besitzt; new Request(request)
warf "Cannot read private member #state" und jede /api/auth-Anfrage endete
lokal mit 500. Produktion (Node 22, undici 6) war nicht betroffen.

withClientIp baut die Anfrage jetzt aus URL, Methode, Headern und Body neu.
Regressionstest mit einem Proxy wie in Next.
2026-10-15 05:10:51 +02:00

109 lines
5.5 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from "vitest";
import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, 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("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)", async () => {
const request = new Request("https://example.org/api/auth/sign-in/email", { headers: { "x-forwarded-for": "2001:db8:1:2:aaaa::1" } });
expect((await 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", async () => {
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((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
});
test("withClientIp verträgt Nexts Proxy um NextRequest (Node 24, undici 7) und behält Methode und Body", async () => {
// Next 16 reicht Route-Handlern einen Proxy, dessen Methoden an das Original gebunden sind.
// undici 7 prüft private Felder (#state); ein Proxy besteht diese Prüfung nicht.
const original = new Request("https://example.org/api/auth/sign-in/email", {
method: "POST",
headers: { "x-forwarded-for": "198.51.100.9", "content-type": "application/json" },
body: '{"email":"[email protected]"}',
});
const wieNext = new Proxy(original, {
get(target, key) {
const wert = Reflect.get(target, key, target);
return typeof wert === "function" ? wert.bind(target) : wert;
},
});
const kopie = await withClientIp(wieNext);
expect(kopie.method).toBe("POST");
expect(kopie.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
expect(await kopie.text()).toBe('{"email":"[email protected]"}');
});
});
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");
});
});