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
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:
1 parent
ee25545b7f
commit
5e4fc9ab59
8 files changed
+1323
-37
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user