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

+469
View File
@@ -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;
}