fix(security): eigene Rate-Limits zaehlen IPv6 je /64-Netz
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 7m24s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 7m24s
consumeRateLimit bekommt IPv6-Adressen als /64-Praefix (rateLimitSubject): ein Anschluss hat meist ein ganzes /64 und koennte sonst durch Adresswechsel jedes Limit umgehen. better-auth bekommt weiter die einzelne Adresse und normalisiert sie selbst auf /64 (ipv6Subnet, Standard 64). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f75a5f7769
commit
e6fa383934
3 files changed
+47
-3
No files matched your search
+2
-2
@@ -7,7 +7,7 @@ import { NextResponse } from "next/server";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env, requireAppSecret } from "./env";
|
import { env, requireAppSecret } from "./env";
|
||||||
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
|
import { clientIp, consumeRateLimit, rateLimitSubject, type RateLimitRule } from "./rate-limit";
|
||||||
|
|
||||||
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export async function readJson<T extends z.ZodType>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
||||||
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
|
const result = await consumeRateLimit(getDb(), rule, rateLimitSubject(clientIp(request.headers)), requireAppSecret());
|
||||||
if (result.allowed) return null;
|
if (result.allowed) return null;
|
||||||
return fail(
|
return fail(
|
||||||
429,
|
429,
|
||||||
|
|||||||
@@ -88,6 +88,37 @@ export function clientIp(headers: Headers): string {
|
|||||||
return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
|
return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die acht 16-Bit-Gruppen einer gueltigen IPv6-Adresse; eine IPv4-Endung (::ffff:a.b.c.d) wird umgerechnet. */
|
||||||
|
function ipv6Groups(ip: string): number[] {
|
||||||
|
let text = ip.toLowerCase().split("%")[0] ?? "";
|
||||||
|
const dotted = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
|
||||||
|
if (dotted) {
|
||||||
|
const [a = 0, b = 0, c = 0, d = 0] = dotted.slice(1).map(Number);
|
||||||
|
text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
||||||
|
}
|
||||||
|
const parse = (part: string) => (part ? part.split(":").map((group) => Number.parseInt(group, 16)) : []);
|
||||||
|
const [head = "", tail] = text.split("::");
|
||||||
|
const left = parse(head);
|
||||||
|
if (tail === undefined) return left;
|
||||||
|
const right = parse(tail);
|
||||||
|
return [...left, ...new Array<number>(8 - left.length - right.length).fill(0), ...right];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel fuer consumeRateLimit: IPv4 unveraendert, IPv6 als /64-Netz. Ein
|
||||||
|
* Anschluss bekommt meist ein ganzes /64; einzeln gezaehlt liesse sich jedes
|
||||||
|
* Limit durch Wechsel der Adresse innerhalb des eigenen Netzes umgehen.
|
||||||
|
*/
|
||||||
|
export function rateLimitSubject(ip: string): string {
|
||||||
|
if (isIP(ip) !== 6) return ip;
|
||||||
|
const groups = ipv6Groups(ip);
|
||||||
|
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||||
|
const [high = 0, low = 0] = groups.slice(6);
|
||||||
|
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||||
|
}
|
||||||
|
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(":")}::/64`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
|
/** Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
|
||||||
export function withClientIp(request: Request): Request {
|
export function withClientIp(request: Request): Request {
|
||||||
const headers = new Headers(request.headers);
|
const headers = new Headers(request.headers);
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { CLIENT_IP_HEADER, clientIp, withClientIp } from "@/lib/rate-limit";
|
import { CLIENT_IP_HEADER, clientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const h = (values: Record<string, string>) => new Headers(values);
|
const h = (values: Record<string, string>) => new Headers(values);
|
||||||
|
|
||||||
@@ -20,6 +20,19 @@ describe("clientIp hinter Traefik", () => {
|
|||||||
expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, kein-ip" }))).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)", () => {
|
||||||
|
const request = new Request("https://example.org/api/auth/sign-in/email", { headers: { "x-forwarded-for": "2001:db8:1:2:aaaa::1" } });
|
||||||
|
expect(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", () => {
|
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", {
|
const request = new Request("https://example.org/api/auth/sign-in/email", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in new issue
Block a user