fix: Client-IP-Ermittlung härten (CF-Connecting-IP, X-Real-IP)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m59s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m59s
CF-Connecting-IP wird nur noch als öffentliche Unicast-Adresse akzeptiert (kein 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16, 240/4 "Pseudo IPv4", 255.255.255.255, ::, ::1, fc00::/7, fe80::/10 sowie IPv4-gemappte Formen); sonst gilt die Gegenstelle aus X-Forwarded-For. Der X-Real-IP-Fallback gilt nur noch außerhalb von Produktion. In Produktion ohne X-Forwarded-For wird "unknown" geliefert. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
97f0e033e0
commit
0d254db867
2 files changed
+57
-4
No files matched your search
@@ -1,8 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { rateLimitSubject, resolveClientIp } from "./rate-limit";
|
import { rateLimitSubject, resolveClientIp } from "./rate-limit";
|
||||||
|
|
||||||
const h = (values: Record<string, string>) => new Headers(values);
|
const h = (values: Record<string, string>) => new Headers(values);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveClientIp (Client-IP hinter Cloudflare und Traefik)", () => {
|
describe("resolveClientIp (Client-IP hinter Cloudflare und Traefik)", () => {
|
||||||
it("nimmt den letzten X-Forwarded-For-Eintrag, nie einen vom Client gesetzten", () => {
|
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");
|
expect(resolveClientIp(h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
@@ -18,10 +22,38 @@ describe("resolveClientIp (Client-IP hinter Cloudflare und Traefik)", () => {
|
|||||||
expect(resolveClientIp(h({ "x-forwarded-for": "198.51.100.9", "cf-connecting-ip": "203.0.113.7" }))).toBe("198.51.100.9");
|
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“", () => {
|
it("fällt ohne X-Forwarded-For außerhalb von Produktion auf X-Real-IP zurück, sonst „unknown“", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
expect(resolveClientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
|
expect(resolveClientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
expect(resolveClientIp(h({}))).toBe("unknown");
|
expect(resolveClientIp(h({}))).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignoriert X-Real-IP in Produktion, wenn X-Forwarded-For fehlt", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
expect(resolveClientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown");
|
||||||
|
expect(resolveClientIp(h({ "x-forwarded-for": "198.51.100.9", "x-real-ip": "203.0.113.1" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => {
|
||||||
|
const edge = "172.70.1.2";
|
||||||
|
const nonPublic = [
|
||||||
|
"0.0.0.0", "0.1.2.3", "10.0.0.1", "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:3456::1", "fe80::1", "febf::1",
|
||||||
|
"::ffff:10.0.0.1", "::ffff:127.0.0.1", "::ffff:192.168.1.1", "::ffff:240.0.0.1", "::ffff:0.0.0.0",
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(nonPublic)("verwirft %s und nutzt die Cloudflare-Edge als Gegenstelle", (ip) => {
|
||||||
|
expect(resolveClientIp(h({ "x-forwarded-for": edge, "cf-connecting-ip": ip }))).toBe(edge);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "239.1.1.1", "2001:db8::5", "::ffff:203.0.113.7"])(
|
||||||
|
"akzeptiert öffentliche Adresse %s",
|
||||||
|
(ip) => {
|
||||||
|
expect(resolveClientIp(h({ "x-forwarded-for": edge, "cf-connecting-ip": ip }))).toBe(ip);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rateLimitSubject (IPv6 je /64)", () => {
|
describe("rateLimitSubject (IPv6 je /64)", () => {
|
||||||
|
|||||||
+23
-2
@@ -95,6 +95,24 @@ for (const [cidrs, family] of [[CLOUDFLARE_V4, "ipv4"], [CLOUDFLARE_V6, "ipv6"]]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nicht oeffentlich routbare Bereiche; CF-Connecting-IP daraus waere nie ein echter Client (u.a. Cloudflare "Pseudo IPv4" 240/4). */
|
||||||
|
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 NON_PUBLIC_V6 = ["::/128", "::1/128", "fc00::/7", "fe80::/10"];
|
||||||
|
const nonPublic = new BlockList();
|
||||||
|
for (const [cidrs, family] of [[NON_PUBLIC_V4, "ipv4"], [NON_PUBLIC_V6, "ipv6"]] as const) {
|
||||||
|
for (const cidr of cidrs) {
|
||||||
|
const [net = "", prefix = ""] = cidr.split("/");
|
||||||
|
nonPublic.addSubnet(net, Number(prefix), family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True nur fuer oeffentliche Unicast-Adressen; IPv4-gemappte IPv6 wird wie die IPv4-Adresse geprueft. */
|
||||||
|
function isPublicUnicast(ip: string): boolean {
|
||||||
|
const subject = rateLimitSubject(ip); // entpackt ::ffff:a.b.c.d zu a.b.c.d
|
||||||
|
if (isIP(subject) === 4) return subject !== "255.255.255.255" && !nonPublic.check(subject, "ipv4");
|
||||||
|
return !nonPublic.check(ip, "ipv6");
|
||||||
|
}
|
||||||
|
|
||||||
function validIp(value: string | null | undefined): string | null {
|
function validIp(value: string | null | undefined): string | null {
|
||||||
const ip = value?.trim();
|
const ip = value?.trim();
|
||||||
return ip && isIP(ip) ? ip : null;
|
return ip && isIP(ip) ? ip : null;
|
||||||
@@ -109,8 +127,11 @@ function validIp(value: string | null | undefined): string | null {
|
|||||||
*/
|
*/
|
||||||
export function resolveClientIp(list: Headers): string {
|
export function resolveClientIp(list: Headers): string {
|
||||||
const forwarded = list.get("x-forwarded-for");
|
const forwarded = list.get("x-forwarded-for");
|
||||||
const peer = forwarded === null ? validIp(list.get("x-real-ip")) : validIp(forwarded.split(",").at(-1));
|
// x-real-ip ist nur ausserhalb von Produktion ein Fallback; in Produktion setzt Traefik immer x-forwarded-for.
|
||||||
|
const fallback = process.env.NODE_ENV === "production" ? null : validIp(list.get("x-real-ip"));
|
||||||
|
const peer = forwarded === null ? fallback : validIp(forwarded.split(",").at(-1));
|
||||||
if (!peer) return "unknown";
|
if (!peer) return "unknown";
|
||||||
const viaCloudflare = validIp(list.get("cf-connecting-ip"));
|
const candidate = validIp(list.get("cf-connecting-ip"));
|
||||||
|
const viaCloudflare = candidate && isPublicUnicast(candidate) ? candidate : null;
|
||||||
return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
|
return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user