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

- 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:
openhands committed 2026-09-23 13:03:19 +02:00
1 parent 64edb81ab7
commit f32a6dadd0
9 files changed
+1414 -5

No files matched your search

+158 -5
View File
@@ -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 (
<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>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Gate</CardTitle>
@@ -157,6 +185,21 @@ export default async function AdminAntiDdosPage() {
</CardContent>
</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>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active blocks</CardTitle>
@@ -254,6 +297,53 @@ export default async function AdminAntiDdosPage() {
block.
</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">
{(
[
@@ -401,7 +491,12 @@ export default async function AdminAntiDdosPage() {
className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm"
>
<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}
</span>
<form action={unbanAntiddosIp}>
@@ -495,6 +590,64 @@ export default async function AdminAntiDdosPage() {
</CardContent>
</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 && (
<p className="text-xs text-muted-foreground">
Persisted site settings: none yet — the form values above reflect the