feat(security): give back to CrowdSec and harden the CTI budget
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m34s
CI / tests-unit (push) Successful in 1m36s
CI / tests-ui (push) Successful in 2m22s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m53s

- Bound the in-process verdict cache (FIFO eviction at 2000 entries) so a
  flood of distinct bucket-tripping IPs cannot grow it without limit.
- Record block metadata (reputation, score, behaviors, category, TTL) in
  antiddos:block:meta:{ip}, surfaced as the reason in the admin block list;
  unban now also clears the metadata and report locks.
- Track daily CTI enrichment usage in Redis (crowdsec:usage:{date}); warn
  once at 80% and pause lookups until tomorrow at CROWDSEC_CTI_DAILY_QUOTA
  (default 10000, 0 = unlimited) so a via-spread DDoS cannot burn the plan.
- Add opt-in signal push to the CrowdSec community (CAPI watcher): stable
  auto-generated 48-char machine_id/password pair persisted in Redis (or via
  env), one-time registration, cached JWT login, optional Console enrollment,
  and POST /v3/signals with a ban decision, deduped per IP. Never throws and
  reports last status to the admin panel with a verify action.
- Admin page: quota usage bar, reporting status/verify channel, and CrowdSec
  block reasons in the active-blocks list.
This commit is contained in:
openhands committed 2026-09-23 14:24:44 +02:00
1 parent ee25545b7f
commit 5e4fc9ab59
8 files changed
+1323 -37

No files matched your search

