fix(security): Rate-Limits je Besucher statt je Cloudflare-Edge
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
wabenhain.com laeuft ueber Cloudflare. Der letzte X-Forwarded-For-Eintrag ist dann die Cloudflare-Edge, die sich viele Besucher teilen: Login-, 2FA- und Checkout-Limits trafen alle Besucher derselben Edge gemeinsam, ein einzelner konnte andere aussperren. resolveClientIp nimmt CF-Connecting-IP, wenn die Gegenstelle eine Cloudflare-Edge ist; sonst wie bisher den letzten Eintrag (beim Direktzugriff ist CF-Connecting-IP faelschbar und zaehlt nicht). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dfa5c97491
commit
946745c064
2 files changed
+58
-6
No files matched your search
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveClientIp } from "./rate-limit";
|
||||||
|
|
||||||
|
const h = (values: Record<string, string>) => new Headers(values);
|
||||||
|
|
||||||
|
describe("resolveClientIp (Client-IP hinter Cloudflare und Traefik)", () => {
|
||||||
|
it("nimmt den letzten X-Forwarded-For-Eintrag, nie einen vom Client gesetzten", () => {
|
||||||
|
expect(resolveClientIp(h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt CF-Connecting-IP, wenn die Gegenstelle eine Cloudflare-Edge ist", () => {
|
||||||
|
const viaCloudflare = { "x-forwarded-for": "1.2.3.4, 203.0.113.7, 172.70.1.2", "cf-connecting-ip": "203.0.113.7" };
|
||||||
|
expect(resolveClientIp(h(viaCloudflare))).toBe("203.0.113.7");
|
||||||
|
expect(resolveClientIp(h({ "x-forwarded-for": "2606:4700:10::1", "cf-connecting-ip": "2001:db8::5" }))).toBe("2001:db8::5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert CF-Connecting-IP bei direktem Zugriff an Cloudflare vorbei", () => {
|
||||||
|
expect(resolveClientIp(h({ "x-forwarded-for": "198.51.100.9", "cf-connecting-ip": "203.0.113.7" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fällt ohne X-Forwarded-For auf X-Real-IP zurück, sonst „unknown“", () => {
|
||||||
|
expect(resolveClientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
expect(resolveClientIp(h({}))).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-6
@@ -1,3 +1,4 @@
|
|||||||
|
import { BlockList, isIP } from "node:net";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
interface Bucket {
|
interface Bucket {
|
||||||
@@ -48,10 +49,36 @@ export function rateLimit(key: string, limit: number, windowMs: number): boolean
|
|||||||
* Rate-Limits in dieser Datei faelschen (Login, 2FA, Checkout, Reset, ...).
|
* Rate-Limits in dieser Datei faelschen (Login, 2FA, Checkout, Reset, ...).
|
||||||
*/
|
*/
|
||||||
export async function getClientIp(): Promise<string> {
|
export async function getClientIp(): Promise<string> {
|
||||||
const headerStore = await headers();
|
return resolveClientIp(await headers());
|
||||||
return (
|
}
|
||||||
headerStore.get("x-forwarded-for")?.split(",").pop()?.trim() ||
|
|
||||||
headerStore.get("x-real-ip") ||
|
/** https://www.cloudflare.com/ips/ (Stand 2026-10). */
|
||||||
"unknown"
|
const CLOUDFLARE_V4 = ["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"];
|
||||||
);
|
const CLOUDFLARE_V6 = ["2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"];
|
||||||
|
const cloudflare = new BlockList();
|
||||||
|
for (const [cidrs, family] of [[CLOUDFLARE_V4, "ipv4"], [CLOUDFLARE_V6, "ipv6"]] as const) {
|
||||||
|
for (const cidr of cidrs) {
|
||||||
|
const [net = "", prefix = ""] = cidr.split("/");
|
||||||
|
cloudflare.addSubnet(net, Number(prefix), family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validIp(value: string | null | undefined): string | null {
|
||||||
|
const ip = value?.trim();
|
||||||
|
return ip && isIP(ip) ? ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wabenhain.com laeuft ueber Cloudflare: Der letzte x-forwarded-for-Eintrag
|
||||||
|
* (von Traefik angehaengt) ist dann eine Cloudflare-Edge, die sich viele
|
||||||
|
* Besucher teilen. In dem Fall gilt CF-Connecting-IP, das Cloudflare immer
|
||||||
|
* selbst setzt. Kommt die Anfrage nicht von einer Cloudflare-Edge, wird der
|
||||||
|
* Header ignoriert (sonst liesse er sich beim Direktzugriff faelschen).
|
||||||
|
*/
|
||||||
|
export function resolveClientIp(list: Headers): string {
|
||||||
|
const forwarded = list.get("x-forwarded-for");
|
||||||
|
const peer = forwarded === null ? validIp(list.get("x-real-ip")) : validIp(forwarded.split(",").at(-1));
|
||||||
|
if (!peer) return "unknown";
|
||||||
|
const viaCloudflare = validIp(list.get("cf-connecting-ip"));
|
||||||
|
return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user