From f0c27eb81596a01bee25e63215238187c834717f Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 22 Sep 2026 22:22:51 +0200 Subject: [PATCH] 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) --- src/actions/admin-antiddos.ts | 201 +++++++++ src/app/admin/devops/antiddos/page.tsx | 393 ++++++++++++++++++ src/app/admin/devops/page.tsx | 3 + src/env.ts | 23 + .../foundation-source-contract.test.ts | 6 +- .../migration/discover-legacy-pages.test.ts | 2 +- .../housekeeping/migration/matrix.test.ts | 4 +- .../housekeeping/migration/system.test.ts | 4 +- src/features/housekeeping/migration/system.ts | 30 ++ src/lib/antiddos-config.test.ts | 72 ++++ src/lib/antiddos-config.ts | 211 ++++++++++ src/lib/client-ip-consumers.test.ts | 1 + src/lib/client-ip.test.ts | 16 +- src/lib/client-ip.ts | 19 +- src/lib/cloudflare.test.ts | 117 ++++++ src/lib/cloudflare.ts | 32 ++ src/lib/ddos-guard.ts | 80 ++-- 17 files changed, 1151 insertions(+), 63 deletions(-) create mode 100644 src/actions/admin-antiddos.ts create mode 100644 src/app/admin/devops/antiddos/page.tsx create mode 100644 src/lib/antiddos-config.test.ts create mode 100644 src/lib/antiddos-config.ts create mode 100644 src/lib/cloudflare.test.ts create mode 100644 src/lib/cloudflare.ts diff --git a/src/actions/admin-antiddos.ts b/src/actions/admin-antiddos.ts new file mode 100644 index 00000000..7aa6eb87 --- /dev/null +++ b/src/actions/admin-antiddos.ts @@ -0,0 +1,201 @@ +"use server"; + +import { like } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { requirePermission } from "@/lib/admin/guard"; +import { + type AntiddosBlockTier, + type AntiddosConfig, + antiddosConfigToJson, + antiddosDefaultsFromEnv, + invalidateAntiddosConfig, +} from "@/lib/antiddos-config"; +import { db, WebsiteSetting } from "@/lib/db"; +import { logger } from "@/lib/logger"; +import { PERMS } from "@/lib/permissions"; +import { redis } from "@/lib/redis"; +import { siteSettings } from "@/lib/services/site-settings"; + +const OVERRIDE_KEY = "antiddos:config"; + +function str(raw: FormDataEntryValue | null): string { + return typeof raw === "string" ? raw : ""; +} + +function positiveInt(raw: FormDataEntryValue | null, fallback: number): number { + const n = Number(str(raw)); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.floor(n); +} + +function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] { + const tiers: AntiddosBlockTier[] = []; + for (const part of str(raw).split(",")) { + const [minRaw, ttlRaw] = part.split(":"); + const min = Number(minRaw); + const ttl = Number(ttlRaw); + if (!Number.isFinite(min) || !Number.isFinite(ttl) || ttl <= 0) continue; + tiers.push({ + minViolations: Math.max(1, Math.floor(min)), + ttlSeconds: Math.floor(ttl), + }); + } + tiers.sort((a, b) => a.minViolations - b.minViolations); + return tiers; +} + +function configFromForm(formData: FormData): AntiddosConfig { + const defaults = antiddosDefaultsFromEnv(); + const tierRaw = str(formData.get("block_tiers")).trim(); + return { + enabled: str(formData.get("enabled")) === "1", + pages: { + limit: positiveInt(formData.get("pages_limit"), defaults.pages.limit), + windowSeconds: positiveInt( + formData.get("pages_window_sec"), + defaults.pages.windowSeconds, + ), + }, + api: { + limit: positiveInt(formData.get("api_limit"), defaults.api.limit), + windowSeconds: positiveInt( + formData.get("api_window_sec"), + defaults.api.windowSeconds, + ), + }, + auth: { + limit: positiveInt(formData.get("auth_limit"), defaults.auth.limit), + windowSeconds: positiveInt( + formData.get("auth_window_sec"), + defaults.auth.windowSeconds, + ), + }, + global: { + limit: positiveInt(formData.get("global_limit"), defaults.global.limit), + windowSeconds: positiveInt( + formData.get("global_window_sec"), + defaults.global.windowSeconds, + ), + }, + violationWindowSeconds: positiveInt( + formData.get("violation_window_sec"), + defaults.violationWindowSeconds, + ), + maxViolations: positiveInt( + formData.get("max_violations"), + defaults.maxViolations, + ), + blockTiers: + tierRaw.length > 0 + ? parseTiers(formData.get("block_tiers")) + : defaults.blockTiers, + globalHaltMs: positiveInt( + formData.get("global_halt_ms"), + defaults.globalHaltMs, + ), + }; +} + +async function persistSettings(config: AntiddosConfig): Promise { + const entries: [string, string][] = [ + ["antiddos_enabled", config.enabled ? "1" : "0"], + ["antiddos_pages_limit", String(config.pages.limit)], + ["antiddos_pages_window_sec", String(config.pages.windowSeconds)], + ["antiddos_api_limit", String(config.api.limit)], + ["antiddos_api_window_sec", String(config.api.windowSeconds)], + ["antiddos_auth_limit", String(config.auth.limit)], + ["antiddos_auth_window_sec", String(config.auth.windowSeconds)], + ["antiddos_global_limit", String(config.global.limit)], + ["antiddos_global_window_sec", String(config.global.windowSeconds)], + ["antiddos_violation_window_sec", String(config.violationWindowSeconds)], + ["antiddos_max_violations", String(config.maxViolations)], + [ + "antiddos_block_tiers", + config.blockTiers + .map((t) => `${t.minViolations}:${t.ttlSeconds}`) + .join(","), + ], + ["antiddos_global_halt_ms", String(config.globalHaltMs)], + ]; + await Promise.all( + entries.map(([key, value]) => + db + .insert(WebsiteSetting) + .values({ key, value, comment: "Anti-DDoS protection" }) + .onDuplicateKeyUpdate({ set: { value } }), + ), + ); +} + +/** + * Save anti-DDoS settings from the admin panel. Persists to site settings + * (durable across Redis flushes) and pushes the same config to the Redis + * override the proxy reads, so the change is live within the proxy cache TTL. + */ +export async function saveAntiddosSettings(formData: FormData): Promise { + const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const config = configFromForm(formData); + + try { + await persistSettings(config); + if (redis) { + await redis.set(OVERRIDE_KEY, antiddosConfigToJson(config)); + } + invalidateAntiddosConfig(); + siteSettings.reload(); + logger.info("Anti-DDoS settings updated", { + staff: staff.username, + enabled: config.enabled, + }); + } catch (err) { + logger.error("Failed to save anti-DDoS settings", { err }); + } + revalidatePath("/admin/devops/antiddos"); +} + +/** + * Revert to the boot defaults (env) — drop the Redis live override and the + * DB-persisted settings. The gate immediately falls back to env ANTI_DDOS_*. + */ +export async function resetAntiddosSettings(): Promise { + const staff = await requirePermission(PERMS.SETTINGS_VIEW); + + try { + if (redis) await redis.del(OVERRIDE_KEY); + // Remove every persisted antiddos_* row. + await db + .delete(WebsiteSetting) + .where(like(WebsiteSetting.key, "antiddos_%")); + invalidateAntiddosConfig(); + siteSettings.reload(); + logger.info("Anti-DDoS settings reset to defaults", { + staff: staff.username, + }); + } catch (err) { + logger.error("Failed to reset anti-DDoS settings", { err }); + } + revalidatePath("/admin/devops/antiddos"); +} + +/** Remove a single IP from the temporary DDoS block list. */ +export async function unbanAntiddosIp(formData: FormData): Promise { + const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const ip = str(formData.get("ip")).trim(); + if (!ip) return; + + try { + if (redis) { + await Promise.all([ + redis.del(`antiddos:block:${ip}`), + redis.del(`antiddos:v:${ip}`), + ]); + } + logger.info("Anti-DDoS block manually removed", { + staff: staff.username, + ip, + }); + } catch (err) { + logger.error("Failed to remove anti-DDoS block", { err, ip }); + } + revalidatePath("/admin/devops/antiddos"); +} diff --git a/src/app/admin/devops/antiddos/page.tsx b/src/app/admin/devops/antiddos/page.tsx new file mode 100644 index 00000000..e031efa7 --- /dev/null +++ b/src/app/admin/devops/antiddos/page.tsx @@ -0,0 +1,393 @@ +import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { + resetAntiddosSettings, + saveAntiddosSettings, + unbanAntiddosIp, +} from "@/actions/admin-antiddos"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + antiddosDefaultsFromEnv, + getAntiddosConfig, +} from "@/lib/antiddos-config"; +import { resolveClientIp } from "@/lib/client-ip"; +import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare"; +import { db, WebsiteSetting } from "@/lib/db"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { redis } from "@/lib/redis"; + +function seconds(ttlMs: number): string { + const s = Math.floor(ttlMs / 1000); + if (s <= 0) return "–"; + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""}`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} + +export default async function AdminAntiDdosPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + + const [effective, defaults, requestHeaders, persistedRows] = + await Promise.all([ + getAntiddosConfig(), + antiddosDefaultsFromEnv(), + headers(), + db + .select({ key: WebsiteSetting.key, value: WebsiteSetting.value }) + .from(WebsiteSetting) + .then((rows) => new Map(rows.map((r) => [r.key, r.value]))) + .catch(() => new Map() as Map), + ]); + + const cloudflare = isCloudflareProxied(requestHeaders); + const sourceHeader = preferredClientIpHeader(requestHeaders); + const viewerIp = resolveClientIp(requestHeaders); + + let blocks: { ip: string; ttlMs: number; count: number }[] = []; + let redisOk = false; + const rateStore = redis; + if (rateStore) { + redisOk = true; + try { + const blockKeys = await rateStore.keys("antiddos:block:*"); + const violationKeys = await rateStore.keys("antiddos:v:*"); + const violationCounts = new Map(); + for (const key of violationKeys.slice(0, 200)) { + // incr is used on writes; for display we just read the raw value. + const raw = await rateStore.get(key); + const n = Number(raw); + violationCounts.set( + key.replace(`antiddos:v:`, ""), + Number.isFinite(n) ? n : 0, + ); + } + const withTtl = await Promise.all( + blockKeys.slice(0, 100).map(async (key) => { + const ttlMs = await rateStore.pttl(key); + return { + ip: key.replace("antiddos:block:", ""), + ttlMs: ttlMs > 0 ? ttlMs : 0, + count: violationCounts.get(key.replace("antiddos:block:", "")) ?? 0, + }; + }), + ); + blocks = withTtl + .filter((b) => b.ttlMs > 0) + .sort((a, b) => a.ttlMs - b.ttlMs); + } catch { + redisOk = false; + } + } + + const stored = persistedRows; + + return ( +
+
+ + + Gate + + + + + {effective.enabled ? "Enabled" : "Disabled"} + +

+ Boot default: {defaults.enabled ? "on" : "off"} +

+
+
+ + + + Rates + + + +
+ {effective.api.limit} + + {" "} + /min API + +
+

+ Pages {effective.pages.limit} · Auth {effective.auth.limit} · + Global {effective.global.limit} +

+
+
+ + + + Cloudflare + + + + + {cloudflare ? "Detected" : "Not detected"} + +

+ IP source: {sourceHeader} +

+
+
+ + + + Active blocks + + + +
+ {blocks.length} +
+

+ Temporary DDoS blocks +

+
+
+ + + + Redis + + + + + {redisOk ? "Connected" : "Unavailable"} + +

+ Shared rate-limit state +

+
+
+
+ + {!cloudflare && ( + + + + Cloudflare not detected + + + +

+ This request did not arrive through Cloudflare. When the site DNS + is proxied (orange cloud), the CMS trusts the real visitor IP from{" "} + CF-Connecting-IP. A direct + client can spoof that header — put the origin behind Cloudflare + and restrict direct access to the origin ports for full DDoS + protection. +

+

+ Your request is keyed as{" "} + {viewerIp} (via{" "} + {sourceHeader}). +

+
+
+ )} + + + + + Anti-DDoS settings + + + +
+ + +
+ {( + [ + ["pages", "Pages", "pages_limit", "pages_window_sec"], + ["api", "API", "api_limit", "api_window_sec"], + ["auth", "Auth", "auth_limit", "auth_window_sec"], + ] as const + ).map(([category, label, limitName, windowName]) => ( +
+

{label}

+
+ + + req/min + + + + sec window + +
+
+ ))} +
+ +
+ + + + +
+ +
+ +

+ Boot default:{" "} + {defaults.blockTiers + .map((t) => `${t.minViolations}:${t.ttlSeconds}`) + .join(", ")} +

+
+ +
+ +
+
+ +
+ +
+
+
+ + + + + Blocked IPs ({blocks.length}) + + + + {blocks.length === 0 ? ( +

+ No IPs are currently rate-limited into a temporary block. +

+ ) : ( +
+ {blocks.map((b) => ( +
+ {b.ip} + + TTL {seconds(b.ttlMs)} · violations {b.count} + +
+ + +
+
+ ))} +
+ )} +
+
+ + {stored.size === 0 && ( +

+ Persisted site settings: none yet — the form values above reflect the + current effective configuration. +

+ )} +
+ ); +} diff --git a/src/app/admin/devops/page.tsx b/src/app/admin/devops/page.tsx index c25ac694..ce8533b9 100644 --- a/src/app/admin/devops/page.tsx +++ b/src/app/admin/devops/page.tsx @@ -71,6 +71,9 @@ export default async function DevOpsPage() { {deliveries("title")} + + Anti-DDoS protection + {/* Status cards */}
diff --git a/src/env.ts b/src/env.ts index 6f3618c5..c1dd206e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -106,6 +106,29 @@ const schema = z .string() .optional() .transform((value) => value !== "false" && value !== "0"), + // Anti-DDoS thresholds. These are the YAML-file boot defaults; the admin + // panel can override them at runtime (Redis `antiddos:config`). + ANTI_DDOS_PAGES_LIMIT: z.coerce.number().int().positive().default(300), + ANTI_DDOS_PAGES_WINDOW_SEC: z.coerce.number().int().positive().default(60), + ANTI_DDOS_API_LIMIT: z.coerce.number().int().positive().default(600), + ANTI_DDOS_API_WINDOW_SEC: z.coerce.number().int().positive().default(60), + ANTI_DDOS_AUTH_LIMIT: z.coerce.number().int().positive().default(20), + ANTI_DDOS_AUTH_WINDOW_SEC: z.coerce.number().int().positive().default(60), + ANTI_DDOS_GLOBAL_LIMIT: z.coerce.number().int().positive().default(18_000), + ANTI_DDOS_GLOBAL_WINDOW_SEC: z.coerce.number().int().positive().default(60), + ANTI_DDOS_VIOLATION_WINDOW_SEC: z.coerce + .number() + .int() + .positive() + .default(600), + ANTI_DDOS_MAX_VIOLATIONS: z.coerce.number().int().positive().default(10), + // Escalation tiers as "minViolations:ttlSeconds,minViolations:ttlSeconds". + ANTI_DDOS_BLOCK_TIERS: z.string().optional(), + ANTI_DDOS_GLOBAL_HALT_MS: z.coerce + .number() + .int() + .positive() + .default(10_000), // Logging level. LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(), APP_VERSION: z.string().optional(), diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index f0d1957e..3c79f051 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -621,15 +621,15 @@ describe("housekeeping foundation completion contracts", () => { expect(html).toContain(`>${sentinel}`); }); - it("validates the complete 146-row migration matrix without issues", () => { + it("validates the complete 147-row migration matrix without issues", () => { const discovered = discoverLegacyPages(); const issues = validateMigrationEntries( discovered, HOUSEKEEPING_MIGRATION_MATRIX, ); - expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146); - expect(discovered).toHaveLength(146); + expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(147); + expect(discovered).toHaveLength(147); expect(issues).toEqual([]); }); }); diff --git a/src/features/housekeeping/migration/discover-legacy-pages.test.ts b/src/features/housekeeping/migration/discover-legacy-pages.test.ts index 7b783ffb..e8d3e661 100644 --- a/src/features/housekeeping/migration/discover-legacy-pages.test.ts +++ b/src/features/housekeeping/migration/discover-legacy-pages.test.ts @@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => { it("discovers the exact legacy administration inventory", () => { const pages = discoverLegacyPages(); - expect(pages).toHaveLength(146); + expect(pages).toHaveLength(147); expect(pages).toContainEqual({ surface: "admin", legacyPath: "/admin/users/:id/edit", diff --git a/src/features/housekeeping/migration/matrix.test.ts b/src/features/housekeeping/migration/matrix.test.ts index 1268fcaf..c3c42abd 100644 --- a/src/features/housekeeping/migration/matrix.test.ts +++ b/src/features/housekeeping/migration/matrix.test.ts @@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix"; import { validateMigrationEntries } from "./validate-matrix"; describe("HOUSEKEEPING_MIGRATION_MATRIX", () => { - it("covers all 146 legacy pages exactly once", () => { + it("covers all 147 legacy pages exactly once", () => { const discovered = discoverLegacyPages(); - expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146); + expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(147); expect( validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX), ).toEqual([]); diff --git a/src/features/housekeeping/migration/system.test.ts b/src/features/housekeeping/migration/system.test.ts index d115866a..775332ff 100644 --- a/src/features/housekeeping/migration/system.test.ts +++ b/src/features/housekeeping/migration/system.test.ts @@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [ ] as const; describe("systemMigrationEntries", () => { - it("covers all 23 System pages exactly once", () => { - expect(systemMigrationEntries).toHaveLength(23); + it("covers all 24 System pages exactly once", () => { + expect(systemMigrationEntries).toHaveLength(24); expect( validateMigrationEntries( ownedLegacyPages(SYSTEM_PREFIXES), diff --git a/src/features/housekeeping/migration/system.ts b/src/features/housekeeping/migration/system.ts index a46900b2..8b2abd53 100644 --- a/src/features/housekeeping/migration/system.ts +++ b/src/features/housekeeping/migration/system.ts @@ -272,6 +272,36 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [ localization: "PARTIAL", accessibility: "PARTIAL", }), + plannedSystemEntry({ + surface: "admin", + legacyPath: "/admin/devops/antiddos", + sourceFile: "src/app/admin/devops/antiddos/page.tsx", + targetPath: "/admin/system/configuration/antiddos", + decision: "REHOST", + capabilities: { + read: [PERMS.SETTINGS_VIEW], + mutate: [PERMS.SETTINGS_EDIT], + }, + dependencies: { + queries: [ + "antiddos:config override", + "WebsiteSetting antiddos_*", + "blocked anti-DDoS IPs", + "GET /api/health", + ], + mutations: [ + "saveAntiddosSettings", + "resetAntiddosSettings", + "unbanAntiddosIp", + ], + }, + auditRequirement: "MUTATION", + localization: "PARTIAL", + accessibility: "PARTIAL", + notes: [ + "Tunable anti-DDoS rates and block tiers; Cloudflare detection is read-only and driven by cf-ray/cdn-loop presence", + ], + }), plannedSystemEntry({ surface: "admin", legacyPath: "/admin/emulator", diff --git a/src/lib/antiddos-config.test.ts b/src/lib/antiddos-config.test.ts new file mode 100644 index 00000000..9b48a38b --- /dev/null +++ b/src/lib/antiddos-config.test.ts @@ -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); + }); +}); diff --git a/src/lib/antiddos-config.ts b/src/lib/antiddos-config.ts new file mode 100644 index 00000000..845f5b97 --- /dev/null +++ b/src/lib/antiddos-config.ts @@ -0,0 +1,211 @@ +import "server-only"; + +import { env } from "@/env"; +import { redis } from "@/lib/redis"; + +export interface AntiddosCategoryConfig { + limit: number; + windowSeconds: number; +} + +export interface AntiddosBlockTier { + minViolations: number; + ttlSeconds: number; +} + +export interface AntiddosConfig { + enabled: boolean; + pages: AntiddosCategoryConfig; + api: AntiddosCategoryConfig; + auth: AntiddosCategoryConfig; + global: AntiddosCategoryConfig; + violationWindowSeconds: number; + maxViolations: number; + blockTiers: AntiddosBlockTier[]; + globalHaltMs: number; +} + +const DEFAULT_CONFIG: AntiddosConfig = { + enabled: true, + pages: { limit: 300, windowSeconds: 60 }, + api: { limit: 600, windowSeconds: 60 }, + auth: { limit: 20, windowSeconds: 60 }, + global: { limit: 18_000, windowSeconds: 60 }, + violationWindowSeconds: 600, + maxViolations: 10, + blockTiers: [ + { minViolations: 5, ttlSeconds: 600 }, + { minViolations: 20, ttlSeconds: 3_600 }, + { minViolations: 50, ttlSeconds: 86_400 }, + ], + globalHaltMs: 10_000, +}; + +function positiveInt(value: number | undefined, fallback: number): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.floor(n); +} + +function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null { + if (!raw?.trim()) return null; + const tiers: AntiddosBlockTier[] = []; + for (const part of raw.split(",")) { + const [minRaw, ttlRaw] = part.split(":"); + const min = Number(minRaw); + const ttl = Number(ttlRaw); + if (!Number.isFinite(min) || !Number.isFinite(ttl) || ttl <= 0) return null; + tiers.push({ + minViolations: Math.max(1, Math.floor(min)), + ttlSeconds: ttl, + }); + } + if (tiers.length === 0) return null; + tiers.sort((a, b) => a.minViolations - b.minViolations); + return tiers; +} + +/** Boot defaults from environment (explicitly set → overrides code; unset → sane value). */ +export function antiddosDefaultsFromEnv(): AntiddosConfig { + const tiers = parseTiers(env.ANTI_DDOS_BLOCK_TIERS); + return { + enabled: env.ANTI_DDOS_ENABLED !== false, + pages: { + limit: positiveInt(env.ANTI_DDOS_PAGES_LIMIT, DEFAULT_CONFIG.pages.limit), + windowSeconds: positiveInt( + env.ANTI_DDOS_PAGES_WINDOW_SEC, + DEFAULT_CONFIG.pages.windowSeconds, + ), + }, + api: { + limit: positiveInt(env.ANTI_DDOS_API_LIMIT, DEFAULT_CONFIG.api.limit), + windowSeconds: positiveInt( + env.ANTI_DDOS_API_WINDOW_SEC, + DEFAULT_CONFIG.api.windowSeconds, + ), + }, + auth: { + limit: positiveInt(env.ANTI_DDOS_AUTH_LIMIT, DEFAULT_CONFIG.auth.limit), + windowSeconds: positiveInt( + env.ANTI_DDOS_AUTH_WINDOW_SEC, + DEFAULT_CONFIG.auth.windowSeconds, + ), + }, + global: { + limit: positiveInt( + env.ANTI_DDOS_GLOBAL_LIMIT, + DEFAULT_CONFIG.global.limit, + ), + windowSeconds: positiveInt( + env.ANTI_DDOS_GLOBAL_WINDOW_SEC, + DEFAULT_CONFIG.global.windowSeconds, + ), + }, + violationWindowSeconds: positiveInt( + env.ANTI_DDOS_VIOLATION_WINDOW_SEC, + DEFAULT_CONFIG.violationWindowSeconds, + ), + maxViolations: positiveInt( + env.ANTI_DDOS_MAX_VIOLATIONS, + DEFAULT_CONFIG.maxViolations, + ), + blockTiers: tiers ?? DEFAULT_CONFIG.blockTiers, + globalHaltMs: positiveInt( + env.ANTI_DDOS_GLOBAL_HALT_MS, + DEFAULT_CONFIG.globalHaltMs, + ), + }; +} + +const OVERRIDE_KEY = "antiddos:config"; +const MEMORY_TTL_MS = 30_000; +const ABSENT_CACHE_MS = 30_000; + +let cachedAt = 0; +let cachedConfig: AntiddosConfig | null = null; + +function sanitize(config: AntiddosConfig): AntiddosConfig { + const base = antiddosDefaultsFromEnv(); + const cat = ( + c: AntiddosCategoryConfig, + fallback: AntiddosCategoryConfig, + ): AntiddosCategoryConfig => ({ + limit: positiveInt(c?.limit, fallback.limit), + windowSeconds: positiveInt(c?.windowSeconds, fallback.windowSeconds), + }); + return { + enabled: Boolean(config?.enabled), + pages: cat(config?.pages, base.pages), + api: cat(config?.api, base.api), + auth: cat(config?.auth, base.auth), + global: cat(config?.global, base.global), + violationWindowSeconds: positiveInt( + config?.violationWindowSeconds, + base.violationWindowSeconds, + ), + maxViolations: positiveInt(config?.maxViolations, base.maxViolations), + blockTiers: + Array.isArray(config?.blockTiers) && config.blockTiers.length > 0 + ? config.blockTiers + .filter((t) => t && t.ttlSeconds > 0) + .map((t) => ({ + minViolations: positiveInt(t.minViolations, 1), + ttlSeconds: positiveInt(t.ttlSeconds, 600), + })) + .sort((a, b) => a.minViolations - b.minViolations) + : base.blockTiers, + globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs), + }; +} + +/** + * Effective anti-DDoS configuration. The admin panel writes the full JSON to + * the Redis `antiddos:config` key (and mirrors it into site settings for + * durability); the proxy reads it with a short in-process TTL so the running + * deployment picks changes up quickly. On Redis miss it returns the env-derived + * boot defaults. + */ +export async function getAntiddosConfig(): Promise { + const now = Date.now(); + if (cachedConfig !== null && now - cachedAt < MEMORY_TTL_MS) { + return cachedConfig; + } + + if (redis) { + try { + const raw = await redis.get(OVERRIDE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + const config = sanitize(parsed as AntiddosConfig); + cachedConfig = config; + cachedAt = now; + return config; + } + } catch { + // fall through to env defaults; stale in-process config kept serving. + } + } + + if (now - cachedAt < ABSENT_CACHE_MS && cachedConfig !== null) { + return cachedConfig; + } + + const config = antiddosDefaultsFromEnv(); + cachedConfig = config; + cachedAt = now; + return config; +} + +/** Reset the in-process view (after the admin writes a new config). */ +export function invalidateAntiddosConfig(): void { + cachedConfig = null; + cachedAt = 0; +} + +/** + * Serialize the live config for the `antiddos:config` value the admin persists + * and the proxy consumes. + */ +export function antiddosConfigToJson(config: AntiddosConfig): string { + return JSON.stringify(config); +} diff --git a/src/lib/client-ip-consumers.test.ts b/src/lib/client-ip-consumers.test.ts index 8673137e..64334b27 100644 --- a/src/lib/client-ip-consumers.test.ts +++ b/src/lib/client-ip-consumers.test.ts @@ -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", diff --git a/src/lib/client-ip.test.ts b/src/lib/client-ip.test.ts index 73d141b7..45acd0d2 100644 --- a/src/lib/client-ip.test.ts +++ b/src/lib/client-ip.test.ts @@ -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"); }); diff --git a/src/lib/client-ip.ts b/src/lib/client-ip.ts index bccb2c1b..4cf8739f 100644 --- a/src/lib/client-ip.ts +++ b/src/lib/client-ip.ts @@ -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): 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 ); } diff --git a/src/lib/cloudflare.test.ts b/src/lib/cloudflare.test.ts new file mode 100644 index 00000000..52dfe984 --- /dev/null +++ b/src/lib/cloudflare.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { resolveClientIp } from "@/lib/client-ip"; +import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare"; + +function headers(entries: Record): Headers { + const h = new Headers(); + for (const [k, v] of Object.entries(entries)) { + if (v === "") h.set(k, ""); + else h.set(k, v); + } + return h; +} + +describe("isCloudflareProxied", () => { + it("detects Cloudflare from edge-stamped cf-ray or cdn-loop", () => { + expect(isCloudflareProxied(headers({ "cf-ray": "abc123-FRA" }))).toBe(true); + expect(isCloudflareProxied(headers({ "cdn-loop": "cloudflare" }))).toBe( + true, + ); + expect( + isCloudflareProxied( + headers({ "cf-connecting-ip": "1.2.3.4", "cf-ray": "abc-FRA" }), + ), + ).toBe(true); + }); + + it("treats a bare cf-connecting-ip as spoofable and not proof", () => { + expect( + isCloudflareProxied(headers({ "cf-connecting-ip": "1.2.3.4" })), + ).toBe(false); + expect(isCloudflareProxied(headers({ "x-forwarded-for": "1.2.3.4" }))).toBe( + false, + ); + expect(isCloudflareProxied(headers({}))).toBe(false); + }); +}); + +describe("preferredClientIpHeader", () => { + it("prefers cf-connecting-ip behind Cloudflare", () => { + expect( + preferredClientIpHeader( + headers({ "cf-connecting-ip": "1.2.3.4", "cf-ray": "abc-FRA" }), + ), + ).toBe("cf-connecting-ip"); + }); + + it("prefers ingress x-real-ip outside Cloudflare", () => { + expect( + preferredClientIpHeader( + headers({ + "x-real-ip": "198.51.100.9", + "cf-connecting-ip": "20.0.0.1", + }), + ), + ).toBe("x-real-ip"); + expect( + preferredClientIpHeader(headers({ "x-forwarded-for": "192.0.2.1" })), + ).toBe("x-forwarded-for"); + }); +}); + +describe("resolveClientIp cloudflare-aware trust order", () => { + it("trusts cf-connecting-ip only behind Cloudflare", () => { + expect( + resolveClientIp( + headers({ + "cf-connecting-ip": "20.0.0.1", + "x-real-ip": "192.0.2.30", + }), + ), + ).toBe("192.0.2.30"); + expect( + resolveClientIp( + headers({ + "cf-connecting-ip": "20.0.0.1", + "cf-ray": "abc-FRA", + "x-real-ip": "192.0.2.30", + }), + ), + ).toBe("20.0.0.1"); + }); + + it("drops client spoofed cf-connecting-ip when not proxied", () => { + expect(resolveClientIp(headers({ "cf-connecting-ip": "20.0.0.1" }))).toBe( + "0.0.0.0", + ); + expect( + resolveClientIp( + headers({ + "cf-connecting-ip": "20.0.0.1", + "x-forwarded-for": "192.0.2.10", + }), + ), + ).toBe("192.0.2.10"); + }); + + it("keeps normalized IPv6 and fallbacks identical to the audited resolver", () => { + expect( + resolveClientIp( + headers({ + "cf-connecting-ip": "2001:DB8:0:0::1", + "cf-ray": "abc-FRA", + "x-forwarded-for": "192.0.2.10", + }), + ), + ).toBe("2001:db8::1"); + expect( + resolveClientIp( + headers({ + "cf-connecting-ip": "", + "x-forwarded-for": "malformed, 192.0.2.10", + "x-real-ip": "192.0.2.30", + }), + ), + ).toBe("192.0.2.30"); + }); +}); diff --git a/src/lib/cloudflare.ts b/src/lib/cloudflare.ts new file mode 100644 index 00000000..074b001d --- /dev/null +++ b/src/lib/cloudflare.ts @@ -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): 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, +): "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"; +} diff --git a/src/lib/ddos-guard.ts b/src/lib/ddos-guard.ts index 40185053..ad996fb7 100644 --- a/src/lib/ddos-guard.ts +++ b/src/lib/ddos-guard.ts @@ -2,10 +2,10 @@ import "server-only"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; - import { env } from "@/env"; +import { getAntiddosConfig } from "@/lib/antiddos-config"; import { resolveClientIp } from "@/lib/client-ip"; -import { classifyDdos, type DdosCategory, isSuspiciousPath } from "@/lib/ddos"; +import { classifyDdos, isSuspiciousPath } from "@/lib/ddos"; import { rateLimit } from "@/lib/rate-limit"; import { redis } from "@/lib/redis"; @@ -14,52 +14,22 @@ export type DdosDecision = | { outcome: "suspect" } | { outcome: "block"; retryAfterSeconds: number }; -export interface DdosLimitRule { - limit: number; - windowSeconds: number; -} - -const DEFAULT_LIMITS: Record = { - // Anonymous HTML is cached at the nginx layer for 60s, so Node only pays - // for cache misses and authenticated traffic here. - pages: { limit: 300, windowSeconds: 60 }, - // Game clients poll a handful of endpoints; generous burst headroom that - // still cuts off single-IP floods. - api: { limit: 600, windowSeconds: 60 }, - // Login, register and admin — the valuable brute-force surface. - auth: { limit: 20, windowSeconds: 60 }, -}; - -// Global safety valve: sheds aggregate load even when a DDoS spreads over -// many IPs, keeping the process and database alive with 429s instead of -// letting every connection through until the DB melts. -const GLOBAL_LIMIT: DdosLimitRule = { limit: 18_000, windowSeconds: 60 }; - -// Escalating blocks so persistent / distributed offenders stay off longer -// than a single window. The violation counter lives for a day; after a quiet -// day the counter and any block TTL both expire, so blocks are self-healing. -const VIOLATION_COUNTER_TTL_SECONDS = 86_400; -const BLOCK_TIERS: readonly { minViolations: number; ttlSeconds: number }[] = [ - { minViolations: 5, ttlSeconds: 600 }, - { minViolations: 20, ttlSeconds: 3_600 }, - { minViolations: 50, ttlSeconds: 86_400 }, -]; - -// Once the global valve trips, shed every request for a short spell from -// process memory only — no further Redis round-trips — so a live flood can -// never pile request-handling work onto the limiter itself. -const GLOBAL_HALT_MS = 10_000; - -let globalHaltedUntil = 0; - function isEnabled(): boolean { if (env.NODE_ENV !== "production") return false; return env.ANTI_DDOS_ENABLED; } -function blockTtlForViolations(violations: number): number { - let ttl = BLOCK_TIERS[0].ttlSeconds; - for (const tier of BLOCK_TIERS) { +// Once the global valve trips, shed every request for a short spell from +// process memory only — no further Redis round-trips — so a live flood can +// never pile request-handling work onto the limiter itself. +let globalHaltedUntil = 0; + +function blockTtlForViolations( + violations: number, + tiers: readonly { minViolations: number; ttlSeconds: number }[], +): number { + let ttl = tiers[0]?.ttlSeconds ?? 600; + for (const tier of tiers) { if (violations >= tier.minViolations) ttl = tier.ttlSeconds; } return ttl; @@ -71,12 +41,16 @@ function blockTtlForViolations(violations: number): number { * audited IP resolver. Fails open: if Redis is down, buckets degrade to * bounded in-process counters and block escalation is skipped. * - * `/api/health` is exempt so the Docker liveness probe never trips the gate. + * Tunables come from the anti-DDoS config (env boot defaults, live-overridden + * by the admin panel via Redis). `/api/health` is exempt so the Docker + * liveness probe never trips the gate. */ export async function enforceDdosRateLimit( req: NextRequest, ): Promise { if (!isEnabled()) return { outcome: "pass" }; + const config = await getAntiddosConfig(); + if (!config.enabled) return { outcome: "pass" }; const pathname = req.nextUrl.pathname; if (pathname === "/api/health") return { outcome: "pass" }; @@ -94,7 +68,7 @@ export async function enforceDdosRateLimit( if ((await redis.get(blockKey)) !== null) { return { outcome: "block", - retryAfterSeconds: blockTtlForViolations(0), + retryAfterSeconds: blockTtlForViolations(0, config.blockTiers), }; } } catch { @@ -104,11 +78,11 @@ export async function enforceDdosRateLimit( const global = await rateLimit( "antiddos:global:all", - GLOBAL_LIMIT.limit, - GLOBAL_LIMIT.windowSeconds * 1000, + config.global.limit, + config.global.windowSeconds * 1000, ); if (!global.ok) { - globalHaltedUntil = now + GLOBAL_HALT_MS; + globalHaltedUntil = now + config.globalHaltMs; return { outcome: "block", retryAfterSeconds: Math.max(global.retryAfter, 1), @@ -117,7 +91,7 @@ export async function enforceDdosRateLimit( if (globalHaltedUntil !== 0) globalHaltedUntil = 0; const category = classifyDdos(pathname); - const rule = DEFAULT_LIMITS[category]; + const rule = config[category]; const bucket = await rateLimit( `antiddos:${category}:${ip}`, rule.limit, @@ -131,10 +105,12 @@ export async function enforceDdosRateLimit( const counterKey = `antiddos:v:${ip}`; violations = await redis.incr(counterKey); if (violations === 1) { - await redis.pexpire(counterKey, VIOLATION_COUNTER_TTL_SECONDS * 1000); + await redis.pexpire(counterKey, config.violationWindowSeconds * 1000); + } + const ttl = blockTtlForViolations(violations, config.blockTiers); + if (violations >= config.maxViolations) { + await redis.set(blockKey, "1", "EX", ttl); } - const ttl = blockTtlForViolations(violations); - await redis.set(blockKey, "1", "EX", ttl); return { outcome: "block", retryAfterSeconds: ttl }; } catch { // fail-open — Redis merely unavailable; in-process buckets still shed.