feat(security): opt-in local CrowdSec LAPI bouncer on the Docker engine
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m37s
CI / tests-integration (push) Successful in 1m55s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m37s
CI / tests-integration (push) Successful in 1m55s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s
This commit is contained in:
1 parent
3e1a3f92c8
commit
84d53139a9
15 files changed
+1002
-2
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkCrowdsecLocalBlock,
|
||||
isBlockingCrowdsecDecision,
|
||||
parseCrowdsecDecisionDuration,
|
||||
resetCrowdsecLocalCache,
|
||||
} from "@/lib/crowdsec-local";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
map: new Map<string, string>(),
|
||||
sendAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/alert", () => ({
|
||||
sendAlert: state.sendAlert,
|
||||
ddosDetected: vi.fn(),
|
||||
}));
|
||||
|
||||
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;
|
||||
},
|
||||
expire: async () => 1,
|
||||
pexpire: async () => 1,
|
||||
pttl: async () => 60_000,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const IP = "198.51.100.11";
|
||||
const LAPI_URL = "http://127.0.0.1:18080";
|
||||
|
||||
describe("crowdsec-local app-layer bouncer", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecLocalCache();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
|
||||
vi.stubEnv("CROWDSEC_LAPI_URL", LAPI_URL);
|
||||
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "test-local-key");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecLocalCache();
|
||||
});
|
||||
|
||||
it("does nothing when the local stack is not enabled", async () => {
|
||||
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "false");
|
||||
const result = await checkCrowdsecLocalBlock(IP);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing without a bouncer key", async () => {
|
||||
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "");
|
||||
const result = await checkCrowdsecLocalBlock(IP);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an IP with a local ban decision and caches it", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{
|
||||
origin: "crowdsec",
|
||||
type: "ban",
|
||||
scope: "ip",
|
||||
value: IP,
|
||||
duration: "4h",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(true);
|
||||
expect(first.retryAfterSeconds).toBeGreaterThan(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain(
|
||||
`/v1/decisions?ip=${IP}`,
|
||||
);
|
||||
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats captcha decisions as blocks", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([{ type: "captcha", scope: "ip", value: IP }]),
|
||||
);
|
||||
const result = await checkCrowdsecLocalBlock(IP);
|
||||
expect(result.blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("passes non-blocking decisions and caches the negative", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([{ type: "probation", scope: "ip", value: IP }]),
|
||||
);
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(false);
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails open when LAPI errors and backs off", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("connection refused"));
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("backs off for five minutes when the bouncer key is rejected", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "forbidden" }, 403));
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(false);
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not query the LAPI for the unknown-IP sentinel", async () => {
|
||||
const result = await checkCrowdsecLocalBlock("0.0.0.0");
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("crowdsec-local decision parsing", () => {
|
||||
it("parses Go-style durations into seconds", () => {
|
||||
expect(parseCrowdsecDecisionDuration("3h51m57s")).toBe(
|
||||
3 * 3_600 + 51 * 60 + 57,
|
||||
);
|
||||
expect(parseCrowdsecDecisionDuration("500ms")).toBeCloseTo(0.5);
|
||||
expect(parseCrowdsecDecisionDuration("")).toBe(0);
|
||||
expect(parseCrowdsecDecisionDuration(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("recognises only ban/captcha ip/range decisions", () => {
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "ban", scope: "ip", value: IP }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "ban", scope: "range", value: IP }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "captcha", scope: "ip", value: IP }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "probation", scope: "ip", value: IP }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "ban", scope: "as", value: IP }),
|
||||
).toBe(false);
|
||||
expect(isBlockingCrowdsecDecision(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user