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

+280
View File
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CrowdsecVerdict } from "./crowdsec-api";
import {
type CrowdsecReportStatus,
crowdsecReportEnabled,
getLastCrowdsecReport,
reportCrowdsecSignal,
resetCrowdsecReportCache,
verifyCrowdsecReporting,
} from "./crowdsec-report";
// The signal-push watcher is tested against a deterministic in-memory Redis
// fake (NX lock + token cache) and a mocked fetch that routes the CAPI paths.
const state = vi.hoisted(() => ({ map: new Map<string, string>() }));
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;
},
},
__esModule: true,
}));
vi.mock("@/lib/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const CAPI = "https://capi.example.test/v3";
const MACHINE = "m".repeat(48);
const PASSWORD = "Strong!1P@ssw0rdStrong!1P@ssw0rd";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function signalInput(ip = "198.51.100.9") {
const verdict: CrowdsecVerdict = {
ip,
reputation: "malicious",
score: 5,
aggressiveness: 4,
confidence: "0.95",
behaviors: ["http:bruteforce", "http:scan"],
falsePositive: false,
checkedAt: Date.now(),
};
return {
ip,
category: "api",
ttlSeconds: 86_400,
verdict,
meta: {
source: "crowdsec" as const,
category: "api",
reputation: verdict.reputation,
score: verdict.score,
behaviors: verdict.behaviors,
ttlSeconds: 86_400,
blockedAt: Date.now(),
},
};
}
describe("crowdsec-report", () => {
let fetchMock: ReturnType<typeof vi.fn>;
function routeCapi(overrides: Record<string, number> = {}) {
const statusFor = (path: string) =>
overrides[path] ?? (path === "/signals" ? 200 : 200);
fetchMock.mockImplementation((url: string) => {
const path = String(url).replace(CAPI, "");
const status = statusFor(path);
if (status !== 200) {
return Promise.resolve(jsonResponse({ message: "boom" }, status));
}
if (path === "/watchers/login") {
return Promise.resolve(
jsonResponse({
token: "jwt-xyz",
expire: new Date(Date.now() + 3_600_000).toISOString(),
}),
);
}
return Promise.resolve(jsonResponse({}));
});
}
beforeEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
state.map.clear();
resetCrowdsecReportCache();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true");
vi.stubEnv("CROWDSEC_REPORT_MACHINE_ID", MACHINE);
vi.stubEnv("CROWDSEC_REPORT_PASSWORD", PASSWORD);
vi.stubEnv("CROWDSEC_CAPI_BASE_URL", CAPI);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
state.map.clear();
resetCrowdsecReportCache();
vi.restoreAllMocks();
});
it("is enabled only when the toggle and credentials are present", async () => {
expect(await crowdsecReportEnabled()).toBe(true);
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
resetCrowdsecReportCache();
expect(await crowdsecReportEnabled()).toBe(false);
// Machine id supplied but no password: falls back to generating a
// stable credential pair persisted in Redis.
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true");
vi.stubEnv("CROWDSEC_REPORT_PASSWORD", "");
resetCrowdsecReportCache();
expect(await crowdsecReportEnabled()).toBe(true);
const storedMachine = state.map.get("crowdsec:report:machine");
expect(storedMachine).toMatch(/^[A-Za-z0-9]{48}$/);
expect(state.map.get("crowdsec:report:pass")).toBeTruthy();
});
it("does nothing when the channel is disabled", async () => {
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
resetCrowdsecReportCache();
await reportCrowdsecSignal(signalInput());
expect(fetchMock).not.toHaveBeenCalled();
});
it("registers once, caches the token and pushes one signal per IP", async () => {
routeCapi();
await reportCrowdsecSignal(signalInput("198.51.100.10"));
await reportCrowdsecSignal(signalInput("198.51.100.11"));
await reportCrowdsecSignal(signalInput("198.51.100.10"));
// Let the fire-and-forget network body land.
await new Promise((resolve) => setTimeout(resolve, 20));
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
expect(urls.filter((u) => u.endsWith("/watchers/register"))).toHaveLength(
1,
);
expect(urls.filter((u) => u.endsWith("/watchers/login"))).toHaveLength(1);
expect(urls.filter((u) => u.endsWith("/signals"))).toHaveLength(2);
// No enrollment requested without an attachment key.
expect(urls.some((u) => u.endsWith("/watchers/enroll"))).toBe(false);
});
it("builds a well-formed CrowdSec signal with a ban decision", async () => {
routeCapi();
await reportCrowdsecSignal(signalInput());
await new Promise((resolve) => setTimeout(resolve, 20));
const signalsCall = fetchMock.mock.calls.find((call) =>
String(call[0]).endsWith("/signals"),
);
expect(signalsCall).toBeDefined();
if (!signalsCall) throw new Error("expected a /signals call");
const init = signalsCall[1] as {
body: string;
headers: Record<string, string>;
};
const body = JSON.parse(init.body) as Record<string, unknown>[];
expect(body).toHaveLength(1);
const signal = body[0] as {
machine_id: string;
scenario: string;
scenario_version: string;
source: { scope: string; value: string; ip: string };
decisions: {
scope: string;
type: string;
value: string;
duration: string;
}[];
context: { key: string; value: string }[];
created_at: string;
start_at: string;
stop_at: string;
};
expect(signal.machine_id).toBe(MACHINE);
expect(signal.scenario).toBe("community/anti-ddos-block");
expect(signal.scenario_version).toBe("1.0.0");
expect(signal.source).toEqual({
scope: "ip",
value: "198.51.100.9",
ip: "198.51.100.9",
});
expect(signal.decisions).toHaveLength(1);
expect(signal.decisions[0]).toMatchObject({
origin: "crowdsec",
scope: "ip",
type: "ban",
value: "198.51.100.9",
});
expect(String(signal.decisions[0].duration)).toMatch(/^24h0m0s$/);
for (const key of ["created_at", "start_at", "stop_at"] as const) {
expect(typeof signal[key]).toBe("string");
}
expect(
signal.context.find((c) => c.key === "crowdsec_reputation")?.value,
).toBe("malicious");
});
it("records a healthy last-report state after a successful push", async () => {
routeCapi();
await reportCrowdsecSignal(signalInput());
await new Promise((resolve) => setTimeout(resolve, 20));
const last = await getLastCrowdsecReport();
expect(last?.ok).toBe(true);
});
it("never throws and logs the failure when the CAPI rejects the signal", async () => {
fetchMock.mockImplementation((url: string) => {
const path = String(url).replace(CAPI, "");
if (path === "/watchers/login") {
return Promise.resolve(
jsonResponse({
token: "jwt-xyz",
expire: new Date(Date.now() + 3_600_000).toISOString(),
}),
);
}
if (path === "/signals") {
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
}
return Promise.resolve(jsonResponse({}));
});
await expect(reportCrowdsecSignal(signalInput())).resolves.toBeUndefined();
await new Promise((resolve) => setTimeout(resolve, 20));
const last: CrowdsecReportStatus | null = await getLastCrowdsecReport();
expect(last?.ok).toBe(false);
expect(last?.message).toContain("signal push rejected");
});
it("verifies the watcher channel end to end", async () => {
routeCapi();
const status = await verifyCrowdsecReporting();
expect(status.ok).toBe(true);
expect(String(fetchMock.mock.calls[0][0])).toContain("/watchers/register");
});
it("reports a clear reason when verification is impossible", async () => {
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
resetCrowdsecReportCache();
const status = await verifyCrowdsecReporting();
expect(status.ok).toBe(false);
expect(status.message).toContain("CROWDSEC_REPORT_ENABLED");
expect(fetchMock).not.toHaveBeenCalled();
});
});