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
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:
1 parent
c3fdfbdc77
commit
f75a5f7769
4 files changed
+77
-10
No files matched your search
@@ -1,10 +1,11 @@
|
|||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
import { getAuth } from "@/lib/auth";
|
import { getAuth } from "@/lib/auth";
|
||||||
|
import { withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).GET(request);
|
return toNextJsHandler(getAuth()).GET(withClientIp(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).POST(request);
|
return toNextJsHandler(getAuth()).POST(withClientIp(request));
|
||||||
}
|
}
|
||||||
+4
-1
@@ -17,6 +17,7 @@ import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
|
|||||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { CLIENT_IP_HEADER } from "./rate-limit";
|
||||||
|
|
||||||
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||||
const e = env();
|
const e = env();
|
||||||
@@ -51,7 +52,9 @@ function createAuth(options: { allowSignUp?: boolean } = {}) {
|
|||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
useSecureCookies: e.APP_URL.startsWith("https://"),
|
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()],
|
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
||||||
});
|
});
|
||||||
|
|||||||
+39
-7
@@ -5,6 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
||||||
*/
|
*/
|
||||||
|
import { BlockList, isIP } from "node:net";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
import { hmacHex } from "./tokens";
|
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'`;
|
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
|
* Client-IP hinter Traefik. Gegenstelle ist der letzte Eintrag in
|
||||||
* X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint-
|
* X-Forwarded-For, den Traefik aus der TCP-Verbindung anhaengt; alles links
|
||||||
* Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md).
|
* 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 {
|
export function clientIp(headers: Headers): string {
|
||||||
const realIp = headers.get("x-real-ip")?.trim();
|
const forwarded = headers.get("x-forwarded-for");
|
||||||
if (realIp) return realIp;
|
const peer = forwarded === null ? validIp(headers.get("x-real-ip")) : validIp(forwarded.split(",").at(-1));
|
||||||
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
if (!peer) return "unknown";
|
||||||
return forwarded || "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 });
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user