feat(security): mirror anti-DDoS blocks to Cloudflare edge via API
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Failing after 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Failing after 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- gate creates a zone IP Access Rule (block) for proxied offenders that hit the block threshold, deduped until the tiered block expires - cloudflare-api lib: verified endpoints, create/delete/verify/list helpers, Redis-backed tracking + 30s TTL sweep (instrumentation worker + admin render) - runtime toggle cloudflareAutoBlock in antiddos config; boot default CLOUDFLARE_AUTO_BLOCK_ENABLED - admin panel: Cloudflare edge-blocks card with verify + remove-rule actions; unban also lifts the edge block - credentials live in env only (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID)
This commit is contained in:
1 parent
f0c27eb815
commit
4479753160
9 files changed
+1157
-1
No files matched your search
@@ -0,0 +1,193 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { invalidateAntiddosConfig } from "@/lib/antiddos-config";
|
||||
import { resetCloudflareAutoBlockCache } from "@/lib/cloudflare-api";
|
||||
import { enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||
|
||||
// The gate's block escalation (and thus the auto-block 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";
|
||||
|
||||
describe("anti-DDoS automatic Cloudflare blocks", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCloudflareAutoBlockCache();
|
||||
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("CLOUDFLARE_API_TOKEN", "test-api-token");
|
||||
vi.stubEnv("CLOUDFLARE_ZONE_ID", "z123");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCloudflareAutoBlockCache();
|
||||
invalidateAntiddosConfig();
|
||||
});
|
||||
|
||||
it("creates an edge block for a proxied offender at the block threshold", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, errors: [], result: { id: "rule-a" } }),
|
||||
);
|
||||
|
||||
const ip = "198.51.100.77";
|
||||
const blocks = await pump(proxiedRequest(ip), 5);
|
||||
expect(blocks).toBe(2);
|
||||
|
||||
// Post-fire-and-forget settles before asserting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(body.configuration.value).toBe(ip);
|
||||
expect(body.notes).toContain("category=api");
|
||||
});
|
||||
|
||||
it("does not re-create the edge block on subsequent hits", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, errors: [], result: { id: "rule-b" } }),
|
||||
);
|
||||
|
||||
const ip = "198.51.100.78";
|
||||
await pump(proxiedRequest(ip), 12);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never auto-blocks traffic that did not transit Cloudflare", async () => {
|
||||
await pump(directRequest("198.51.100.79"), 5);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects the runtime auto-block toggle from the config", async () => {
|
||||
vi.stubEnv("CLOUDFLARE_AUTO_BLOCK_ENABLED", "false");
|
||||
invalidateAntiddosConfig();
|
||||
|
||||
await pump(proxiedRequest("198.51.100.80"), 5);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays silent when the Cloudflare credentials are not configured", async () => {
|
||||
vi.stubEnv("CLOUDFLARE_API_TOKEN", "");
|
||||
vi.stubEnv("CLOUDFLARE_ZONE_ID", "");
|
||||
|
||||
await pump(proxiedRequest("198.51.100.81"), 5);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps gate decisions unchanged when the Cloudflare API fails", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [{ code: 9109, message: "quota" }],
|
||||
result: null,
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
|
||||
const ip = "198.51.100.82";
|
||||
const blocks = await pump(proxiedRequest(ip), 5);
|
||||
expect(blocks).toBe(2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user