Files
EpicNext-Cms/src/lib/crowdsec-local.ts
T
openhands 84d53139a9
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m37s
CI / tests-integration (push) Successful in 1m55s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s
feat(security): opt-in local CrowdSec LAPI bouncer on the Docker engine
2026-09-24 18:08:18 +02:00

305 lines
7.6 KiB
TypeScript

import "server-only";
import { env } from "@/env";
import {
bumpCrowdsecBreakdownStat,
bumpCrowdsecStat,
} from "@/lib/crowdsec-stats";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
import { UNKNOWN_CLIENT_IP } from "./client-ip";
export interface CrowdsecLocalDecision {
origin?: string;
scope?: string;
type?: string;
value?: string;
duration?: string | null;
}
export interface CrowdsecLocalBlockResult {
blocked: boolean;
retryAfterSeconds: number;
}
const DEFAULT_LAPI_URL = "http://127.0.0.1:18080";
const REQUEST_TIMEOUT_MS = 500;
const NEGATIVE_CACHE_TTL_MS = 2_000;
const DECISION_CACHE_TTL_MS = 300_000;
const DECISION_RETRY_MAX_SECONDS = 300;
const FALLBACK_RETRY_SECONDS = 60;
const BACKOFF_MS = 5_000;
const AUTH_BACKOFF_MS = 300_000;
const MEMORY_CACHE_MAX = 5_000;
const CACHE_PREFIX = "crowdsec:local:";
const BACKOFF_KEY = "crowdsec:local:backoff-until";
const DURATION_TOKEN = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g;
export function parseCrowdsecDecisionDuration(
value: string | null | undefined,
): number {
if (!value) return 0;
let total = 0;
for (const match of value.matchAll(DURATION_TOKEN)) {
const amount = Number(match[1]);
if (!Number.isFinite(amount)) continue;
const unit = match[2];
if (unit === "h") total += amount * 3_600;
else if (unit === "m") total += amount * 60;
else if (unit === "s") total += amount;
else if (unit === "ms") total += amount / 1_000;
else if (unit === "us" || unit === "µs") total += amount / 1_000_000;
else if (unit === "ns") total += amount / 1_000_000_000;
}
return total;
}
export function isBlockingCrowdsecDecision(
decision: CrowdsecLocalDecision | null | undefined,
): boolean {
if (!decision) return false;
const type = decision.type?.toLowerCase();
const scope = decision.scope?.toLowerCase();
if ((type !== "ban" && type !== "captcha") || !decision.value) return false;
return scope === "ip" || scope === "range";
}
function localEnabled(): boolean {
const flag: unknown = env.CROWDSEC_LOCAL_ENABLED;
return flag === true || flag === "true" || flag === "1";
}
function localConfig(): {
url: string;
apiKey: string;
timeoutMs: number;
} {
return {
url: (env.CROWDSEC_LAPI_URL || DEFAULT_LAPI_URL).replace(/\/+$/, ""),
apiKey: String(env.CROWDSEC_LAPI_API_KEY ?? "").trim(),
timeoutMs:
Number(env.CROWDSEC_LAPI_TIMEOUT_MS) > 0
? Number(env.CROWDSEC_LAPI_TIMEOUT_MS)
: REQUEST_TIMEOUT_MS,
};
}
interface CacheEntry {
blocked: boolean;
retryAfterSeconds: number;
until: number;
}
const memoryCache = new Map<string, CacheEntry>();
let backoffUntil = 0;
let authBackoffWarned = false;
async function rememberCache(
ip: string,
entry: CacheEntry,
ttlMs: number,
): Promise<void> {
memoryCache.delete(ip);
memoryCache.set(ip, entry);
while (memoryCache.size > MEMORY_CACHE_MAX) {
const oldest = memoryCache.keys().next();
if (oldest.done) break;
memoryCache.delete(oldest.value);
}
if (!redis) return;
try {
await redis.set(
`${CACHE_PREFIX}block:${ip}`,
JSON.stringify(entry),
"EX",
Math.max(1, Math.ceil(ttlMs / 1_000)),
);
} catch {
// Cache is best-effort — a miss only costs one extra LAPI call.
}
}
async function readCache(ip: string): Promise<CacheEntry | null> {
const memory = memoryCache.get(ip);
if (memory && memory.until > Date.now()) return memory;
if (redis) {
try {
const raw = await redis.get(`${CACHE_PREFIX}block:${ip}`);
if (raw) {
const parsed = JSON.parse(raw) as CacheEntry;
if (parsed.until > Date.now()) return parsed;
}
} catch {
// Redis hiccup — an extra local LAPI call is the only cost.
}
}
return null;
}
async function getBackoffUntil(): Promise<number> {
if (Date.now() < backoffUntil) return backoffUntil;
if (redis) {
try {
const raw = await redis.get(BACKOFF_KEY);
const shared = Number(raw ?? 0);
if (Number.isFinite(shared) && shared > backoffUntil) {
backoffUntil = shared;
}
} catch {
// Redis hiccup — the local view is enough.
}
}
return backoffUntil;
}
async function setBackoff(ms: number): Promise<void> {
const until = Date.now() + ms;
backoffUntil = until;
if (redis) {
try {
await redis.set(
BACKOFF_KEY,
String(until),
"EX",
Math.ceil(ms / 1_000) + 1,
);
} catch {
// Local view still protects this instance.
}
}
}
function decisionRetrySeconds(decision: CrowdsecLocalDecision | null): number {
const parsed = decision
? parseCrowdsecDecisionDuration(decision.duration)
: 0;
if (parsed <= 0) return FALLBACK_RETRY_SECONDS;
return Math.min(Math.max(1, Math.floor(parsed)), DECISION_RETRY_MAX_SECONDS);
}
async function queryLocalDecision(
baseUrl: string,
apiKey: string,
timeoutMs: number,
ip: string,
): Promise<CrowdsecLocalDecision | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(
`${baseUrl}/v1/decisions?ip=${encodeURIComponent(ip)}`,
{
headers: {
"X-Api-Key": apiKey,
Accept: "application/json",
},
signal: controller.signal,
cache: "no-store",
},
);
if (response.status === 401 || response.status === 403) {
await setBackoff(AUTH_BACKOFF_MS);
if (!authBackoffWarned) {
authBackoffWarned = true;
logger.warn(
"[crowdsec-local] LAPI rejected the bouncer key — local decisions paused for 5 minutes",
{ status: response.status },
);
}
return null;
}
if (!response.ok) {
await setBackoff(BACKOFF_MS);
logger.warn(
"[crowdsec-local] LAPI decision request failed — failing open",
{ ip, status: response.status },
);
return null;
}
const body = (await response.json()) as unknown;
if (!Array.isArray(body)) return null;
return body.find(isBlockingCrowdsecDecision) ?? null;
} catch (error) {
await setBackoff(BACKOFF_MS);
logger.warn(
"[crowdsec-local] LAPI decision request errored — failing open",
{
ip,
error: error instanceof Error ? error.message : String(error),
},
);
return null;
} finally {
clearTimeout(timer);
}
}
export async function checkCrowdsecLocalBlock(
ip: string,
): Promise<CrowdsecLocalBlockResult> {
if (!localEnabled()) return { blocked: false, retryAfterSeconds: 0 };
const config = localConfig();
if (!config.apiKey) return { blocked: false, retryAfterSeconds: 0 };
if (!ip || ip === UNKNOWN_CLIENT_IP) {
return { blocked: false, retryAfterSeconds: 0 };
}
if (Date.now() < (await getBackoffUntil())) {
return { blocked: false, retryAfterSeconds: 0 };
}
const cached = await readCache(ip);
if (cached && cached.until > Date.now()) {
return {
blocked: cached.blocked,
retryAfterSeconds: cached.blocked ? cached.retryAfterSeconds : 0,
};
}
const decision = await queryLocalDecision(
config.url,
config.apiKey,
config.timeoutMs,
ip,
);
if (decision) {
const retryAfterSeconds = decisionRetrySeconds(decision);
await rememberCache(
ip,
{
blocked: true,
retryAfterSeconds,
until: Date.now() + DECISION_CACHE_TTL_MS,
},
DECISION_CACHE_TTL_MS,
);
void bumpCrowdsecStat("blocks");
void bumpCrowdsecBreakdownStat("category", "local");
logger.info("[crowdsec-local] IP blocked by a local CrowdSec decision", {
ip,
type: decision.type,
retryAfterSeconds,
});
return { blocked: true, retryAfterSeconds };
}
await rememberCache(
ip,
{
blocked: false,
retryAfterSeconds: 0,
until: Date.now() + NEGATIVE_CACHE_TTL_MS,
},
NEGATIVE_CACHE_TTL_MS,
);
return { blocked: false, retryAfterSeconds: 0 };
}
export function resetCrowdsecLocalCache(): void {
memoryCache.clear();
backoffUntil = 0;
authBackoffWarned = false;
}