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

- 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:
openhands committed 2026-09-23 13:03:19 +02:00
1 parent 64edb81ab7
commit f32a6dadd0
9 files changed
+1414 -5

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
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 { enforceDdosRateLimit } from "@/lib/ddos-guard";
// The gate's block escalation (and thus the CrowdSec hook) only runs when
// Redis is reachable, so the integration test drives a small in-memory fake.
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;
},
incr: async (key: string) => {
const next = (Number(state.map.get(key)) || 0) + 1;
state.map.set(key, String(next));
return next;
},
pexpire: async () => 1,
pttl: async () => 60_000,
sadd: async (key: string, member: string) => {
const members = new Set(
(state.map.get(key) ?? "").split("\u0001").filter(Boolean),
);
members.add(member);
state.map.set(key, [...members].join("\u0001"));
return 1;
},
srem: async (key: string, member: string) => {
const members = new Set(
(state.map.get(key) ?? "").split("\u0001").filter(Boolean),
);
const before = members.size;
members.delete(member);
state.map.set(key, [...members].join("\u0001"));
return before - members.size;
},
smembers: async (key: string) =>
(state.map.get(key) ?? "").split("\u0001").filter(Boolean),
},
__esModule: true,
}));
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function proxiedRequest(ip: string): NextRequest {
return new NextRequest("https://hotel.test/api/balance", {
headers: { "cf-ray": "abc-AMS", "cf-connecting-ip": ip },
});
}
function directRequest(ip: string): NextRequest {
return new NextRequest("https://hotel.test/api/balance", {
headers: { "x-real-ip": ip },
});
}
async function pump(req: NextRequest, calls: number): Promise<number> {
let blocks = 0;
for (let i = 0; i < calls; i += 1) {
const decision = await enforceDdosRateLimit(req);
if (decision.outcome === "block") blocks += 1;
}
return blocks;
}
const apiLimit = "3";
const maxViolations = "2";
const crowdsecKey = "test-cs-key";
describe("anti-DDoS automatic CrowdSec blocks", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
state.map.clear();
resetCrowdsecCache();
invalidateAntiddosConfig();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("ANTI_DDOS_ENABLED", "true");
vi.stubEnv("ANTI_DDOS_API_LIMIT", apiLimit);
vi.stubEnv("ANTI_DDOS_MAX_VIOLATIONS", maxViolations);
vi.stubEnv("ANTI_DDOS_VIOLATION_WINDOW_SEC", "60");
vi.stubEnv("CROWDSEC_API_KEY", crowdsecKey);
vi.stubEnv("CLOUDFLARE_API_TOKEN", "");
vi.stubEnv("CLOUDFLARE_ZONE_ID", "");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
state.map.clear();
resetCrowdsecCache();
invalidateAntiddosConfig();
});
it("blocks a community-flagged offender before the local threshold", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
ip: "198.51.100.71",
reputation: "malicious",
confidence: "0.9",
scores: { overall: { total: 5 } },
classifications: { false_positives: [] },
}),
);
const ip = "198.51.100.71";
// The first three requests pass inside the API bucket; the fourth trips
// it, which is where the gate consults CrowdSec (never on the hot path).
const firstFour = await pump(proxiedRequest(ip), 4);
expect(firstFour).toBe(1);
// Let the fire-and-forget lookup + block write settle.
await new Promise((resolve) => setTimeout(resolve, 50));
// The community block is already in the shared gate key after a single
// violation — far below the gate's own 2-violation hard-block threshold...
expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec");
// ...so every subsequent request is shed immediately via the block check.
const blocks = await pump(proxiedRequest(ip), 4);
expect(blocks).toBe(4);
});
it("leaves a community-safe offender to the ordinary gate logic", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
ip: "198.51.100.72",
reputation: "safe",
scores: { overall: { total: 0 } },
classifications: { false_positives: [] },
}),
);
const ip = "198.51.100.72";
// With a 3-request API limit and 2 allowed violations, exactly the
// second repeat request trips the ordinary hard block — value "1",
// never "crowdsec".
const blocks = await pump(proxiedRequest(ip), 5);
expect(blocks).toBe(2);
expect(state.map.get(`antiddos:block:${ip}`)).toBe("1");
});
it("never auto-blocks traffic without the API key", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "");
await pump(proxiedRequest("198.51.100.73"), 5);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).not.toHaveBeenCalled();
});
it("respects the runtime CrowdSec toggle from the config", async () => {
vi.stubEnv("CROWDSEC_AUTO_BLOCK_ENABLED", "false");
invalidateAntiddosConfig();
await pump(proxiedRequest("198.51.100.74"), 5);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).not.toHaveBeenCalled();
});
it("keeps gate decisions unchanged when the CrowdSec API fails", async () => {
fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500));
const ip = "198.51.100.75";
const blocks = await pump(proxiedRequest(ip), 5);
expect(blocks).toBe(2);
expect(state.map.get(`antiddos:block:${ip}`)).toBe("1");
});
it("uses the configured score threshold for ambiguous verdicts", async () => {
vi.stubEnv("CROWDSEC_BLOCK_SCORE", "3");
invalidateAntiddosConfig();
fetchMock.mockResolvedValue(
jsonResponse({
ip: "198.51.100.76",
reputation: "suspicious",
scores: { overall: { total: 3 } },
classifications: { false_positives: [] },
}),
);
const ip = "198.51.100.76";
await pump(proxiedRequest(ip), 4);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec");
});
it("never auto-blocks the unknown-IP sentinel", async () => {
await pump(directRequest("0.0.0.0"), 1);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).not.toHaveBeenCalled();
});
});