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
196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
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);
|
|
});
|
|
});
|