feat(security): Cloudflare-aware IP trust and admin-tunable anti-DDoS

- resolveClientIp: trust cf-connecting-ip only behind cf-ray/cdn-loop, use nginx x-real-ip otherwise (anti-spoof)
- antiddos-config: Redis-backed live config (antiddos:config) with 30s cache, 13 ANTI_DDOS_* env vars
- ddos-guard: consume tunable rates/tiers via getAntiddosConfig
- admin panel at /admin/devops/antiddos (save/reset/unban actions, PERMS.SETTINGS_VIEW)
- register new admin page in housekeeping migration matrix (146 -> 147)
This commit is contained in:
openhands committed 2026-09-22 22:22:51 +02:00
1 parent fd4d0fa1cb
commit f0c27eb815
17 files changed
+1151 -63

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
value: null as string | null,
}));
vi.mock("@/lib/redis", () => ({
redis: {
get: async () => state.value,
},
__esModule: true,
}));
import {
antiddosConfigToJson,
antiddosDefaultsFromEnv,
getAntiddosConfig,
invalidateAntiddosConfig,
} from "@/lib/antiddos-config";
describe("antiddos-config", () => {
beforeEach(() => {
state.value = null;
invalidateAntiddosConfig();
});
it("returns sane boot defaults without a live override", async () => {
const config = await getAntiddosConfig();
expect(config.enabled).toBe(true);
expect(config.pages.limit).toBeGreaterThan(0);
expect(config.api.limit).toBeGreaterThan(config.auth.limit);
expect(config.blockTiers.length).toBeGreaterThan(0);
expect(config.globalHaltMs).toBeGreaterThan(0);
});
it("applies the admin live override from Redis", async () => {
state.value = JSON.stringify({
enabled: false,
auth: { limit: 5, windowSeconds: 30 },
global: { limit: 1000, windowSeconds: 60 },
blockTiers: [
{ minViolations: 3, ttlSeconds: 120 },
{ minViolations: 9, ttlSeconds: 900 },
],
});
const config = await getAntiddosConfig();
expect(config.enabled).toBe(false);
expect(config.auth.limit).toBe(5);
expect(config.auth.windowSeconds).toBe(30);
expect(config.pages.limit).toBeGreaterThan(0);
expect(config.blockTiers.map((t) => t.minViolations)).toEqual([3, 9]);
});
it("sanitizes malformed overrides instead of trusting them", async () => {
state.value = JSON.stringify({
enabled: true,
pages: { limit: -5, windowSeconds: "x" },
blockTiers: "garbage",
});
const config = await getAntiddosConfig();
expect(config.pages.limit).toBeGreaterThan(0);
expect(Array.isArray(config.blockTiers)).toBe(true);
expect(config.blockTiers.length).toBeGreaterThan(0);
});
it("stores the config JSON serialization", () => {
const raw = JSON.parse(antiddosConfigToJson(antiddosDefaultsFromEnv()));
expect(raw.enabled).toBe(true);
expect(typeof raw.pages.limit).toBe("number");
expect(Array.isArray(raw.blockTiers)).toBe(true);
});
});
File diff suppressed because it is too large. Load diff
+1
View File
@@ -78,6 +78,7 @@ describe("client IP security consumers", () => {
headers: {
"x-real-client-ip": "198.51.100.99",
"cf-connecting-ip": "2001:DB8:0:0::1",
"cf-ray": "abc123-FRA",
"x-forwarded-for": "192.0.2.10",
},
expected: "2001:db8::1",
+15 -1
View File
@@ -34,7 +34,7 @@ describe("normalized client IP addresses", () => {
expect(normalizeClientIp(input)).toBeNull();
});
it("uses the first forwarded address after an invalid higher-priority header", () => {
it("falls back to the trusted ingress header when a spoofed Cloudflare header lacks cf-ray", () => {
expect(
resolveClientIp(
new Headers({
@@ -44,6 +44,20 @@ describe("normalized client IP addresses", () => {
"x-real-client-ip": "198.51.100.99",
}),
),
).toBe("192.0.2.30");
});
it("ignores an invalid Cloudflare header on proxied traffic and uses the first forwarding entry", () => {
expect(
resolveClientIp(
new Headers({
"cf-ray": "8a9b-AMS",
"cf-connecting-ip": "invalid",
"x-forwarded-for": " 192.0.2.10, 192.0.2.20 ",
"x-real-ip": "192.0.2.30",
"x-real-client-ip": "198.51.100.99",
}),
),
).toBe("192.0.2.10");
});
+17 -2
View File
@@ -1,4 +1,5 @@
import { isIP } from "node:net";
import { isCloudflareProxied } from "@/lib/cloudflare";
export const UNKNOWN_CLIENT_IP = "0.0.0.0";
@@ -26,12 +27,26 @@ export function normalizeClientIp(
* Forwarded headers must be overwritten by a trusted ingress and the origin must
* reject direct public access. Header syntax alone cannot establish peer trust.
* Never consume x-real-client-ip: API routes bypass the proxy that once set it.
*
* Trust order is Cloudflare-aware: only when a request demonstrably arrived via
* Cloudflare (CF-Connecting-IP / CF-Ray / CDN-Loop) is `CF-Connecting-IP` used.
* Otherwise the client-supplied CF header is ignored and only the ingress-set
* `X-Real-IP` (`$remote_addr`) / `X-Forwarded-For` are trusted, so a DDoS that
* hits the origin directly cannot re-key itself behind a spoofed header.
*/
export function resolveClientIp(headers: Pick<Headers, "get">): string {
const cf = normalizeClientIp(headers.get("cf-connecting-ip"));
if (isCloudflareProxied(headers)) {
return (
cf ??
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
normalizeClientIp(headers.get("x-real-ip")) ??
UNKNOWN_CLIENT_IP
);
}
return (
normalizeClientIp(headers.get("cf-connecting-ip")) ??
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
normalizeClientIp(headers.get("x-real-ip")) ??
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
UNKNOWN_CLIENT_IP
);
}
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
/**
* Cloudflare presence detection.
*
* Only headers that a Cloudflare edge adds to every transit are treated as
* proof the request passed through Cloudflare: `CF-Ray` is stamped by the
* edge and `CDN-Loop: cloudflare` is prepended on CDN transits. A bare
* `CF-Connecting-IP` is *not* sufficient — a direct client to the origin can
* send that header itself — so it is only trusted in combination with one of
* the edge-stamped fingerprints.
*/
export function isCloudflareProxied(headers: Pick<Headers, "get">): boolean {
const ray = headers.get("cf-ray")?.trim();
if (ray) return true;
const loop = headers.get("cdn-loop")?.trim();
if (loop) return true;
return false;
}
/**
* Which client-IP header the stack should trust for a given request. When the
* request demonstrably transited Cloudflare, `CF-Connecting-IP` carries the
* real client; otherwise only the ingress-set `X-Real-IP` / `X-Forwarded-For`
* (derived by nginx from the actual TCP peer) may be trusted.
*/
export function preferredClientIpHeader(
headers: Pick<Headers, "get">,
): "cf-connecting-ip" | "x-forwarded-for" | "x-real-ip" | "none" {
if (isCloudflareProxied(headers)) return "cf-connecting-ip";
if (headers.get("x-real-ip")?.trim()) return "x-real-ip";
if (headers.get("x-forwarded-for")?.trim()) return "x-forwarded-for";
return "none";
}
+28 -52
View File
File diff suppressed because it is too large. Load diff