feat(security): auto-block repeat offenders via CrowdSec community reputation
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Failing after 17s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Failing after 17s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- 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)
This commit is contained in:
1 parent
64edb81ab7
commit
f32a6dadd0
9 files changed
+1414
-5
No files matched your search
@@ -72,6 +72,24 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
|||||||
# Override for tests/staging (production uses the public endpoint by default).
|
# Override for tests/staging (production uses the public endpoint by default).
|
||||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
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 ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
setLastCloudflareVerify,
|
setLastCloudflareVerify,
|
||||||
verifyCloudflareConnection,
|
verifyCloudflareConnection,
|
||||||
} from "@/lib/cloudflare-api";
|
} from "@/lib/cloudflare-api";
|
||||||
|
import {
|
||||||
|
setLastCrowdsecVerify,
|
||||||
|
verifyCrowdsecConnection,
|
||||||
|
} from "@/lib/crowdsec-api";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
@@ -33,6 +37,17 @@ function positiveInt(raw: FormDataEntryValue | null, fallback: number): number {
|
|||||||
return Math.floor(n);
|
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[] {
|
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
|
||||||
const tiers: AntiddosBlockTier[] = [];
|
const tiers: AntiddosBlockTier[] = [];
|
||||||
for (const part of str(raw).split(",")) {
|
for (const part of str(raw).split(",")) {
|
||||||
@@ -99,6 +114,17 @@ function configFromForm(formData: FormData): AntiddosConfig {
|
|||||||
defaults.globalHaltMs,
|
defaults.globalHaltMs,
|
||||||
),
|
),
|
||||||
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
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<void> {
|
|||||||
],
|
],
|
||||||
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
||||||
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
["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(
|
await Promise.all(
|
||||||
entries.map(([key, value]) =>
|
entries.map(([key, value]) =>
|
||||||
@@ -230,6 +259,19 @@ export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
|||||||
revalidatePath("/admin/devops/antiddos");
|
revalidatePath("/admin/devops/antiddos");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test the configured CrowdSec API credentials against the CTI endpoint. */
|
||||||
|
export async function verifyCrowdsecConfiguration(): Promise<void> {
|
||||||
|
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. */
|
/** Test the configured Cloudflare API credentials against the zone. */
|
||||||
export async function verifyCloudflareConfiguration(): Promise<void> {
|
export async function verifyCloudflareConfiguration(): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
|||||||
@@ -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 { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +14,7 @@ import {
|
|||||||
saveAntiddosSettings,
|
saveAntiddosSettings,
|
||||||
unbanAntiddosIp,
|
unbanAntiddosIp,
|
||||||
verifyCloudflareConfiguration,
|
verifyCloudflareConfiguration,
|
||||||
|
verifyCrowdsecConfiguration,
|
||||||
} from "@/actions/admin-antiddos";
|
} from "@/actions/admin-antiddos";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -24,6 +32,11 @@ import {
|
|||||||
listCloudflareBlocks,
|
listCloudflareBlocks,
|
||||||
sweepExpiredCloudflareBlocks,
|
sweepExpiredCloudflareBlocks,
|
||||||
} from "@/lib/cloudflare-api";
|
} from "@/lib/cloudflare-api";
|
||||||
|
import {
|
||||||
|
CROWDSEC_BLOCK_SOURCE,
|
||||||
|
crowdsecEnabled,
|
||||||
|
getLastCrowdsecVerify,
|
||||||
|
} from "@/lib/crowdsec-api";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
@@ -58,7 +71,12 @@ export default async function AdminAntiDdosPage() {
|
|||||||
const sourceHeader = preferredClientIpHeader(requestHeaders);
|
const sourceHeader = preferredClientIpHeader(requestHeaders);
|
||||||
const viewerIp = resolveClientIp(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;
|
let redisOk = false;
|
||||||
const rateStore = redis;
|
const rateStore = redis;
|
||||||
if (rateStore) {
|
if (rateStore) {
|
||||||
@@ -78,11 +96,19 @@ export default async function AdminAntiDdosPage() {
|
|||||||
}
|
}
|
||||||
const withTtl = await Promise.all(
|
const withTtl = await Promise.all(
|
||||||
blockKeys.slice(0, 100).map(async (key) => {
|
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 {
|
return {
|
||||||
ip: key.replace("antiddos:block:", ""),
|
ip: key.replace("antiddos:block:", ""),
|
||||||
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
||||||
count: violationCounts.get(key.replace("antiddos:block:", "")) ?? 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()));
|
cloudflareBlocks.push(...(await listCloudflareBlocks()));
|
||||||
}
|
}
|
||||||
const lastVerify = await getLastCloudflareVerify();
|
const lastVerify = await getLastCloudflareVerify();
|
||||||
|
const crowdsecConfigured = crowdsecEnabled();
|
||||||
|
const lastCrowdsecVerify = await getLastCrowdsecVerify();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Gate</CardTitle>
|
<CardTitle className="text-sm font-medium">Gate</CardTitle>
|
||||||
@@ -157,6 +185,21 @@ export default async function AdminAntiDdosPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">CrowdSec</CardTitle>
|
||||||
|
<Radar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant={crowdsecConfigured ? "default" : "secondary"}>
|
||||||
|
{crowdsecConfigured ? "Connected" : "Not configured"}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Community reputation auto-block
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Active blocks</CardTitle>
|
<CardTitle className="text-sm font-medium">Active blocks</CardTitle>
|
||||||
@@ -254,6 +297,53 @@ export default async function AdminAntiDdosPage() {
|
|||||||
block.
|
block.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="cs_auto_block"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={effective.crowdsecAutoBlock}
|
||||||
|
/>
|
||||||
|
Automatically block IPs flagged as malicious by the CrowdSec
|
||||||
|
community
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
Requires <span className="font-mono">CROWDSEC_API_KEY</span> 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.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Minimum reputation score (0–5)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="cs_block_score"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={5}
|
||||||
|
defaultValue={effective.crowdsecBlockScore}
|
||||||
|
className="w-24 mt-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
4–5 = malicious (CrowdSec scale)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Block duration (sec)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="cs_block_ttl_sec"
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective.crowdsecBlockTtlSeconds}
|
||||||
|
className="w-32 mt-1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -401,7 +491,12 @@ export default async function AdminAntiDdosPage() {
|
|||||||
className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm"
|
className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-mono">{b.ip}</span>
|
<span className="font-mono">{b.ip}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{b.source === "crowdsec" ? (
|
||||||
|
<Badge variant="default">CrowdSec</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Gate</Badge>
|
||||||
|
)}
|
||||||
TTL {seconds(b.ttlMs)} · violations {b.count}
|
TTL {seconds(b.ttlMs)} · violations {b.count}
|
||||||
</span>
|
</span>
|
||||||
<form action={unbanAntiddosIp}>
|
<form action={unbanAntiddosIp}>
|
||||||
@@ -495,6 +590,64 @@ export default async function AdminAntiDdosPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Radar className="h-4 w-4" /> CrowdSec reputation API
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant={crowdsecConfigured ? "default" : "secondary"}>
|
||||||
|
{crowdsecConfigured ? "API configured" : "API not configured"}
|
||||||
|
</Badge>
|
||||||
|
{!crowdsecConfigured && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set <span className="font-mono">CROWDSEC_API_KEY</span> 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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form action={verifyCrowdsecConfiguration}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!crowdsecConfigured}
|
||||||
|
>
|
||||||
|
Verify connection
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastCrowdsecVerify && crowdsecConfigured && (
|
||||||
|
<p className="text-xs">
|
||||||
|
<Badge
|
||||||
|
variant={lastCrowdsecVerify.ok ? "default" : "destructive"}
|
||||||
|
>
|
||||||
|
{lastCrowdsecVerify.ok ? "Reachable" : "Failed"}
|
||||||
|
</Badge>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{lastCrowdsecVerify.ok
|
||||||
|
? `CTI endpoint verified ${new Date(lastCrowdsecVerify.at).toLocaleString()}`
|
||||||
|
: lastCrowdsecVerify.message}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crowdsecConfigured && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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{" "}
|
||||||
|
<Badge variant="default">CrowdSec</Badge> badge in the list above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{stored.size === 0 && (
|
{stored.size === 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Persisted site settings: none yet — the form values above reflect the
|
Persisted site settings: none yet — the form values above reflect the
|
||||||
|
|||||||
+29
@@ -148,6 +148,35 @@ const schema = z
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value !== "false" && value !== "0"),
|
.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) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.NODE_ENV !== "production") return;
|
if (data.NODE_ENV !== "production") return;
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface AntiddosConfig {
|
|||||||
blockTiers: AntiddosBlockTier[];
|
blockTiers: AntiddosBlockTier[];
|
||||||
globalHaltMs: number;
|
globalHaltMs: number;
|
||||||
cloudflareAutoBlock: boolean;
|
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 = {
|
const DEFAULT_CONFIG: AntiddosConfig = {
|
||||||
@@ -41,6 +46,9 @@ const DEFAULT_CONFIG: AntiddosConfig = {
|
|||||||
],
|
],
|
||||||
globalHaltMs: 10_000,
|
globalHaltMs: 10_000,
|
||||||
cloudflareAutoBlock: true,
|
cloudflareAutoBlock: true,
|
||||||
|
crowdsecAutoBlock: true,
|
||||||
|
crowdsecBlockScore: 4,
|
||||||
|
crowdsecBlockTtlSeconds: 86_400,
|
||||||
};
|
};
|
||||||
|
|
||||||
function positiveInt(value: number | undefined, fallback: number): number {
|
function positiveInt(value: number | undefined, fallback: number): number {
|
||||||
@@ -49,6 +57,17 @@ function positiveInt(value: number | undefined, fallback: number): number {
|
|||||||
return Math.floor(n);
|
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 {
|
function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null {
|
||||||
if (!raw?.trim()) return null;
|
if (!raw?.trim()) return null;
|
||||||
const tiers: AntiddosBlockTier[] = [];
|
const tiers: AntiddosBlockTier[] = [];
|
||||||
@@ -125,6 +144,17 @@ export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
|||||||
DEFAULT_CONFIG.globalHaltMs,
|
DEFAULT_CONFIG.globalHaltMs,
|
||||||
),
|
),
|
||||||
cloudflareAutoBlock: isTruthyFlag(env.CLOUDFLARE_AUTO_BLOCK_ENABLED),
|
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,
|
: base.blockTiers,
|
||||||
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
||||||
cloudflareAutoBlock: config?.cloudflareAutoBlock !== false,
|
cloudflareAutoBlock: config?.cloudflareAutoBlock !== false,
|
||||||
|
crowdsecAutoBlock: config?.crowdsecAutoBlock !== false,
|
||||||
|
crowdsecBlockScore: clampInt(
|
||||||
|
config?.crowdsecBlockScore,
|
||||||
|
base.crowdsecBlockScore,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
crowdsecBlockTtlSeconds: positiveInt(
|
||||||
|
config?.crowdsecBlockTtlSeconds,
|
||||||
|
base.crowdsecBlockTtlSeconds,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string>() }));
|
||||||
|
|
||||||
|
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> = {}): 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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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<string, string>)["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<typeof redis>, "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<string, string>)[
|
||||||
|
"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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Response> {
|
||||||
|
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<string> {
|
||||||
|
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<string, CrowdsecVerdict>();
|
||||||
|
|
||||||
|
function verdictKey(ip: string): string {
|
||||||
|
return `${VERDICT_PREFIX}${ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readVerdictCache(ip: string): Promise<CrowdsecVerdict | null> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<CrowdsecVerdict | null> {
|
||||||
|
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<void> {
|
||||||
|
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<CrowdsecConnectionStatus> {
|
||||||
|
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<CrowdsecConnectionStatus | null> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, string>() }));
|
||||||
|
|
||||||
|
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<number> {
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { getAntiddosConfig } from "@/lib/antiddos-config";
|
|||||||
import { resolveClientIp } from "@/lib/client-ip";
|
import { resolveClientIp } from "@/lib/client-ip";
|
||||||
import { isCloudflareProxied } from "@/lib/cloudflare";
|
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||||
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||||
|
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
|
||||||
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
@@ -124,6 +125,20 @@ export async function enforceDdosRateLimit(
|
|||||||
config.cloudflareAutoBlock && isCloudflareProxied(req.headers),
|
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 };
|
return { outcome: "block", retryAfterSeconds: ttl };
|
||||||
} catch {
|
} catch {
|
||||||
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
||||||
|
|||||||
Reference in new issue
Block a user