feat(security): opt-in local CrowdSec LAPI bouncer on the Docker engine
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
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
This commit is contained in:
1 parent
3e1a3f92c8
commit
84d53139a9
15 files changed
+1002
-2
No files matched your search
@@ -0,0 +1,304 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user