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