feat(security): auto-block repeat offenders via CrowdSec community reputation
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Failing after 17s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Failing after 17s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- new crowdsec-api lib: CTI lookup (GET /smoke/{ip}, freemium x-api-key), verdict parser with false-positive veto, 1h Redis + in-memory verdict cache, NX lock dedupe, 403/429 backoff; writes only the shared antiddos:block:{ip} key (value "crowdsec") and never touches Cloudflare
- gate fires it fire-and-forget for IPs that already tripped a rate bucket, so known-bad IPs are hard-blocked before the local maxViolations threshold
- runtime config: crowdsecAutoBlock toggle, score threshold (0-5, default 4), block TTL (default 24h); boot defaults CROWDSEC_AUTO_BLOCK_ENABLED / CROWDSEC_BLOCK_SCORE / CROWDSEC_BLOCK_TTL_SECONDS
- admin panel: CrowdSec stat card, verify-connection action, score/TTL settings, CrowdSec source badge in the blocked-IPs list
- credentials live in env only (CROWDSEC_API_KEY); block is enforced per-request via proxy on the resolved X-Forwarded-For / CF-Connecting-IP
- tests: crowdsec-api unit suite + ddos-guard integration suite (early-block, threshold, cache dedupe, backoff)
This commit is contained in:
1 parent
64edb81ab7
commit
f32a6dadd0
9 files changed
+1414
-5
No files matched your search
@@ -0,0 +1,422 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type CrowdsecVerdict,
|
||||
crowdsecEnabled,
|
||||
getCrowdsecApiConfig,
|
||||
getLastCrowdsecVerify,
|
||||
maybeAutoBlockCrowdsec,
|
||||
resetCrowdsecCache,
|
||||
setLastCrowdsecVerify,
|
||||
verdictIsMalicious,
|
||||
verifyCrowdsecConnection,
|
||||
} from "./crowdsec-api";
|
||||
|
||||
// Unit-test the CTI client in isolation: a deterministic in-memory Redis fake
|
||||
// and a silenced logger, so fetch calls count only CrowdSec lookups. CrowdSec
|
||||
// deliberately never touches Cloudflare, so no Cloudflare surface is stubbed.
|
||||
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;
|
||||
},
|
||||
pttl: async () => 60_000,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function maliciousItem(ip: string, score = 5): unknown {
|
||||
return {
|
||||
ip,
|
||||
reputation: "malicious",
|
||||
confidence: "0.95",
|
||||
scores: { overall: { aggressiveness: 4, total: score } },
|
||||
behaviors: [{ name: "http:bruteforce" }, { name: "http:scan" }],
|
||||
classifications: { false_positives: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function suspiciousItem(ip: string, score: number): unknown {
|
||||
return {
|
||||
ip,
|
||||
reputation: "suspicious",
|
||||
scores: { overall: { total: score } },
|
||||
};
|
||||
}
|
||||
|
||||
function verdict(minimal: Partial<CrowdsecVerdict> = {}): CrowdsecVerdict {
|
||||
return {
|
||||
ip: "198.51.100.1",
|
||||
reputation: "suspicious",
|
||||
score: 3,
|
||||
aggressiveness: 0,
|
||||
confidence: null,
|
||||
behaviors: [],
|
||||
falsePositive: false,
|
||||
checkedAt: Date.now(),
|
||||
...minimal,
|
||||
};
|
||||
}
|
||||
|
||||
function blockIp(): string {
|
||||
return "198.51.100.1";
|
||||
}
|
||||
|
||||
describe("crowdsec-api", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecCache();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("is enabled only when a non-blank API key is configured", () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_BASE_URL", "https://cti.example.test");
|
||||
expect(crowdsecEnabled()).toBe(true);
|
||||
expect(getCrowdsecApiConfig().apiKey).toBe("cs_key");
|
||||
expect(getCrowdsecApiConfig().baseUrl).toBe("https://cti.example.test");
|
||||
|
||||
vi.stubEnv("CROWDSEC_API_KEY", " ");
|
||||
expect(crowdsecEnabled()).toBe(false);
|
||||
vi.stubEnv("CROWDSEC_CTI_BASE_URL", "");
|
||||
expect(getCrowdsecApiConfig().baseUrl).toBe(
|
||||
"https://cti.api.crowdsec.net/v2",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks malicious reputations at any threshold", () => {
|
||||
expect(
|
||||
verdictIsMalicious(verdict({ reputation: "malicious", score: 0 }), 5),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("never blocks safe or benign reputations", () => {
|
||||
expect(verdictIsMalicious(verdict({ reputation: "safe" }), 0)).toBe(false);
|
||||
expect(verdictIsMalicious(verdict({ reputation: "benign" }), 1)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("vetoes a false-positive tag even for a malicious reputation", () => {
|
||||
expect(
|
||||
verdictIsMalicious(
|
||||
verdict({ reputation: "malicious", score: 5, falsePositive: true }),
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the score threshold to suspicious/known attackers", () => {
|
||||
const v4 = verdict({ reputation: "suspicious", score: 4 });
|
||||
expect(verdictIsMalicious(v4, 4)).toBe(true);
|
||||
expect(verdictIsMalicious(v4, 5)).toBe(false);
|
||||
expect(verdictIsMalicious(verdict({ score: 3 }), 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("never blocks score-0 (unknown) IPs even at threshold 0", () => {
|
||||
expect(
|
||||
verdictIsMalicious(verdict({ score: 0, reputation: "unknown" }), 0),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing without an API key", async () => {
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the runtime toggle is off", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: false,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never consults CrowdSec for the unknown-IP sentinel", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "0.0.0.0",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hard-blocks a malicious IP in the shared gate key only", 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,
|
||||
});
|
||||
|
||||
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec");
|
||||
// No Cloudflare keys may ever be written by CrowdSec.
|
||||
expect(
|
||||
[...state.map.keys()].some((key) => key.includes("cloudflare")),
|
||||
).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain(`/smoke/${blockIp()}`);
|
||||
expect((init.headers as Record<string, string>)["x-api-key"]).toBe(
|
||||
"cs_key",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block an IP the community knows nothing about", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, 404));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects a custom score threshold", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3)));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
||||
|
||||
fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3)));
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 3,
|
||||
enabled: true,
|
||||
});
|
||||
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec");
|
||||
});
|
||||
|
||||
it("dedupes concurrent lookups into a single API call", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
await Promise.all([
|
||||
maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
}),
|
||||
maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
}),
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reuses the Redis verdict cache for repeat offenders", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never shortens an existing longer host block", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
state.map.set(`antiddos:block:${blockIp()}`, "1");
|
||||
const redis = (await import("@/lib/redis")).redis;
|
||||
vi.spyOn(redis as NonNullable<typeof redis>, "pttl").mockImplementation(
|
||||
async () => 86_400_000,
|
||||
);
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("1");
|
||||
});
|
||||
|
||||
it("backs off after a 403 so it stops hammering a rejected key", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "203.0.113.9",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("backs off after a 429 rate limit as well", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "rate limited" }, 429));
|
||||
|
||||
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(1);
|
||||
});
|
||||
|
||||
it("swallows API failures instead of throwing on the hot path", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500));
|
||||
|
||||
await expect(
|
||||
maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a missing credential without calling the API", async () => {
|
||||
const status = await verifyCrowdsecConnection();
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.message).toContain("CROWDSEC_API_KEY");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies the key against the CTI probe endpoint", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ip: "1.1.1.1", reputation: "safe" }),
|
||||
);
|
||||
|
||||
const status = await verifyCrowdsecConnection();
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.message).toContain("1.1.1.1");
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain("/smoke/1.1.1.1");
|
||||
expect(
|
||||
(fetchMock.mock.calls[0][1].headers as Record<string, string>)[
|
||||
"x-api-key"
|
||||
],
|
||||
).toBe("cs_key");
|
||||
});
|
||||
|
||||
it("surfaces a rejected credential", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
||||
|
||||
const status = await verifyCrowdsecConnection();
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.message).toContain("Invalid key");
|
||||
});
|
||||
|
||||
it("round-trips the last verify status through Redis and memory", async () => {
|
||||
const status = { ok: true, message: "CTI key accepted", at: Date.now() };
|
||||
await setLastCrowdsecVerify(status);
|
||||
expect(await getLastCrowdsecVerify()).toEqual(status);
|
||||
expect(JSON.parse(state.map.get("crowdsec:last-verify") ?? "{}")).toEqual(
|
||||
status,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user