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

This commit is contained in:
openhands committed 2026-09-24 18:08:18 +02:00
1 parent 3e1a3f92c8
commit 84d53139a9
15 files changed
+1002 -2

No files matched your search

+25
View File
@@ -197,6 +197,23 @@ const schema = z
.string()
.url()
.default("https://api.crowdsec.net/v3"),
// Local CrowdSec engine shipped as an opt-in Docker stack in
// deployment/crowdsec. When enabled, the anti-DDoS gate asks the local
// LAPI (bouncer) for each client IP before its own buckets and blocks
// ban/captcha decisions immediately. The key lives in env only.
CROWDSEC_LOCAL_ENABLED: z
.string()
.optional()
.transform((value) => value === "true" || value === "1"),
CROWDSEC_LAPI_URL: z
.string()
.optional()
.transform((value) =>
value?.trim() ? value.trim() : "http://127.0.0.1:18080",
)
.pipe(z.string().url()),
CROWDSEC_LAPI_API_KEY: z.string().optional(),
CROWDSEC_LAPI_TIMEOUT_MS: z.coerce.number().int().positive().default(500),
// Watcher credentials for signal push. When omitted, a stable pair is
// generated once and persisted in Redis (48-char alnum machine id,
// per the CAPI schema).
@@ -232,6 +249,14 @@ const schema = z
path: ["PAYPAL_CLIENT_ID"],
});
}
if (data.CROWDSEC_LOCAL_ENABLED && !data.CROWDSEC_LAPI_API_KEY) {
ctx.addIssue({
code: "custom",
message:
"CROWDSEC_LAPI_API_KEY is required when CROWDSEC_LOCAL_ENABLED=true",
path: ["CROWDSEC_LAPI_API_KEY"],
});
}
});
type Env = z.infer<typeof schema>;
+195
View File
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
checkCrowdsecLocalBlock,
isBlockingCrowdsecDecision,
parseCrowdsecDecisionDuration,
resetCrowdsecLocalCache,
} from "@/lib/crowdsec-local";
const state = vi.hoisted(() => ({
map: new Map<string, string>(),
sendAlert: vi.fn(),
}));
vi.mock("@/lib/services/alert", () => ({
sendAlert: state.sendAlert,
ddosDetected: vi.fn(),
}));
vi.mock("@/lib/redis", () => ({
redis: {
get: async (key: string) => state.map.get(key) ?? null,
set: async (
key: string,
value: string,
_mode?: string,
_seconds?: number,
nx?: string,
) => {
if (nx === "NX" && state.map.has(key)) return null;
state.map.set(key, value);
return "OK";
},
del: async (...keys: string[]) => {
for (const key of keys) state.map.delete(key);
return keys.length;
},
incr: async (key: string) => {
const next = (Number(state.map.get(key)) || 0) + 1;
state.map.set(key, String(next));
return next;
},
expire: async () => 1,
pexpire: async () => 1,
pttl: async () => 60_000,
},
__esModule: true,
}));
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
const IP = "198.51.100.11";
const LAPI_URL = "http://127.0.0.1:18080";
describe("crowdsec-local app-layer bouncer", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
state.map.clear();
resetCrowdsecLocalCache();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
vi.stubEnv("CROWDSEC_LAPI_URL", LAPI_URL);
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "test-local-key");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
state.map.clear();
resetCrowdsecLocalCache();
});
it("does nothing when the local stack is not enabled", async () => {
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "false");
const result = await checkCrowdsecLocalBlock(IP);
expect(result.blocked).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does nothing without a bouncer key", async () => {
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "");
const result = await checkCrowdsecLocalBlock(IP);
expect(result.blocked).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks an IP with a local ban decision and caches it", async () => {
fetchMock.mockResolvedValue(
jsonResponse([
{
origin: "crowdsec",
type: "ban",
scope: "ip",
value: IP,
duration: "4h",
},
]),
);
const first = await checkCrowdsecLocalBlock(IP);
expect(first.blocked).toBe(true);
expect(first.retryAfterSeconds).toBeGreaterThan(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toContain(
`/v1/decisions?ip=${IP}`,
);
const second = await checkCrowdsecLocalBlock(IP);
expect(second.blocked).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("treats captcha decisions as blocks", async () => {
fetchMock.mockResolvedValue(
jsonResponse([{ type: "captcha", scope: "ip", value: IP }]),
);
const result = await checkCrowdsecLocalBlock(IP);
expect(result.blocked).toBe(true);
});
it("passes non-blocking decisions and caches the negative", async () => {
fetchMock.mockResolvedValue(
jsonResponse([{ type: "probation", scope: "ip", value: IP }]),
);
const first = await checkCrowdsecLocalBlock(IP);
expect(first.blocked).toBe(false);
const second = await checkCrowdsecLocalBlock(IP);
expect(second.blocked).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("fails open when LAPI errors and backs off", async () => {
fetchMock.mockRejectedValueOnce(new Error("connection refused"));
const first = await checkCrowdsecLocalBlock(IP);
expect(first.blocked).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 5));
const second = await checkCrowdsecLocalBlock(IP);
expect(second.blocked).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("backs off for five minutes when the bouncer key is rejected", async () => {
fetchMock.mockResolvedValue(jsonResponse({ message: "forbidden" }, 403));
const first = await checkCrowdsecLocalBlock(IP);
expect(first.blocked).toBe(false);
const second = await checkCrowdsecLocalBlock(IP);
expect(second.blocked).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not query the LAPI for the unknown-IP sentinel", async () => {
const result = await checkCrowdsecLocalBlock("0.0.0.0");
expect(result.blocked).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("crowdsec-local decision parsing", () => {
it("parses Go-style durations into seconds", () => {
expect(parseCrowdsecDecisionDuration("3h51m57s")).toBe(
3 * 3_600 + 51 * 60 + 57,
);
expect(parseCrowdsecDecisionDuration("500ms")).toBeCloseTo(0.5);
expect(parseCrowdsecDecisionDuration("")).toBe(0);
expect(parseCrowdsecDecisionDuration(null)).toBe(0);
});
it("recognises only ban/captcha ip/range decisions", () => {
expect(
isBlockingCrowdsecDecision({ type: "ban", scope: "ip", value: IP }),
).toBe(true);
expect(
isBlockingCrowdsecDecision({ type: "ban", scope: "range", value: IP }),
).toBe(true);
expect(
isBlockingCrowdsecDecision({ type: "captcha", scope: "ip", value: IP }),
).toBe(true);
expect(
isBlockingCrowdsecDecision({ type: "probation", scope: "ip", value: IP }),
).toBe(false);
expect(
isBlockingCrowdsecDecision({ type: "ban", scope: "as", value: IP }),
).toBe(false);
expect(isBlockingCrowdsecDecision(null)).toBe(false);
});
});
+304
View File
@@ -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;
}
+28
View File
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { invalidateAntiddosConfig } from "@/lib/antiddos-config";
import { resetCrowdsecCache } from "@/lib/crowdsec-api";
import { resetCrowdsecLocalCache } from "@/lib/crowdsec-local";
import { enforceDdosRateLimit } from "@/lib/ddos-guard";
// The gate's block escalation (and thus the CrowdSec hook) only runs when
@@ -123,6 +124,7 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
state.z.clear();
state.sendAlert.mockReset();
resetCrowdsecCache();
resetCrowdsecLocalCache();
invalidateAntiddosConfig();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
@@ -141,6 +143,7 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
vi.unstubAllEnvs();
state.map.clear();
resetCrowdsecCache();
resetCrowdsecLocalCache();
invalidateAntiddosConfig();
});
@@ -244,4 +247,29 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => {
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080");
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "local-bouncer-key");
fetchMock.mockImplementation(async (input) => {
if (String(input).startsWith("http://127.0.0.1:18080/")) {
return jsonResponse([
{
origin: "crowdsec",
type: "ban",
scope: "ip",
value: "198.51.100.88",
duration: "1h",
},
]);
}
return jsonResponse({ message: "unexpected upstream" }, 500);
});
const ip = "198.51.100.88";
const blocks = await pump(proxiedRequest(ip), 1);
expect(blocks).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+9
View File
@@ -8,6 +8,7 @@ import { resolveClientIp } from "@/lib/client-ip";
import { isCloudflareProxied } from "@/lib/cloudflare";
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
import { checkCrowdsecLocalBlock } from "@/lib/crowdsec-local";
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
import {
bumpCrowdsecBreakdownStat,
@@ -84,6 +85,14 @@ export async function enforceDdosRateLimit(
}
}
const localBlock = await checkCrowdsecLocalBlock(ip);
if (localBlock.blocked) {
return {
outcome: "block",
retryAfterSeconds: Math.max(localBlock.retryAfterSeconds, 1),
};
}
const global = await rateLimit(
"antiddos:global:all",
config.global.limit,