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

+106
View File
@@ -3,7 +3,11 @@ import {
type CrowdsecVerdict,
crowdsecEnabled,
getCrowdsecApiConfig,
getCrowdsecBlockMeta,
getCrowdsecQuotaUsage,
getLastCrowdsecVerify,
getMemoryVerdictCacheSize,
lookupCrowdsecVerdict,
maybeAutoBlockCrowdsec,
resetCrowdsecCache,
setLastCrowdsecVerify,
@@ -34,6 +38,12 @@ vi.mock("@/lib/redis", () => ({
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,
pttl: async () => 60_000,
},
__esModule: true,
@@ -44,6 +54,7 @@ vi.mock("@/lib/logger", () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
@@ -419,4 +430,99 @@ describe("crowdsec-api", () => {
status,
);
});
it("records why it blocked an IP next to the gate key", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
await maybeAutoBlockCrowdsec({
ip: blockIp(),
category: "api",
ttlSeconds: 86_400,
scoreThreshold: 4,
enabled: true,
});
const meta = await getCrowdsecBlockMeta(blockIp());
expect(meta).not.toBeNull();
expect(meta?.source).toBe("crowdsec");
expect(meta?.reputation).toBe("malicious");
expect(meta?.score).toBe(5);
expect(meta?.behaviors).toEqual(["http:bruteforce", "http:scan"]);
expect(meta?.category).toBe("api");
expect(meta?.ttlSeconds).toBe(86_400);
expect(meta?.blockedAt).toBeGreaterThan(0);
});
it("stops consulting the API once today's quota is spent", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "2");
// Fresh Response per call — a consumed body must never be re-parsed.
fetchMock.mockImplementation(() =>
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
);
await maybeAutoBlockCrowdsec({
ip: blockIp(),
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
await maybeAutoBlockCrowdsec({
ip: "198.51.100.2",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(state.map.get(`antiddos:block:198.51.100.2`)).toBe("crowdsec");
// Third bucket-tripping IP arrives after the quota counter hit 2.
await maybeAutoBlockCrowdsec({
ip: "198.51.100.3",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(state.map.has(`antiddos:block:198.51.100.3`)).toBe(false);
const usage = await getCrowdsecQuotaUsage();
expect(usage.quota).toBe(2);
expect(usage.used).toBe(2);
expect(usage.exhausted).toBe(true);
});
it("exposes today's quota usage for the admin panel", async () => {
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "10000");
const before = await getCrowdsecQuotaUsage();
expect(before.quota).toBe(10000);
expect(before.used).toBe(0);
expect(before.exhausted).toBe(false);
expect(before.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
state.map.set(`crowdsec:usage:${before.date}`, "9876");
const after = await getCrowdsecQuotaUsage();
expect(after.used).toBe(9876);
});
it("caps the in-process verdict cache so it cannot grow forever", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
fetchMock.mockImplementation((url: string | URL) =>
Promise.resolve(
jsonResponse(maliciousItem(String(url).split("/").pop() ?? "ip")),
),
);
// One lookup per distinct IP (never cached before), exceeding the cap —
// the oldest entries are evicted first, so the cache stays bounded.
for (let i = 0; i < 2100; i += 1) {
await lookupCrowdsecVerdict(`198.51.100.${i}`);
}
expect(getMemoryVerdictCacheSize()).toBe(2000);
});
});