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)
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

+41
View File
@@ -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,
),
};
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+15
View File
@@ -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.