+178 -7
View File
@@ -1,6 +1,7 @@
import "server-only";
import { env } from "@/env";
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
import { UNKNOWN_CLIENT_IP } from "./client-ip";
@@ -28,9 +29,15 @@ import { UNKNOWN_CLIENT_IP } from "./client-ip";
* 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.
* Quota guard: every enrichment call counts against a per-day Redis counter so
* a spread DDoS (many distinct IPs tripping buckets) can exhaust the day's
* freemium quota only until the configured ceiling, after which lookups pause
* until tomorrow instead of hammering a 429 wall.
*
* Sharing detections back: after a block is created this module fires the
* signal push in `@/lib/crowdsec-report` (Central API watcher login + POST
* /signals), strictly opt-in via CROWDSEC_REPORT_ENABLED and always
* fire-and-forget.
*/
export class CrowdsecApiError extends Error {}
@@ -70,6 +77,27 @@ export interface CrowdsecConnectionStatus {
at: number;
}
/** Why a CrowdSec-sourced block exists — persisted next to the block key. */
export interface CrowdsecBlockMeta {
source: typeof CROWDSEC_BLOCK_SOURCE;
category: string;
reputation: CrowdsecReputation | null;
score: number;
behaviors: string[];
ttlSeconds: number;
blockedAt: number;
}
/** Daily CTI usage counter as shown in the admin panel. */
export interface CrowdsecQuotaUsage {
/** UTC calendar day the counter belongs to (YYYY-MM-DD). */
date: string;
used: number;
/** 0 = unlimited. */
quota: number;
exhausted: boolean;
}
/** Value written into the shared block key so the admin UI can label the source. */
export const CROWDSEC_BLOCK_SOURCE = "crowdsec";
@@ -82,6 +110,13 @@ const AUTH_BACKOFF_MS = 300_000;
const VERDICT_PREFIX = "crowdsec:cti:";
const LOOKUP_LOCK_PREFIX = "crowdsec:lock:";
const LAST_VERIFY_KEY = "crowdsec:last-verify";
const BLOCK_META_PREFIX = "antiddos:block:meta:";
const QUOTA_PREFIX = "crowdsec:usage:";
const QUOTA_KEY_TTL_SECONDS = 48 * 3_600;
/** In-process verdict cache cap so a flood of distinct IPs cannot grow it forever. */
const MEMORY_VERDICT_CACHE_MAX = 2_000;
/** Warn at this fraction of the daily quota, once per day. */
const QUOTA_WARN_RATIO = 0.8;
/** Well-known, community-safe address used by the admin "verify" button. */
const PROBE_IP = "1.1.1.1";
@@ -197,6 +232,26 @@ export function verdictIsMalicious(
const memoryVerdicts = new Map<string, CrowdsecVerdict>();
/**
* Insert/refresh an in-process verdict while keeping the cache bounded: Map
* iteration order is insertion order, so the oldest (leftmost) entry is
* dropped first and re-inserted entries are refreshed to the back.
*/
function rememberVerdict(verdict: CrowdsecVerdict): void {
memoryVerdicts.delete(verdict.ip);
memoryVerdicts.set(verdict.ip, verdict);
while (memoryVerdicts.size > MEMORY_VERDICT_CACHE_MAX) {
const oldest = memoryVerdicts.keys().next();
if (oldest.done) break;
memoryVerdicts.delete(oldest.value);
}
}
/** Test hook only — reports the bounded in-process cache size. */
export function getMemoryVerdictCacheSize(): number {
return memoryVerdicts.size;
}
function verdictKey(ip: string): string {
return `${VERDICT_PREFIX}${ip}`;
}
@@ -211,7 +266,7 @@ async function readVerdictCache(ip: string): Promise<CrowdsecVerdict | null> {
const raw = await redis.get(verdictKey(ip));
if (raw) {
const parsed = JSON.parse(raw) as CrowdsecVerdict;
memoryVerdicts.set(ip, parsed);
rememberVerdict(parsed);
return parsed;
}
} catch {
@@ -222,7 +277,7 @@ async function readVerdictCache(ip: string): Promise<CrowdsecVerdict | null> {
}
async function writeVerdictCache(verdict: CrowdsecVerdict): Promise<void> {
memoryVerdicts.set(verdict.ip, verdict);
rememberVerdict(verdict);
if (redis) {
try {
await redis.set(
@@ -256,11 +311,79 @@ async function acquireLookupLock(ip: string): Promise<boolean> {
}
let backoffUntil = 0;
let quotaWarnedDate: string | null = null;
function quotaDate(): string {
return new Date().toISOString().slice(0, 10);
}
function quotaKey(date: string): string {
return `${QUOTA_PREFIX}${date}`;
}
/**
* Today's configured ceiling. Coerced because tests run with
* SKIP_ENV_VALIDATION (raw process.env strings, no zod defaults) while
* production gets a parsed number. 0 (or unset) = unlimited.
*/
function dailyQuota(): number {
const raw = Number(env.CROWDSEC_CTI_DAILY_QUOTA ?? 0);
return Number.isFinite(raw) && raw > 0 ? raw : 0;
}
/**
* Today's CTI usage against the configured daily ceiling. Counted in Redis so
* every instance shares one budget.
*/
export async function getCrowdsecQuotaUsage(): Promise<CrowdsecQuotaUsage> {
const date = quotaDate();
const quota = dailyQuota();
let used = 0;
if (redis) {
try {
used = Number((await redis.get(quotaKey(date))) ?? 0);
if (!Number.isFinite(used)) used = 0;
} catch {
// counter unavailable — report zero rather than blocking the admin
}
}
return { date, used, quota, exhausted: quota > 0 && used >= quota };
}
async function reserveQuota(): Promise<boolean> {
const quota = dailyQuota();
if (quota <= 0) return true;
const date = quotaDate();
const usage = await getCrowdsecQuotaUsage();
if (usage.used >= quota) {
// Stop consulting the API for the rest of the day: a flood of distinct
// bucket-tripping IPs would otherwise burn every remaining call and
// then sit in a 429 storm anyway.
return false;
}
if (usage.used >= quota * QUOTA_WARN_RATIO && quotaWarnedDate !== date) {
quotaWarnedDate = date;
logger.warn("[crowdsec-api] CTI daily quota nearing its limit", {
used: usage.used,
quota,
});
}
if (redis) {
try {
await redis.incr(quotaKey(date));
await redis.expire(quotaKey(date), QUOTA_KEY_TTL_SECONDS);
} catch {
// best effort — an uncounted call is better than a failed lookup
}
}
return true;
}
/**
* 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.
* null when the API is not configured, the lookup failed, the API is in
* backoff, or today's quota is spent — never throws, so it is safe on the
* gate's hot path.
*/
export async function lookupCrowdsecVerdict(
ip: string,
@@ -284,6 +407,15 @@ export async function lookupCrowdsecVerdict(
const raced = await readVerdictCache(ip);
if (raced) return raced;
// Cache miss costs a paid call — reserve against today's quota first.
if (!(await reserveQuota())) {
logger.warn(
"[crowdsec-api] CTI daily quota exhausted — pausing lookups until tomorrow",
{ quota: dailyQuota() },
);
return null;
}
const response = await crowdsecRequest(`/smoke/${encodeURIComponent(ip)}`);
if (response.status === 404) {
@@ -358,6 +490,27 @@ export async function maybeAutoBlockCrowdsec(input: {
if (existingTtl >= ttlSeconds * 1000) return;
await redis.set(blockKey, CROWDSEC_BLOCK_SOURCE, "EX", ttlSeconds);
// Record why this block exists so the admin panel can surface the
// community reasoning (reputation, score, behaviors) for the IP.
const meta: CrowdsecBlockMeta = {
source: CROWDSEC_BLOCK_SOURCE,
category,
reputation: verdict.reputation,
score: verdict.score,
behaviors: verdict.behaviors,
ttlSeconds,
blockedAt: Date.now(),
};
try {
await redis.set(
`${BLOCK_META_PREFIX}${ip}`,
JSON.stringify(meta),
"EX",
ttlSeconds,
);
} catch {
// metadata is display sugar only — the block itself is already set.
}
// 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.
@@ -372,6 +525,9 @@ export async function maybeAutoBlockCrowdsec(input: {
behaviors: verdict.behaviors,
},
);
// Opt-in community signal push (CAPI), fire-and-forget: never awaited,
// never throws, and internally deduped per IP.
void reportCrowdsecSignal({ ip, category, ttlSeconds, verdict, meta });
} catch (error) {
logger.error("[crowdsec-api] Automatic IP block failed", {
ip,
@@ -461,9 +617,24 @@ export async function setLastCrowdsecVerify(
}
}
/** Why an IP is blocked by CrowdSec, when known. */
export async function getCrowdsecBlockMeta(
ip: string,
): Promise<CrowdsecBlockMeta | null> {
if (!redis) return null;
try {
const raw = await redis.get(`${BLOCK_META_PREFIX}${ip}`);
if (!raw) return null;
return JSON.parse(raw) as CrowdsecBlockMeta;
} catch {
return null;
}
}
/** Test hook only — drop in-memory state between unit runs. */
export function resetCrowdsecCache(): void {
memoryVerdicts.clear();
backoffUntil = 0;
quotaWarnedDate = null;
lastVerifyMemory = null;
}