Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m46s
CI / tests-ui (push) Successful in 2m35s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m40s
285 lines
8.6 KiB
TypeScript
285 lines
8.6 KiB
TypeScript
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 { resetCrowdsecLocalCache } from "@/lib/crowdsec-local";
|
|
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>(),
|
|
z: new Map<string, Array<[number, 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,
|
|
zadd: async (key: string, score: number, member: string) => {
|
|
const list = state.z.get(key) ?? [];
|
|
list.push([score, member]);
|
|
list.sort((a, b) => a[0] - b[0]);
|
|
state.z.set(key, list);
|
|
return 1;
|
|
},
|
|
zremrangebyscore: async (key: string, min: number, max: number) => {
|
|
const list = (state.z.get(key) ?? []).filter(
|
|
([score]) => score < min || score > max,
|
|
);
|
|
state.z.set(key, list);
|
|
return 1;
|
|
},
|
|
zcard: async (key: string) => (state.z.get(key) ?? []).length,
|
|
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();
|
|
state.z.clear();
|
|
state.sendAlert.mockReset();
|
|
resetCrowdsecCache();
|
|
resetCrowdsecLocalCache();
|
|
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();
|
|
resetCrowdsecLocalCache();
|
|
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();
|
|
});
|
|
|
|
it("passes the unknown-IP sentinel through even when a stale block key exists", async () => {
|
|
state.map.set("antiddos:block:0.0.0.0", "1");
|
|
|
|
const decision = await enforceDdosRateLimit(directRequest("0.0.0.0"));
|
|
|
|
expect(decision.outcome).toBe("pass");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => {
|
|
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
|
|
vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080");
|
|
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "local-bouncer-key");
|
|
fetchMock.mockImplementation(async (input) => {
|
|
if (String(input).startsWith("http://127.0.0.1:18080/")) {
|
|
return jsonResponse([
|
|
{
|
|
origin: "crowdsec",
|
|
type: "ban",
|
|
scope: "ip",
|
|
value: "198.51.100.88",
|
|
duration: "1h",
|
|
},
|
|
]);
|
|
}
|
|
return jsonResponse({ message: "unexpected upstream" }, 500);
|
|
});
|
|
|
|
const ip = "198.51.100.88";
|
|
const blocks = await pump(proxiedRequest(ip), 1);
|
|
expect(blocks).toBe(1);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|