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
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:
1 parent
ee25545b7f
commit
5e4fc9ab59
8 files changed
+1323
-37
No files matched your search
+178
-7
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user