From f32a6dadd0747d11d7581d827247322882e5a158 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 23 Sep 2026 13:03:19 +0200 Subject: [PATCH] feat(security): auto-block repeat offenders via CrowdSec community reputation - new crowdsec-api lib: CTI lookup (GET /smoke/{ip}, freemium x-api-key), verdict parser with false-positive veto, 1h Redis + in-memory verdict cache, NX lock dedupe, 403/429 backoff; writes only the shared antiddos:block:{ip} key (value "crowdsec") and never touches Cloudflare - gate fires it fire-and-forget for IPs that already tripped a rate bucket, so known-bad IPs are hard-blocked before the local maxViolations threshold - runtime config: crowdsecAutoBlock toggle, score threshold (0-5, default 4), block TTL (default 24h); boot defaults CROWDSEC_AUTO_BLOCK_ENABLED / CROWDSEC_BLOCK_SCORE / CROWDSEC_BLOCK_TTL_SECONDS - admin panel: CrowdSec stat card, verify-connection action, score/TTL settings, CrowdSec source badge in the blocked-IPs list - credentials live in env only (CROWDSEC_API_KEY); block is enforced per-request via proxy on the resolved X-Forwarded-For / CF-Connecting-IP - tests: crowdsec-api unit suite + ddos-guard integration suite (early-block, threshold, cache dedupe, backoff) --- .env.example | 18 + src/actions/admin-antiddos.ts | 42 +++ src/app/admin/devops/antiddos/page.tsx | 163 ++++++++- src/env.ts | 29 ++ src/lib/antiddos-config.ts | 41 +++ src/lib/crowdsec-api.test.ts | 422 ++++++++++++++++++++++ src/lib/crowdsec-api.ts | 469 +++++++++++++++++++++++++ src/lib/ddos-guard-crowdsec.test.ts | 220 ++++++++++++ src/lib/ddos-guard.ts | 15 + 9 files changed, 1414 insertions(+), 5 deletions(-) create mode 100644 src/lib/crowdsec-api.test.ts create mode 100644 src/lib/crowdsec-api.ts create mode 100644 src/lib/ddos-guard-crowdsec.test.ts diff --git a/.env.example b/.env.example index bd5ec34d..af2d5a41 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,24 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true # Override for tests/staging (production uses the public endpoint by default). CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4 +# --- CROWDSEC API (community reputation auto-block, optional) --- +# Free CTI API key: https://app.crowdsec.net/ → Settings → CTI API Keys. +# When set, the anti-DDoS gate checks the community reputation of repeat +# offenders (CTI GET /smoke/{ip}) and immediately hard-blocks known-bad IPs. +# Lookups only happen for IPs that already tripped a rate bucket and are +# cached in Redis for 1h, so quota usage stays minimal. +CROWDSEC_API_KEY= +# Runtime toggle for reputation-based auto-blocking (also overridable live +# from the admin panel). Requires CROWDSEC_API_KEY. +CROWDSEC_AUTO_BLOCK_ENABLED=true +# Minimum malevolence score 0-5 (CrowdSec scale; 4-5 = "malicious") before an +# IP is treated as known-bad. IPs with false-positive tags are never blocked. +CROWDSEC_BLOCK_SCORE=4 +# How long a CrowdSec-confirmed bad IP stays blocked (seconds). +CROWDSEC_BLOCK_TTL_SECONDS=86400 +# Endpoint — override only for tests/staging. +CROWDSEC_CTI_BASE_URL=https://cti.api.crowdsec.net/v2 + # --- PATHS --- BADGE_UPLOAD_DIR=./public/assets/images/badges EMULATOR_JAR_PATH=./emulator/Arcturus.jar diff --git a/src/actions/admin-antiddos.ts b/src/actions/admin-antiddos.ts index 72f56707..3ab25ff0 100644 --- a/src/actions/admin-antiddos.ts +++ b/src/actions/admin-antiddos.ts @@ -15,6 +15,10 @@ import { setLastCloudflareVerify, verifyCloudflareConnection, } from "@/lib/cloudflare-api"; +import { + setLastCrowdsecVerify, + verifyCrowdsecConnection, +} from "@/lib/crowdsec-api"; import { db, WebsiteSetting } from "@/lib/db"; import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; @@ -33,6 +37,17 @@ function positiveInt(raw: FormDataEntryValue | null, fallback: number): number { return Math.floor(n); } +function clampInt( + raw: FormDataEntryValue | null, + fallback: number, + min: number, + max: number, +): number { + const n = Number(str(raw)); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.floor(n))); +} + function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] { const tiers: AntiddosBlockTier[] = []; for (const part of str(raw).split(",")) { @@ -99,6 +114,17 @@ function configFromForm(formData: FormData): AntiddosConfig { defaults.globalHaltMs, ), cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1", + crowdsecAutoBlock: str(formData.get("cs_auto_block")) === "1", + crowdsecBlockScore: clampInt( + formData.get("cs_block_score"), + defaults.crowdsecBlockScore, + 0, + 5, + ), + crowdsecBlockTtlSeconds: positiveInt( + formData.get("cs_block_ttl_sec"), + defaults.crowdsecBlockTtlSeconds, + ), }; } @@ -123,6 +149,9 @@ async function persistSettings(config: AntiddosConfig): Promise { ], ["antiddos_global_halt_ms", String(config.globalHaltMs)], ["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"], + ["antiddos_cs_auto_block", config.crowdsecAutoBlock ? "1" : "0"], + ["antiddos_cs_block_score", String(config.crowdsecBlockScore)], + ["antiddos_cs_block_ttl", String(config.crowdsecBlockTtlSeconds)], ]; await Promise.all( entries.map(([key, value]) => @@ -230,6 +259,19 @@ export async function removeCloudflareRule(formData: FormData): Promise { revalidatePath("/admin/devops/antiddos"); } +/** Test the configured CrowdSec API credentials against the CTI endpoint. */ +export async function verifyCrowdsecConfiguration(): Promise { + const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const status = await verifyCrowdsecConnection(); + await setLastCrowdsecVerify(status); + logger.info("CrowdSec API configuration verified", { + staff: staff.username, + ok: status.ok, + message: status.message, + }); + revalidatePath("/admin/devops/antiddos"); +} + /** Test the configured Cloudflare API credentials against the zone. */ export async function verifyCloudflareConfiguration(): Promise { const staff = await requirePermission(PERMS.SETTINGS_VIEW); diff --git a/src/app/admin/devops/antiddos/page.tsx b/src/app/admin/devops/antiddos/page.tsx index beaa1302..4f5ca068 100644 --- a/src/app/admin/devops/antiddos/page.tsx +++ b/src/app/admin/devops/antiddos/page.tsx @@ -1,4 +1,11 @@ -import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react"; +import { + BadgeCheck, + Cloud, + Lock, + Radar, + Server, + ShieldAlert, +} from "lucide-react"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { @@ -7,6 +14,7 @@ import { saveAntiddosSettings, unbanAntiddosIp, verifyCloudflareConfiguration, + verifyCrowdsecConfiguration, } from "@/actions/admin-antiddos"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -24,6 +32,11 @@ import { listCloudflareBlocks, sweepExpiredCloudflareBlocks, } from "@/lib/cloudflare-api"; +import { + CROWDSEC_BLOCK_SOURCE, + crowdsecEnabled, + getLastCrowdsecVerify, +} from "@/lib/crowdsec-api"; import { db, WebsiteSetting } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { redis } from "@/lib/redis"; @@ -58,7 +71,12 @@ export default async function AdminAntiDdosPage() { const sourceHeader = preferredClientIpHeader(requestHeaders); const viewerIp = resolveClientIp(requestHeaders); - let blocks: { ip: string; ttlMs: number; count: number }[] = []; + let blocks: { + ip: string; + ttlMs: number; + count: number; + source: "gate" | "crowdsec"; + }[] = []; let redisOk = false; const rateStore = redis; if (rateStore) { @@ -78,11 +96,19 @@ export default async function AdminAntiDdosPage() { } const withTtl = await Promise.all( blockKeys.slice(0, 100).map(async (key) => { - const ttlMs = await rateStore.pttl(key); + const [ttlMs, value] = await Promise.all([ + rateStore.pttl(key), + rateStore.get(key), + ]); return { ip: key.replace("antiddos:block:", ""), ttlMs: ttlMs > 0 ? ttlMs : 0, count: violationCounts.get(key.replace("antiddos:block:", "")) ?? 0, + // The gate writes "1"; "crowdsec" marks a community-reputation block. + source: + value === CROWDSEC_BLOCK_SOURCE + ? ("crowdsec" as const) + : ("gate" as const), }; }), ); @@ -103,10 +129,12 @@ export default async function AdminAntiDdosPage() { cloudflareBlocks.push(...(await listCloudflareBlocks())); } const lastVerify = await getLastCloudflareVerify(); + const crowdsecConfigured = crowdsecEnabled(); + const lastCrowdsecVerify = await getLastCrowdsecVerify(); return (
-
+
Gate @@ -157,6 +185,21 @@ export default async function AdminAntiDdosPage() { + + + CrowdSec + + + + + {crowdsecConfigured ? "Connected" : "Not configured"} + +

+ Community reputation auto-block +

+
+
+ Active blocks @@ -254,6 +297,53 @@ export default async function AdminAntiDdosPage() { block.

+ +

+ Requires CROWDSEC_API_KEY in + the environment. When a repeat offender has a bad community + reputation it is hard-blocked immediately (no need to cross the + local violation threshold). IPs carrying CrowdSec false-positive + tags are never blocked. +

+
+ + +
+
{( [ @@ -401,7 +491,12 @@ export default async function AdminAntiDdosPage() { className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm" > {b.ip} - + + {b.source === "crowdsec" ? ( + CrowdSec + ) : ( + Gate + )} TTL {seconds(b.ttlMs)} · violations {b.count}
@@ -495,6 +590,64 @@ export default async function AdminAntiDdosPage() { + + + + CrowdSec reputation API + + + +
+ + {crowdsecConfigured ? "API configured" : "API not configured"} + + {!crowdsecConfigured && ( +

+ Set CROWDSEC_API_KEY to + enable community-reputation auto-blocks. When a repeat offender + is flagged as malicious by the CrowdSec community it is + hard-blocked immediately without waiting for the local violation + threshold. +

+ )} + + + +
+ + {lastCrowdsecVerify && crowdsecConfigured && ( +

+ + {lastCrowdsecVerify.ok ? "Reachable" : "Failed"} + + + {lastCrowdsecVerify.ok + ? `CTI endpoint verified ${new Date(lastCrowdsecVerify.at).toLocaleString()}` + : lastCrowdsecVerify.message} + +

+ )} + + {crowdsecConfigured && ( +

+ Verdicts are looked up lazily for IPs that already triggered a + rate bucket (never on the per-request hot path), cached for an + hour, and blocked IPs show a{" "} + CrowdSec badge in the list above. +

+ )} +
+
+ {stored.size === 0 && (

Persisted site settings: none yet — the form values above reflect the diff --git a/src/env.ts b/src/env.ts index 66e45fc9..3cce5ae1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -148,6 +148,35 @@ const schema = z .string() .optional() .transform((value) => value !== "false" && value !== "0"), + // CrowdSec API — optional. When the CTI API key is set, the anti-DDoS + // gate consults the community reputation of repeat offenders (CTI + // GET /smoke/{ip}) and hard-blocks known-bad IPs immediately. Like the + // Cloudflare token, the key lives in env only and is never written into + // the admin-visible config. Free/community key: app.crowdsec.net → + // Settings → CTI API Keys. + CROWDSEC_API_KEY: z.string().optional(), + // Reputation lookup (CTI) endpoint; overridden for tests/staging. + CROWDSEC_CTI_BASE_URL: z + .string() + .url() + .default("https://cti.api.crowdsec.net/v2"), + // Boot default for the runtime "auto-block from CrowdSec reputation" + // toggle (overridable via the admin panel / antiddos:config). + CROWDSEC_AUTO_BLOCK_ENABLED: z + .string() + .optional() + .transform((value) => value !== "false" && value !== "0"), + // Minimum malevolence score (CrowdSec scores are 0-5; 4-5 maps to + // "malicious") an IP must reach before the gate treats it as known-bad. + // An IP the community already labels "malicious" is always blocked, + // unless it carries false-positive classification tags. + CROWDSEC_BLOCK_SCORE: z.coerce.number().int().min(0).max(5).default(4), + // How long a CrowdSec-confirmed bad IP stays blocked by the gate. + CROWDSEC_BLOCK_TTL_SECONDS: z.coerce + .number() + .int() + .positive() + .default(86_400), }) .superRefine((data, ctx) => { if (data.NODE_ENV !== "production") return; diff --git a/src/lib/antiddos-config.ts b/src/lib/antiddos-config.ts index f0024e5f..a9b81009 100644 --- a/src/lib/antiddos-config.ts +++ b/src/lib/antiddos-config.ts @@ -24,6 +24,11 @@ export interface AntiddosConfig { blockTiers: AntiddosBlockTier[]; globalHaltMs: number; cloudflareAutoBlock: boolean; + crowdsecAutoBlock: boolean; + /** Minimum CrowdSec malevolence score (0-5) treated as known-bad. */ + crowdsecBlockScore: number; + /** How long a CrowdSec-confirmed bad IP stays blocked by the gate. */ + crowdsecBlockTtlSeconds: number; } const DEFAULT_CONFIG: AntiddosConfig = { @@ -41,6 +46,9 @@ const DEFAULT_CONFIG: AntiddosConfig = { ], globalHaltMs: 10_000, cloudflareAutoBlock: true, + crowdsecAutoBlock: true, + crowdsecBlockScore: 4, + crowdsecBlockTtlSeconds: 86_400, }; function positiveInt(value: number | undefined, fallback: number): number { @@ -49,6 +57,17 @@ function positiveInt(value: number | undefined, fallback: number): number { return Math.floor(n); } +function clampInt( + value: number | undefined, + fallback: number, + min: number, + max: number, +): number { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.floor(n))); +} + function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null { if (!raw?.trim()) return null; const tiers: AntiddosBlockTier[] = []; @@ -125,6 +144,17 @@ export function antiddosDefaultsFromEnv(): AntiddosConfig { DEFAULT_CONFIG.globalHaltMs, ), cloudflareAutoBlock: isTruthyFlag(env.CLOUDFLARE_AUTO_BLOCK_ENABLED), + crowdsecAutoBlock: isTruthyFlag(env.CROWDSEC_AUTO_BLOCK_ENABLED), + crowdsecBlockScore: clampInt( + env.CROWDSEC_BLOCK_SCORE, + DEFAULT_CONFIG.crowdsecBlockScore, + 0, + 5, + ), + crowdsecBlockTtlSeconds: positiveInt( + env.CROWDSEC_BLOCK_TTL_SECONDS, + DEFAULT_CONFIG.crowdsecBlockTtlSeconds, + ), }; } @@ -167,6 +197,17 @@ function sanitize(config: AntiddosConfig): AntiddosConfig { : base.blockTiers, globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs), cloudflareAutoBlock: config?.cloudflareAutoBlock !== false, + crowdsecAutoBlock: config?.crowdsecAutoBlock !== false, + crowdsecBlockScore: clampInt( + config?.crowdsecBlockScore, + base.crowdsecBlockScore, + 0, + 5, + ), + crowdsecBlockTtlSeconds: positiveInt( + config?.crowdsecBlockTtlSeconds, + base.crowdsecBlockTtlSeconds, + ), }; } diff --git a/src/lib/crowdsec-api.test.ts b/src/lib/crowdsec-api.test.ts new file mode 100644 index 00000000..44c79a6d --- /dev/null +++ b/src/lib/crowdsec-api.test.ts @@ -0,0 +1,422 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type CrowdsecVerdict, + crowdsecEnabled, + getCrowdsecApiConfig, + getLastCrowdsecVerify, + maybeAutoBlockCrowdsec, + resetCrowdsecCache, + setLastCrowdsecVerify, + verdictIsMalicious, + verifyCrowdsecConnection, +} from "./crowdsec-api"; + +// Unit-test the CTI client in isolation: a deterministic in-memory Redis fake +// and a silenced logger, so fetch calls count only CrowdSec lookups. CrowdSec +// deliberately never touches Cloudflare, so no Cloudflare surface is stubbed. +const state = vi.hoisted(() => ({ map: new Map() })); + +vi.mock("@/lib/redis", () => ({ + redis: { + get: async (key: string) => state.map.get(key) ?? null, + set: async ( + key: string, + value: string, + _mode?: string, + _seconds?: number, + nx?: string, + ) => { + if (nx === "NX" && state.map.has(key)) return null; + state.map.set(key, value); + return "OK"; + }, + del: async (...keys: string[]) => { + for (const key of keys) state.map.delete(key); + return keys.length; + }, + pttl: async () => 60_000, + }, + __esModule: true, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function maliciousItem(ip: string, score = 5): unknown { + return { + ip, + reputation: "malicious", + confidence: "0.95", + scores: { overall: { aggressiveness: 4, total: score } }, + behaviors: [{ name: "http:bruteforce" }, { name: "http:scan" }], + classifications: { false_positives: [] }, + }; +} + +function suspiciousItem(ip: string, score: number): unknown { + return { + ip, + reputation: "suspicious", + scores: { overall: { total: score } }, + }; +} + +function verdict(minimal: Partial = {}): CrowdsecVerdict { + return { + ip: "198.51.100.1", + reputation: "suspicious", + score: 3, + aggressiveness: 0, + confidence: null, + behaviors: [], + falsePositive: false, + checkedAt: Date.now(), + ...minimal, + }; +} + +function blockIp(): string { + return "198.51.100.1"; +} + +describe("crowdsec-api", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + state.map.clear(); + resetCrowdsecCache(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + state.map.clear(); + resetCrowdsecCache(); + vi.restoreAllMocks(); + }); + + it("is enabled only when a non-blank API key is configured", () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + vi.stubEnv("CROWDSEC_CTI_BASE_URL", "https://cti.example.test"); + expect(crowdsecEnabled()).toBe(true); + expect(getCrowdsecApiConfig().apiKey).toBe("cs_key"); + expect(getCrowdsecApiConfig().baseUrl).toBe("https://cti.example.test"); + + vi.stubEnv("CROWDSEC_API_KEY", " "); + expect(crowdsecEnabled()).toBe(false); + vi.stubEnv("CROWDSEC_CTI_BASE_URL", ""); + expect(getCrowdsecApiConfig().baseUrl).toBe( + "https://cti.api.crowdsec.net/v2", + ); + }); + + it("blocks malicious reputations at any threshold", () => { + expect( + verdictIsMalicious(verdict({ reputation: "malicious", score: 0 }), 5), + ).toBe(true); + }); + + it("never blocks safe or benign reputations", () => { + expect(verdictIsMalicious(verdict({ reputation: "safe" }), 0)).toBe(false); + expect(verdictIsMalicious(verdict({ reputation: "benign" }), 1)).toBe( + false, + ); + }); + + it("vetoes a false-positive tag even for a malicious reputation", () => { + expect( + verdictIsMalicious( + verdict({ reputation: "malicious", score: 5, falsePositive: true }), + 4, + ), + ).toBe(false); + }); + + it("applies the score threshold to suspicious/known attackers", () => { + const v4 = verdict({ reputation: "suspicious", score: 4 }); + expect(verdictIsMalicious(v4, 4)).toBe(true); + expect(verdictIsMalicious(v4, 5)).toBe(false); + expect(verdictIsMalicious(verdict({ score: 3 }), 4)).toBe(false); + }); + + it("never blocks score-0 (unknown) IPs even at threshold 0", () => { + expect( + verdictIsMalicious(verdict({ score: 0, reputation: "unknown" }), 0), + ).toBe(false); + }); + + it("does nothing without an API key", async () => { + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does nothing when the runtime toggle is off", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never consults CrowdSec for the unknown-IP sentinel", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + await maybeAutoBlockCrowdsec({ + ip: "0.0.0.0", + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("hard-blocks a malicious IP in the shared gate key only", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); + + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 86_400, + scoreThreshold: 4, + enabled: true, + }); + + expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec"); + // No Cloudflare keys may ever be written by CrowdSec. + expect( + [...state.map.keys()].some((key) => key.includes("cloudflare")), + ).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain(`/smoke/${blockIp()}`); + expect((init.headers as Record)["x-api-key"]).toBe( + "cs_key", + ); + }); + + it("does not block an IP the community knows nothing about", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse({}, 404)); + + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + + expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false); + }); + + it("respects a custom score threshold", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3))); + + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false); + + fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3))); + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 3, + enabled: true, + }); + expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec"); + }); + + it("dedupes concurrent lookups into a single API call", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); + + await Promise.all([ + maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }), + maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }), + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reuses the Redis verdict cache for repeat offenders", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); + + for (let i = 0; i < 3; i += 1) { + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("never shortens an existing longer host block", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); + + state.map.set(`antiddos:block:${blockIp()}`, "1"); + const redis = (await import("@/lib/redis")).redis; + vi.spyOn(redis as NonNullable, "pttl").mockImplementation( + async () => 86_400_000, + ); + + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + + expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("1"); + }); + + it("backs off after a 403 so it stops hammering a rejected key", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403)); + + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + await maybeAutoBlockCrowdsec({ + ip: "203.0.113.9", + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("backs off after a 429 rate limit as well", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse({ message: "rate limited" }, 429)); + + await maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + await maybeAutoBlockCrowdsec({ + ip: "198.51.100.2", + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("swallows API failures instead of throwing on the hot path", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500)); + + await expect( + maybeAutoBlockCrowdsec({ + ip: blockIp(), + category: "api", + ttlSeconds: 600, + scoreThreshold: 4, + enabled: true, + }), + ).resolves.toBeUndefined(); + expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false); + }); + + it("reports a missing credential without calling the API", async () => { + const status = await verifyCrowdsecConnection(); + expect(status.ok).toBe(false); + expect(status.message).toContain("CROWDSEC_API_KEY"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("verifies the key against the CTI probe endpoint", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue( + jsonResponse({ ip: "1.1.1.1", reputation: "safe" }), + ); + + const status = await verifyCrowdsecConnection(); + expect(status.ok).toBe(true); + expect(status.message).toContain("1.1.1.1"); + expect(String(fetchMock.mock.calls[0][0])).toContain("/smoke/1.1.1.1"); + expect( + (fetchMock.mock.calls[0][1].headers as Record)[ + "x-api-key" + ], + ).toBe("cs_key"); + }); + + it("surfaces a rejected credential", async () => { + vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); + fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403)); + + const status = await verifyCrowdsecConnection(); + expect(status.ok).toBe(false); + expect(status.message).toContain("Invalid key"); + }); + + it("round-trips the last verify status through Redis and memory", async () => { + const status = { ok: true, message: "CTI key accepted", at: Date.now() }; + await setLastCrowdsecVerify(status); + expect(await getLastCrowdsecVerify()).toEqual(status); + expect(JSON.parse(state.map.get("crowdsec:last-verify") ?? "{}")).toEqual( + status, + ); + }); +}); diff --git a/src/lib/crowdsec-api.ts b/src/lib/crowdsec-api.ts new file mode 100644 index 00000000..03bff31b --- /dev/null +++ b/src/lib/crowdsec-api.ts @@ -0,0 +1,469 @@ +import "server-only"; + +import { env } from "@/env"; +import { logger } from "@/lib/logger"; +import { redis } from "@/lib/redis"; +import { UNKNOWN_CLIENT_IP } from "./client-ip"; + +/** + * CrowdSec CTI (community threat intelligence) integration for the anti-DDoS + * gate — the reputation side of the auto-block pipeline. + * + * When an IP trips a rate bucket, the gate consults CrowdSec's community + * reputation for that IP (`GET /smoke/{ip}`, the freemium Enrichment API, + * `x-api-key` auth) and hard-blocks known-bad repeat offenders immediately + * instead of waiting for the local `maxViolations` threshold. The block lives + * only in the gate's own shared Redis key (`antiddos:block:{ip}`) so every + * existing consumer — the proxy check, the admin block list, the admin unban — + * keeps working unchanged. CrowdSec never talks to Cloudflare and never + * creates edge rules; if a Cloudflare mirror is wanted it is the gate's own + * escalation logic that decides, never this module. + * + * Quota safety: lookups only run for IPs that already tripped a bucket (never + * on the plain hot path), verdicts are cached in Redis for an hour (so a + * flood from one IP costs at most one API call), concurrent lookups for the + * same IP are deduped across instances with a Redis NX lock, and a 403/429 + * response trips a module-wide backoff instead of hammering the API. + * + * Credentials come from env only (`CROWDSEC_API_KEY`) and are never written + * into the admin-visible config — same contract as the Cloudflare token. + * + * Note: sharing our own blocks back into the community (signal push) is not + * part of this module — CrowdSec's report channel requires a full Security + * Engine / CAPI machine enrollment, not a CTI API key. + */ + +export class CrowdsecApiError extends Error {} + +export interface CrowdsecApiConfig { + baseUrl: string; + apiKey: string | null; +} + +export type CrowdsecReputation = + | "malicious" + | "suspicious" + | "known" + | "safe" + | "benign" + | "unknown"; + +export interface CrowdsecVerdict { + ip: string; + /** Raw CTI reputation enum; null when the IP is unknown to the community. */ + reputation: CrowdsecReputation | null; + /** `scores.overall.total` — CrowdSec malevolence score, 0-5. */ + score: number; + /** `scores.overall.aggressiveness` — 0-5. */ + aggressiveness: number; + confidence: string | null; + /** Reported attack categories, e.g. ["http:scan", "ssh:bruteforce"]. */ + behaviors: string[]; + /** CrowdSec tags IPs carrying false-positive classifications as safe. */ + falsePositive: boolean; + checkedAt: number; +} + +export interface CrowdsecConnectionStatus { + ok: boolean; + message?: string; + at: number; +} + +/** Value written into the shared block key so the admin UI can label the source. */ +export const CROWDSEC_BLOCK_SOURCE = "crowdsec"; + +const API_TIMEOUT_MS = 10_000; +const VERDICT_CACHE_TTL_SECONDS = 3600; +const VERDICT_CACHE_TTL_MS = VERDICT_CACHE_TTL_SECONDS * 1000; +const LOOKUP_LOCK_TTL_SECONDS = 60; +const RATE_LIMIT_BACKOFF_MS = 60_000; +const AUTH_BACKOFF_MS = 300_000; +const VERDICT_PREFIX = "crowdsec:cti:"; +const LOOKUP_LOCK_PREFIX = "crowdsec:lock:"; +const LAST_VERIFY_KEY = "crowdsec:last-verify"; +/** Well-known, community-safe address used by the admin "verify" button. */ +const PROBE_IP = "1.1.1.1"; + +export function getCrowdsecApiConfig(): CrowdsecApiConfig { + return { + baseUrl: env.CROWDSEC_CTI_BASE_URL || "https://cti.api.crowdsec.net/v2", + apiKey: env.CROWDSEC_API_KEY?.trim() || null, + }; +} + +/** True when a CTI API key is present so the gate may call the API. */ +export function crowdsecEnabled(): boolean { + return Boolean(getCrowdsecApiConfig().apiKey); +} + +interface CrowdsecScore { + aggressiveness?: number; + threat?: number; + trust?: number; + anomaly?: number; + total?: number; +} + +interface CrowdsecSmokeItem { + ip?: string; + reputation?: string; + confidence?: string; + scores?: { overall?: CrowdsecScore }; + classifications?: { false_positives?: unknown[] }; + behaviors?: { name?: string }[]; +} + +async function crowdsecRequest( + path: string, + init: { method?: "GET" | "POST"; body?: unknown } = {}, +): Promise { + const config = getCrowdsecApiConfig(); + if (!config.apiKey) { + throw new CrowdsecApiError("CROWDSEC_API_KEY is not configured"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + try { + return await fetch(`${config.baseUrl}${path}`, { + method: init.method ?? "GET", + headers: { + "x-api-key": config.apiKey, + Accept: "application/json", + "Content-Type": "application/json", + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + signal: controller.signal, + cache: "no-store", + }); + } finally { + clearTimeout(timer); + } +} + +async function errorDetail(response: Response): Promise { + try { + const body = (await response.json()) as { message?: string }; + return body.message ?? `HTTP ${response.status}`; + } catch { + return `HTTP ${response.status}`; + } +} + +function toNumber(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function parseVerdict(ip: string, item: CrowdsecSmokeItem): CrowdsecVerdict { + const overall = item.scores?.overall; + const falsePositives = item.classifications?.false_positives ?? []; + return { + ip, + reputation: (item.reputation as CrowdsecReputation | undefined) ?? null, + score: toNumber(overall?.total), + aggressiveness: toNumber(overall?.aggressiveness), + confidence: item.confidence ?? null, + behaviors: (item.behaviors ?? []) + .map((behavior) => behavior?.name) + .filter((name): name is string => Boolean(name)), + // CrowdSec: "Any IP with false_positives tags shouldn't be considered + // as malicious" — this veto always wins over reputation and score. + falsePositive: falsePositives.length > 0, + checkedAt: Date.now(), + }; +} + +/** + * Resolve a cached CTI verdict into a block/no-block decision against the + * admin-configurable score threshold. `malicious` is always blocked; `safe` + * and `benign` never are; everything else follows the 0-5 score threshold + * (with score 0 = "unknown" never blocking, even at threshold 0). + */ +export function verdictIsMalicious( + verdict: CrowdsecVerdict, + threshold: number, +): boolean { + if (verdict.falsePositive) return false; + if (verdict.reputation === "malicious") return true; + if (verdict.reputation === "safe" || verdict.reputation === "benign") { + return false; + } + const effective = Math.min(5, Math.max(0, threshold)); + return verdict.score >= effective && verdict.score >= 1; +} + +// --- Verdict cache (Redis backed, in-process fallback) --- + +const memoryVerdicts = new Map(); + +function verdictKey(ip: string): string { + return `${VERDICT_PREFIX}${ip}`; +} + +async function readVerdictCache(ip: string): Promise { + const cached = memoryVerdicts.get(ip); + if (cached && Date.now() - cached.checkedAt < VERDICT_CACHE_TTL_MS) { + return cached; + } + if (redis) { + try { + const raw = await redis.get(verdictKey(ip)); + if (raw) { + const parsed = JSON.parse(raw) as CrowdsecVerdict; + memoryVerdicts.set(ip, parsed); + return parsed; + } + } catch { + // fall through to a cache miss — the API call below is the fallback. + } + } + return null; +} + +async function writeVerdictCache(verdict: CrowdsecVerdict): Promise { + memoryVerdicts.set(verdict.ip, verdict); + if (redis) { + try { + await redis.set( + verdictKey(verdict.ip), + JSON.stringify(verdict), + "EX", + VERDICT_CACHE_TTL_SECONDS, + ); + } catch { + // cache is best-effort — a miss only costs one extra API call later. + } + } +} + +/** Cross-instance dedupe so a cold-cache flood costs one lookup, not N. */ +async function acquireLookupLock(ip: string): Promise { + if (!redis) return true; + try { + const acquired = await redis.set( + `${LOOKUP_LOCK_PREFIX}${ip}`, + "1", + "EX", + LOOKUP_LOCK_TTL_SECONDS, + "NX", + ); + return acquired === "OK"; + } catch { + // Redis hiccup — allow the lookup; the verdict cache still dedupes. + return true; + } +} + +let backoffUntil = 0; + +/** + * Community reputation verdict for an IP, from cache when possible. Returns + * null when the API is not configured, the lookup failed, or the API is in + * backoff — never throws, so it is safe on the gate's hot path. + */ +export async function lookupCrowdsecVerdict( + ip: string, +): Promise { + if (!crowdsecEnabled()) return null; + if (!ip || ip === UNKNOWN_CLIENT_IP) return null; + if (Date.now() < backoffUntil) return null; + + const cached = await readVerdictCache(ip); + if (cached) return cached; + + if (!(await acquireLookupLock(ip))) { + // Another instance is mid-lookup for this IP; skip rather than + // double-spend API quota on the same address. + return null; + } + + try { + // Re-read after claiming the lock — a concurrent instance may have + // filled the cache while we were acquiring it. + const raced = await readVerdictCache(ip); + if (raced) return raced; + + const response = await crowdsecRequest(`/smoke/${encodeURIComponent(ip)}`); + + if (response.status === 404) { + // Unknown to the community — cache the negative result so a clean + // repeat offender never costs another API call this hour. + const verdict = parseVerdict(ip, {}); + await writeVerdictCache(verdict); + return verdict; + } + if (response.status === 403) { + backoffUntil = Date.now() + AUTH_BACKOFF_MS; + throw new CrowdsecApiError( + `CrowdSec API key rejected (HTTP 403): ${await errorDetail(response)}`, + ); + } + if (response.status === 429) { + backoffUntil = Date.now() + RATE_LIMIT_BACKOFF_MS; + logger.warn("[crowdsec-api] CTI API rate limit hit — backing off", { + ip, + backoffMs: RATE_LIMIT_BACKOFF_MS, + }); + return null; + } + if (!response.ok) { + throw new CrowdsecApiError( + `CrowdSec CTI API error (HTTP ${response.status}): ${await errorDetail(response)}`, + ); + } + + const item = (await response.json()) as CrowdsecSmokeItem; + const verdict = parseVerdict(ip, item); + await writeVerdictCache(verdict); + return verdict; + } catch (error) { + logger.error("[crowdsec-api] CTI lookup failed", { ip, err: error }); + return null; + } +} + +/** + * Consult the CrowdSec community reputation of an IP that just tripped a rate + * bucket and hard-block it when the community flags it as known-bad. Safe to + * call fire-and-forget from the hot path: it is never awaited by the caller, + * does nothing when the API is not configured or the runtime toggle is off, + * never shortens an already-active block, and never lets an API failure + * surface to the request. + */ +export async function maybeAutoBlockCrowdsec(input: { + ip: string; + category: string; + ttlSeconds: number; + scoreThreshold: number; + enabled: boolean; +}): Promise { + const { ip, category, ttlSeconds, scoreThreshold, enabled } = input; + if (!enabled) return; + if (!crowdsecEnabled()) return; + if (!ip || ip === UNKNOWN_CLIENT_IP) return; + // The gate only ever reads its block key through shared Redis — without it + // there is nowhere durable to record the block. + if (!redis) return; + if (Date.now() < backoffUntil) return; + + try { + const verdict = await lookupCrowdsecVerdict(ip); + if (!verdict || !verdictIsMalicious(verdict, scoreThreshold)) return; + + const blockKey = `antiddos:block:${ip}`; + const existingTtl = await redis.pttl(blockKey); + // -2 = no key, -1 = no expiry; both fall through and get overwritten + // with the CrowdSec TTL. An equal or longer block is left untouched. + if (existingTtl >= ttlSeconds * 1000) return; + + await redis.set(blockKey, CROWDSEC_BLOCK_SOURCE, "EX", ttlSeconds); + // CrowdSec only records the block in the gate's own key. It never + // creates Cloudflare edge rules — the gate's own escalation logic is + // the only place that may mirror a host-level block to the edge. + logger.info( + "[crowdsec-api] Automatic IP block created from community reputation", + { + ip, + category, + ttlSeconds, + reputation: verdict.reputation, + score: verdict.score, + behaviors: verdict.behaviors, + }, + ); + } catch (error) { + logger.error("[crowdsec-api] Automatic IP block failed", { + ip, + err: error, + }); + } +} + +let lastVerifyMemory: CrowdsecConnectionStatus | null = null; + +/** Validate that the configured key can query the CTI (Enrichment) API. */ +export async function verifyCrowdsecConnection(): Promise { + const config = getCrowdsecApiConfig(); + if (!config.apiKey) { + return { + ok: false, + message: "CROWDSEC_API_KEY is not configured", + at: Date.now(), + }; + } + try { + const response = await crowdsecRequest(`/smoke/${PROBE_IP}`); + if (response.ok) { + const item = (await response + .json() + .catch(() => null)) as CrowdsecSmokeItem | null; + const reputation = item?.reputation + ? ` (reputation ${item.reputation})` + : ""; + return { + ok: true, + message: `CTI key accepted — probed ${PROBE_IP}${reputation}`, + at: Date.now(), + }; + } + if (response.status === 403) { + return { + ok: false, + message: `API key rejected: ${await errorDetail(response)}`, + at: Date.now(), + }; + } + if (response.status === 429) { + return { + ok: false, + message: "CTI API rate limit reached — try again shortly", + at: Date.now(), + }; + } + return { + ok: false, + message: `CrowdSec CTI API error (HTTP ${response.status}): ${await errorDetail(response)}`, + at: Date.now(), + }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error ? error.message : "CrowdSec API unreachable", + at: Date.now(), + }; + } +} + +export async function getLastCrowdsecVerify(): Promise { + if (redis) { + try { + const raw = await redis.get(LAST_VERIFY_KEY); + if (raw) return JSON.parse(raw) as CrowdsecConnectionStatus; + } catch { + // fall back to the in-process view + } + } + return lastVerifyMemory; +} + +export async function setLastCrowdsecVerify( + status: CrowdsecConnectionStatus, +): Promise { + lastVerifyMemory = status; + if (redis) { + try { + await redis.set(LAST_VERIFY_KEY, JSON.stringify(status)); + } catch { + // redis unavailable — in-process view is enough + } + } +} + +/** Test hook only — drop in-memory state between unit runs. */ +export function resetCrowdsecCache(): void { + memoryVerdicts.clear(); + backoffUntil = 0; + lastVerifyMemory = null; +} diff --git a/src/lib/ddos-guard-crowdsec.test.ts b/src/lib/ddos-guard-crowdsec.test.ts new file mode 100644 index 00000000..64235a76 --- /dev/null +++ b/src/lib/ddos-guard-crowdsec.test.ts @@ -0,0 +1,220 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { invalidateAntiddosConfig } from "@/lib/antiddos-config"; +import { resetCrowdsecCache } from "@/lib/crowdsec-api"; +import { enforceDdosRateLimit } from "@/lib/ddos-guard"; + +// The gate's block escalation (and thus the CrowdSec hook) only runs when +// Redis is reachable, so the integration test drives a small in-memory fake. +const state = vi.hoisted(() => ({ map: new Map() })); + +vi.mock("@/lib/redis", () => ({ + redis: { + get: async (key: string) => state.map.get(key) ?? null, + set: async ( + key: string, + value: string, + _mode?: string, + _seconds?: number, + nx?: string, + ) => { + if (nx === "NX" && state.map.has(key)) return null; + state.map.set(key, value); + return "OK"; + }, + del: async (...keys: string[]) => { + for (const key of keys) state.map.delete(key); + return keys.length; + }, + incr: async (key: string) => { + const next = (Number(state.map.get(key)) || 0) + 1; + state.map.set(key, String(next)); + return next; + }, + pexpire: async () => 1, + pttl: async () => 60_000, + sadd: async (key: string, member: string) => { + const members = new Set( + (state.map.get(key) ?? "").split("\u0001").filter(Boolean), + ); + members.add(member); + state.map.set(key, [...members].join("\u0001")); + return 1; + }, + srem: async (key: string, member: string) => { + const members = new Set( + (state.map.get(key) ?? "").split("\u0001").filter(Boolean), + ); + const before = members.size; + members.delete(member); + state.map.set(key, [...members].join("\u0001")); + return before - members.size; + }, + smembers: async (key: string) => + (state.map.get(key) ?? "").split("\u0001").filter(Boolean), + }, + __esModule: true, +})); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function proxiedRequest(ip: string): NextRequest { + return new NextRequest("https://hotel.test/api/balance", { + headers: { "cf-ray": "abc-AMS", "cf-connecting-ip": ip }, + }); +} + +function directRequest(ip: string): NextRequest { + return new NextRequest("https://hotel.test/api/balance", { + headers: { "x-real-ip": ip }, + }); +} + +async function pump(req: NextRequest, calls: number): Promise { + let blocks = 0; + for (let i = 0; i < calls; i += 1) { + const decision = await enforceDdosRateLimit(req); + if (decision.outcome === "block") blocks += 1; + } + return blocks; +} + +const apiLimit = "3"; +const maxViolations = "2"; +const crowdsecKey = "test-cs-key"; + +describe("anti-DDoS automatic CrowdSec blocks", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + state.map.clear(); + resetCrowdsecCache(); + invalidateAntiddosConfig(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("ANTI_DDOS_ENABLED", "true"); + vi.stubEnv("ANTI_DDOS_API_LIMIT", apiLimit); + vi.stubEnv("ANTI_DDOS_MAX_VIOLATIONS", maxViolations); + vi.stubEnv("ANTI_DDOS_VIOLATION_WINDOW_SEC", "60"); + vi.stubEnv("CROWDSEC_API_KEY", crowdsecKey); + vi.stubEnv("CLOUDFLARE_API_TOKEN", ""); + vi.stubEnv("CLOUDFLARE_ZONE_ID", ""); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + state.map.clear(); + resetCrowdsecCache(); + invalidateAntiddosConfig(); + }); + + it("blocks a community-flagged offender before the local threshold", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + ip: "198.51.100.71", + reputation: "malicious", + confidence: "0.9", + scores: { overall: { total: 5 } }, + classifications: { false_positives: [] }, + }), + ); + + const ip = "198.51.100.71"; + // The first three requests pass inside the API bucket; the fourth trips + // it, which is where the gate consults CrowdSec (never on the hot path). + const firstFour = await pump(proxiedRequest(ip), 4); + expect(firstFour).toBe(1); + // Let the fire-and-forget lookup + block write settle. + await new Promise((resolve) => setTimeout(resolve, 50)); + + // The community block is already in the shared gate key after a single + // violation — far below the gate's own 2-violation hard-block threshold... + expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec"); + + // ...so every subsequent request is shed immediately via the block check. + const blocks = await pump(proxiedRequest(ip), 4); + expect(blocks).toBe(4); + }); + + it("leaves a community-safe offender to the ordinary gate logic", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + ip: "198.51.100.72", + reputation: "safe", + scores: { overall: { total: 0 } }, + classifications: { false_positives: [] }, + }), + ); + + const ip = "198.51.100.72"; + // With a 3-request API limit and 2 allowed violations, exactly the + // second repeat request trips the ordinary hard block — value "1", + // never "crowdsec". + const blocks = await pump(proxiedRequest(ip), 5); + expect(blocks).toBe(2); + expect(state.map.get(`antiddos:block:${ip}`)).toBe("1"); + }); + + it("never auto-blocks traffic without the API key", async () => { + vi.stubEnv("CROWDSEC_API_KEY", ""); + + await pump(proxiedRequest("198.51.100.73"), 5); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("respects the runtime CrowdSec toggle from the config", async () => { + vi.stubEnv("CROWDSEC_AUTO_BLOCK_ENABLED", "false"); + invalidateAntiddosConfig(); + + await pump(proxiedRequest("198.51.100.74"), 5); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("keeps gate decisions unchanged when the CrowdSec API fails", async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500)); + + const ip = "198.51.100.75"; + const blocks = await pump(proxiedRequest(ip), 5); + expect(blocks).toBe(2); + expect(state.map.get(`antiddos:block:${ip}`)).toBe("1"); + }); + + it("uses the configured score threshold for ambiguous verdicts", async () => { + vi.stubEnv("CROWDSEC_BLOCK_SCORE", "3"); + invalidateAntiddosConfig(); + fetchMock.mockResolvedValue( + jsonResponse({ + ip: "198.51.100.76", + reputation: "suspicious", + scores: { overall: { total: 3 } }, + classifications: { false_positives: [] }, + }), + ); + + const ip = "198.51.100.76"; + await pump(proxiedRequest(ip), 4); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec"); + }); + + it("never auto-blocks the unknown-IP sentinel", async () => { + await pump(directRequest("0.0.0.0"), 1); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ddos-guard.ts b/src/lib/ddos-guard.ts index 6e892bb6..c556bff5 100644 --- a/src/lib/ddos-guard.ts +++ b/src/lib/ddos-guard.ts @@ -7,6 +7,7 @@ import { getAntiddosConfig } from "@/lib/antiddos-config"; import { resolveClientIp } from "@/lib/client-ip"; import { isCloudflareProxied } from "@/lib/cloudflare"; import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api"; +import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api"; import { classifyDdos, isSuspiciousPath } from "@/lib/ddos"; import { rateLimit } from "@/lib/rate-limit"; import { redis } from "@/lib/redis"; @@ -124,6 +125,20 @@ export async function enforceDdosRateLimit( config.cloudflareAutoBlock && isCloudflareProxied(req.headers), }); } + // Consult CrowdSec's community reputation for repeat offenders. + // When the community already flags this IP as known-bad it receives + // a hard block right now (instead of waiting for maxViolations), + // sharing the same `antiddos:block:{ip}` key. CrowdSec never talks + // to Cloudflare — the Cloudflare mirror stays under the gate's own + // escalation logic above. Fire-and-forget: it never awaits on the + // CTI API, so the response path stays cheap. + void maybeAutoBlockCrowdsec({ + ip, + category, + ttlSeconds: config.crowdsecBlockTtlSeconds, + scoreThreshold: config.crowdsecBlockScore, + enabled: config.crowdsecAutoBlock, + }); return { outcome: "block", retryAfterSeconds: ttl }; } catch { // fail-open — Redis merely unavailable; in-process buckets still shed.