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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user