diff --git a/src/actions/admin-antiddos.ts b/src/actions/admin-antiddos.ts index 7aa6eb87..72f56707 100644 --- a/src/actions/admin-antiddos.ts +++ b/src/actions/admin-antiddos.ts @@ -10,6 +10,11 @@ import { antiddosDefaultsFromEnv, invalidateAntiddosConfig, } from "@/lib/antiddos-config"; +import { + removeCloudflareBlock, + setLastCloudflareVerify, + verifyCloudflareConnection, +} from "@/lib/cloudflare-api"; import { db, WebsiteSetting } from "@/lib/db"; import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; @@ -93,6 +98,7 @@ function configFromForm(formData: FormData): AntiddosConfig { formData.get("global_halt_ms"), defaults.globalHaltMs, ), + cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1", }; } @@ -116,6 +122,7 @@ async function persistSettings(config: AntiddosConfig): Promise { .join(","), ], ["antiddos_global_halt_ms", String(config.globalHaltMs)], + ["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"], ]; await Promise.all( entries.map(([key, value]) => @@ -190,12 +197,49 @@ export async function unbanAntiddosIp(formData: FormData): Promise { redis.del(`antiddos:v:${ip}`), ]); } + // Also lift a matching Cloudflare edge block (best effort). + const cloudflare = await removeCloudflareBlock(ip); logger.info("Anti-DDoS block manually removed", { staff: staff.username, ip, + cloudflareCleared: cloudflare.removed, }); } catch (err) { logger.error("Failed to remove anti-DDoS block", { err, ip }); } revalidatePath("/admin/devops/antiddos"); } + +/** Remove an automatic Cloudflare edge block for a tracked IP. */ +export async function removeCloudflareRule(formData: FormData): Promise { + const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const ip = str(formData.get("ip")).trim(); + if (!ip) return; + + const result = await removeCloudflareBlock(ip); + logger.info( + result.removed + ? "Cloudflare automatic block removed" + : "Cloudflare automatic block removal skipped", + { + staff: staff.username, + ip, + message: result.message, + }, + ); + revalidatePath("/admin/devops/antiddos"); +} + +/** Test the configured Cloudflare API credentials against the zone. */ +export async function verifyCloudflareConfiguration(): Promise { + const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const status = await verifyCloudflareConnection(); + await setLastCloudflareVerify(status); + logger.info("Cloudflare API configuration verified", { + staff: staff.username, + ok: status.ok, + zoneName: status.zoneName, + message: status.message, + }); + revalidatePath("/admin/devops/antiddos"); +} diff --git a/src/app/admin/devops/antiddos/page.tsx b/src/app/admin/devops/antiddos/page.tsx index e031efa7..beaa1302 100644 --- a/src/app/admin/devops/antiddos/page.tsx +++ b/src/app/admin/devops/antiddos/page.tsx @@ -2,9 +2,11 @@ import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { + removeCloudflareRule, resetAntiddosSettings, saveAntiddosSettings, unbanAntiddosIp, + verifyCloudflareConfiguration, } from "@/actions/admin-antiddos"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -15,6 +17,13 @@ import { } from "@/lib/antiddos-config"; import { resolveClientIp } from "@/lib/client-ip"; import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare"; +import { + type CloudflareBlockView, + cloudflareEnabled, + getLastCloudflareVerify, + listCloudflareBlocks, + sweepExpiredCloudflareBlocks, +} from "@/lib/cloudflare-api"; import { db, WebsiteSetting } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { redis } from "@/lib/redis"; @@ -87,6 +96,14 @@ export default async function AdminAntiDdosPage() { const stored = persistedRows; + const cloudflareConfigured = cloudflareEnabled(); + const cloudflareBlocks: CloudflareBlockView[] = []; + if (cloudflareConfigured) { + await sweepExpiredCloudflareBlocks(); + cloudflareBlocks.push(...(await listCloudflareBlocks())); + } + const lastVerify = await getLastCloudflareVerify(); + return (
@@ -219,6 +236,24 @@ export default async function AdminAntiDdosPage() { Enable the app-layer anti-DDoS gate (production only) + +

+ Requires CLOUDFLARE_API_TOKEN{" "} + and CLOUDFLARE_ZONE_ID in the + environment. Blocks are only created for traffic that provably + transits Cloudflare, and expire together with the host-level + block. +

+
{( [ @@ -382,6 +417,84 @@ export default async function AdminAntiDdosPage() { + + + + Cloudflare edge blocks + + + +
+ + {cloudflareConfigured ? "API configured" : "API not configured"} + + {!cloudflareConfigured && ( +

+ Set CLOUDFLARE_API_TOKEN and{" "} + CLOUDFLARE_ZONE_ID to enable + automatic edge blocking via the Cloudflare API. +

+ )} +
+ +
+
+ + {lastVerify && cloudflareConfigured && ( +

+ + {lastVerify.ok ? "Reachable" : "Failed"} + + + {lastVerify.ok + ? `Zone ${lastVerify.zoneName ?? lastVerify.zoneId ?? ""} — verified ${new Date(lastVerify.at).toLocaleString()}` + : lastVerify.message} + +

+ )} + + {cloudflareConfigured && cloudflareBlocks.length === 0 ? ( +

+ No automatic Cloudflare blocks are active. When the gate blocks a + repeat offender behind Cloudflare, an IP Access Rule is created + here automatically. +

+ ) : ( + cloudflareConfigured && ( +
+ {cloudflareBlocks.map((b) => ( +
+ {b.ip} + + {b.category} · {seconds(b.remainingSeconds * 1000)} left + +
+ + +
+
+ ))} +

+ Expired rules are swept automatically every 30s. +

+
+ ) + )} +
+
+ {stored.size === 0 && (

Persisted site settings: none yet — the form values above reflect the diff --git a/src/env.ts b/src/env.ts index c1dd206e..66e45fc9 100644 --- a/src/env.ts +++ b/src/env.ts @@ -132,6 +132,22 @@ const schema = z // Logging level. LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(), APP_VERSION: z.string().optional(), + // Cloudflare API — optional. When the API token + zone id are set, the + // anti-DDoS gate can automatically mirror escalated IP blocks to the + // zone's IP Access Rules so attackers are dropped at the edge. The token + // lives in env only and is never persisted into Redis-visible config. + CLOUDFLARE_API_BASE_URL: z + .string() + .url() + .default("https://api.cloudflare.com/client/v4"), + CLOUDFLARE_API_TOKEN: z.string().optional(), + CLOUDFLARE_ZONE_ID: z.string().optional(), + // Boot default for the runtime "auto-create Cloudflare blocks" toggle + // (overridable via the admin panel / antiddos:config). + CLOUDFLARE_AUTO_BLOCK_ENABLED: z + .string() + .optional() + .transform((value) => value !== "false" && value !== "0"), }) .superRefine((data, ctx) => { if (data.NODE_ENV !== "production") return; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 546be733..5bdf6433 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -34,4 +34,9 @@ export async function register() { void drainFurnitureImports(); }, 30000); timer.unref(); + const { sweepExpiredCloudflareBlocks } = await import("@/lib/cloudflare-api"); + const cloudflareSweep = setInterval(() => { + void sweepExpiredCloudflareBlocks(); + }, 30000); + cloudflareSweep.unref(); } diff --git a/src/lib/antiddos-config.ts b/src/lib/antiddos-config.ts index 845f5b97..f0024e5f 100644 --- a/src/lib/antiddos-config.ts +++ b/src/lib/antiddos-config.ts @@ -23,6 +23,7 @@ export interface AntiddosConfig { maxViolations: number; blockTiers: AntiddosBlockTier[]; globalHaltMs: number; + cloudflareAutoBlock: boolean; } const DEFAULT_CONFIG: AntiddosConfig = { @@ -39,6 +40,7 @@ const DEFAULT_CONFIG: AntiddosConfig = { { minViolations: 50, ttlSeconds: 86_400 }, ], globalHaltMs: 10_000, + cloudflareAutoBlock: true, }; function positiveInt(value: number | undefined, fallback: number): number { @@ -65,11 +67,19 @@ function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null { return tiers; } +/** + * Gate on a boolean-flag env value that is either already transformed to a + * real boolean (production schema) or still a raw string (SKIP-env tests). + */ +function isTruthyFlag(value: string | boolean | undefined): boolean { + return !(value === false || value === "false" || value === "0"); +} + /** Boot defaults from environment (explicitly set → overrides code; unset → sane value). */ export function antiddosDefaultsFromEnv(): AntiddosConfig { const tiers = parseTiers(env.ANTI_DDOS_BLOCK_TIERS); return { - enabled: env.ANTI_DDOS_ENABLED !== false, + enabled: isTruthyFlag(env.ANTI_DDOS_ENABLED), pages: { limit: positiveInt(env.ANTI_DDOS_PAGES_LIMIT, DEFAULT_CONFIG.pages.limit), windowSeconds: positiveInt( @@ -114,6 +124,7 @@ export function antiddosDefaultsFromEnv(): AntiddosConfig { env.ANTI_DDOS_GLOBAL_HALT_MS, DEFAULT_CONFIG.globalHaltMs, ), + cloudflareAutoBlock: isTruthyFlag(env.CLOUDFLARE_AUTO_BLOCK_ENABLED), }; } @@ -155,6 +166,7 @@ function sanitize(config: AntiddosConfig): AntiddosConfig { .sort((a, b) => a.minViolations - b.minViolations) : base.blockTiers, globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs), + cloudflareAutoBlock: config?.cloudflareAutoBlock !== false, }; } diff --git a/src/lib/cloudflare-api.test.ts b/src/lib/cloudflare-api.test.ts new file mode 100644 index 00000000..65d52d10 --- /dev/null +++ b/src/lib/cloudflare-api.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cloudflareEnabled, + getCloudflareApiConfig, + listCloudflareBlocks, + maybeAutoBlockCloudflare, + removeCloudflareBlock, + resetCloudflareAutoBlockCache, + sweepExpiredCloudflareBlocks, + verifyCloudflareConnection, +} from "./cloudflare-api"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function envForRealSetup(): void { + vi.stubEnv("CLOUDFLARE_API_TOKEN", "test-api-token"); + vi.stubEnv("CLOUDFLARE_ZONE_ID", "z123"); +} + +describe("cloudflare-api", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + resetCloudflareAutoBlockCache(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + resetCloudflareAutoBlockCache(); + }); + + it("is configured only when a token and a zone id are present", () => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", "tok"); + expect(cloudflareEnabled()).toBe(false); + vi.stubEnv("CLOUDFLARE_ZONE_ID", "z1"); + expect(cloudflareEnabled()).toBe(true); + const config = getCloudflareApiConfig(); + expect(config.token).toBe("tok"); + expect(config.zoneId).toBe("z1"); + expect(config.baseUrl).toBe("https://api.cloudflare.com/client/v4"); + }); + + it("reports a missing credential without calling the API", async () => { + const status = await verifyCloudflareConnection(); + expect(status.ok).toBe(false); + expect(status.message).toContain("CLOUDFLARE_API_TOKEN"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("verifies the zone when the token is valid", async () => { + envForRealSetup(); + fetchMock.mockResolvedValue( + jsonResponse({ + success: true, + errors: [], + result: { id: "z123", name: "example.com" }, + }), + ); + + const status = await verifyCloudflareConnection(); + expect(status.ok).toBe(true); + expect(status.zoneName).toBe("example.com"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/zones/z123"); + expect(init.headers.Authorization).toBe("Bearer test-api-token"); + }); + + it("surfaces a rejected credential", async () => { + envForRealSetup(); + fetchMock.mockResolvedValue( + jsonResponse( + { + success: false, + errors: [{ code: 10000, message: "Invalid token" }], + result: null, + }, + 403, + ), + ); + + const status = await verifyCloudflareConnection(); + expect(status.ok).toBe(false); + expect(status.message).toContain("Invalid token"); + }); + + it("creates an IP Access Rule for a blocked client", async () => { + envForRealSetup(); + fetchMock.mockResolvedValue( + jsonResponse({ success: true, errors: [], result: { id: "rule1" } }), + ); + + await maybeAutoBlockCloudflare({ + ip: "192.0.2.55", + ttlSeconds: 600, + category: "api", + enabled: true, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/zones/z123/firewall/access_rules/rules"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body.mode).toBe("block"); + expect(body.configuration).toEqual({ target: "ip", value: "192.0.2.55" }); + expect(body.notes).toContain("category=api"); + expect(body.notes).toContain("ttl=600s"); + }); + + it("supports IPv6 client addresses", async () => { + envForRealSetup(); + fetchMock.mockResolvedValue( + jsonResponse({ success: true, errors: [], result: { id: "rule6" } }), + ); + + await maybeAutoBlockCloudflare({ + ip: "2001:db8::5", + ttlSeconds: 3600, + category: "auth", + enabled: true, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.configuration.value).toBe("2001:db8::5"); + }); + + it("dedupes until the block expires", async () => { + envForRealSetup(); + fetchMock.mockResolvedValue( + jsonResponse({ success: true, errors: [], result: { id: "rule1" } }), + ); + + for (let i = 0; i < 3; i += 1) { + await maybeAutoBlockCloudflare({ + ip: "198.51.100.1", + ttlSeconds: 600, + category: "api", + enabled: true, + }); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does nothing when the runtime toggle is off", async () => { + envForRealSetup(); + await maybeAutoBlockCloudflare({ + ip: "198.51.100.2", + ttlSeconds: 600, + category: "api", + enabled: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does nothing without credentials", async () => { + await maybeAutoBlockCloudflare({ + ip: "198.51.100.3", + ttlSeconds: 600, + category: "api", + enabled: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never auto-blocks the unknown-IP sentinel", async () => { + envForRealSetup(); + await maybeAutoBlockCloudflare({ + ip: "0.0.0.0", + ttlSeconds: 600, + category: "api", + enabled: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("swallows API failures instead of throwing on the hot path", async () => { + envForRealSetup(); + fetchMock.mockResolvedValue( + jsonResponse( + { + success: false, + errors: [{ code: 9109, message: "Not enough quota" }], + result: null, + }, + 400, + ), + ); + + await expect( + maybeAutoBlockCloudflare({ + ip: "198.51.100.4", + ttlSeconds: 600, + category: "api", + enabled: true, + }), + ).resolves.toBeUndefined(); + }); + + it("sweeps expired blocks and deletes their edge rules", async () => { + envForRealSetup(); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ success: true, errors: [], result: { id: "rule-x" } }), + ) + .mockResolvedValueOnce( + jsonResponse({ success: true, errors: [], result: {} }), + ); + + await maybeAutoBlockCloudflare({ + ip: "198.51.100.9", + ttlSeconds: 1, + category: "auth", + enabled: true, + }); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + + const removed = await sweepExpiredCloudflareBlocks(); + expect(removed).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1][0])).toContain( + "/firewall/access_rules/rules/rule-x", + ); + expect(fetchMock.mock.calls[1][1].method).toBe("DELETE"); + expect(await listCloudflareBlocks()).toHaveLength(0); + }); + + it("lists active blocks closest to expiry first", async () => { + envForRealSetup(); + fetchMock.mockResolvedValue( + jsonResponse({ success: true, errors: [], result: { id: "rule1" } }), + ); + + await maybeAutoBlockCloudflare({ + ip: "198.51.100.7", + ttlSeconds: 600, + category: "api", + enabled: true, + }); + const blocks = await listCloudflareBlocks(); + expect(blocks).toHaveLength(1); + expect(blocks[0].ip).toBe("198.51.100.7"); + expect(blocks[0].remainingSeconds).toBeGreaterThan(0); + expect(blocks[0].expired).toBe(false); + }); + + it("removes a tracked block through the admin action", async () => { + envForRealSetup(); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ success: true, errors: [], result: { id: "rule-y" } }), + ) + .mockResolvedValueOnce( + jsonResponse({ success: true, errors: [], result: {} }), + ); + + await maybeAutoBlockCloudflare({ + ip: "198.51.100.8", + ttlSeconds: 600, + category: "pages", + enabled: true, + }); + const first = await removeCloudflareBlock("198.51.100.8"); + expect(first.removed).toBe(true); + expect(String(fetchMock.mock.calls[1][0])).toContain("/rules/rule-y"); + + const second = await removeCloudflareBlock("198.51.100.8"); + expect(second.removed).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("keeps local tracking when the Cloudflare delete fails", async () => { + envForRealSetup(); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ success: true, errors: [], result: { id: "rule-z" } }), + ) + .mockResolvedValueOnce( + jsonResponse( + { + success: false, + errors: [{ code: 6003, message: "boom" }], + result: null, + }, + 400, + ), + ); + + await maybeAutoBlockCloudflare({ + ip: "198.51.100.6", + ttlSeconds: 600, + category: "api", + enabled: true, + }); + const result = await removeCloudflareBlock("198.51.100.6"); + expect(result.removed).toBe(false); + expect(result.message).toContain("boom"); + expect(await listCloudflareBlocks()).toHaveLength(1); + }); +}); diff --git a/src/lib/cloudflare-api.ts b/src/lib/cloudflare-api.ts new file mode 100644 index 00000000..82d5a31d --- /dev/null +++ b/src/lib/cloudflare-api.ts @@ -0,0 +1,449 @@ +import "server-only"; + +import { env } from "@/env"; +import { logger } from "@/lib/logger"; +import { redis } from "@/lib/redis"; +import { UNKNOWN_CLIENT_IP } from "./client-ip"; + +/** + * Cloudflare API integration for the anti-DDoS gate. + * + * When the gate escalates an IP into a host-level block it also mirrors the + * block to the zone's IP Access Rules (`firewall/access_rules/rules`) so + * repeat offenders are dropped at the Cloudflare edge. IP Access Rules are + * the classic per-IP firewall; they carry a `notes` string we use for + * tracking. The rules themselves have no TTL, so expiry is enforced here: + * each auto-created rule is recorded in Redis (meta + index) and the + * `sweepExpiredCloudflareBlocks` job (or the admin page) removes the rule + * once its block duration has passed. + * + * Credentials come from env only (`CLOUDFLARE_API_TOKEN`, + * `CLOUDFLARE_ZONE_ID`) and are never written into the admin-visible config. + */ + +export class CloudflareApiError extends Error {} + +export interface CloudflareApiConfig { + baseUrl: string; + token: string | null; + zoneId: string | null; +} + +export interface CloudflareConnectionStatus { + ok: boolean; + zoneId?: string; + zoneName?: string; + message?: string; + at: number; +} + +export interface CloudflareBlockMeta { + ruleId: string; + ip: string; + ttlSeconds: number; + createdAt: number; + category: string; +} + +export interface CloudflareBlockView extends CloudflareBlockMeta { + remainingSeconds: number; + expired: boolean; +} + +const API_TIMEOUT_MS = 15_000; +const META_PREFIX = "antiddos:cfa:"; +const INDEX_KEY = `${META_PREFIX}index`; +const LOCK_PREFIX = `${META_PREFIX}lock:`; +const LAST_VERIFY_KEY = `${META_PREFIX}last-verify`; +/** Marker written into the CF rule `notes` so we can identify our own rules. */ +export const CLOUDFLARE_BLOCK_NOTE_PREFIX = "atom-nexst anti-ddos auto-block"; + +export function getCloudflareApiConfig(): CloudflareApiConfig { + return { + baseUrl: + env.CLOUDFLARE_API_BASE_URL || "https://api.cloudflare.com/client/v4", + token: env.CLOUDFLARE_API_TOKEN?.trim() || null, + zoneId: env.CLOUDFLARE_ZONE_ID?.trim() || null, + }; +} + +/** True when a token + zone id are present so the gate may call the API. */ +export function cloudflareEnabled(): boolean { + const config = getCloudflareApiConfig(); + return Boolean(config.token && config.zoneId); +} + +interface CloudflareEnvelope { + success: boolean; + errors?: { code: number; message: string }[]; + result: T; +} + +function firstError(envelope: CloudflareEnvelope): string { + return envelope.errors?.[0]?.message ?? "Unknown Cloudflare API error"; +} + +/** Max duration a CF IP Access Rule block may live. Blocks use the gate's per-tier TTL. */ +export const MAX_CLOUDFLARE_BLOCK_TTL_SECONDS = 86_400 * 7; + +async function cloudflareRequest( + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise> { + const config = getCloudflareApiConfig(); + if (!config.token) { + throw new CloudflareApiError("CLOUDFLARE_API_TOKEN is not configured"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + let response: Response | undefined; + try { + response = await fetch(`${config.baseUrl}${path}`, { + method: init.method ?? "GET", + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + signal: controller.signal, + cache: "no-store", + }); + } finally { + clearTimeout(timer); + } + + let envelope: CloudflareEnvelope; + try { + envelope = (await response.json()) as CloudflareEnvelope; + } catch { + throw new CloudflareApiError( + `Cloudflare API returned HTTP ${response.status} with a non-JSON body`, + ); + } + if (!response.ok || !envelope.success) { + throw new CloudflareApiError( + `Cloudflare API error (HTTP ${response.status}): ${firstError(envelope)}`, + ); + } + return envelope; +} + +/** Validate that the configured token can read the zone. */ +export async function verifyCloudflareConnection(): Promise { + const config = getCloudflareApiConfig(); + if (!config.token) { + return { + ok: false, + message: "CLOUDFLARE_API_TOKEN is not configured", + at: Date.now(), + }; + } + if (!config.zoneId) { + return { + ok: false, + message: "CLOUDFLARE_ZONE_ID is not configured", + at: Date.now(), + }; + } + try { + const zone = await cloudflareRequest<{ id: string; name: string }>( + `/zones/${encodeURIComponent(config.zoneId)}`, + ); + return { + ok: true, + zoneId: config.zoneId, + zoneName: zone.result.name, + at: Date.now(), + }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error ? error.message : "Cloudflare API unavailable", + at: Date.now(), + }; + } +} + +async function createIpRule( + ip: string, + ttlSeconds: number, + category: string, +): Promise<{ ruleId: string }> { + const config = getCloudflareApiConfig(); + if (!config.zoneId) { + throw new CloudflareApiError("CLOUDFLARE_ZONE_ID is not configured"); + } + const envelope = await cloudflareRequest<{ id: string }>( + `/zones/${encodeURIComponent(config.zoneId)}/firewall/access_rules/rules`, + { + method: "POST", + body: { + mode: "block", + configuration: { target: "ip", value: ip }, + notes: `${CLOUDFLARE_BLOCK_NOTE_PREFIX} ttl=${ttlSeconds}s category=${category}`, + }, + }, + ); + return { ruleId: envelope.result.id }; +} + +async function deleteIpRule(ruleId: string): Promise { + const config = getCloudflareApiConfig(); + if (!config.zoneId) { + throw new CloudflareApiError("CLOUDFLARE_ZONE_ID is not configured"); + } + await cloudflareRequest( + `/zones/${encodeURIComponent(config.zoneId)}/firewall/access_rules/rules/${encodeURIComponent(ruleId)}`, + { method: "DELETE" }, + ); +} + +// --- Coordination state (Redis backed, in-process fallback) --- + +interface BlockStore { + get(ip: string): Promise; + set(meta: CloudflareBlockMeta): Promise; + delete(ip: string): Promise; + list(): Promise; + /** + * Claim a short-lived per-IP lock. Returns true when this caller may + * create the edge rule (no other instance is mid-flight for the IP). + */ + lock(ip: string): Promise; +} + +function metaKey(ip: string): string { + return `${META_PREFIX}${ip}`; +} + +const redisStore: BlockStore = { + async get(ip) { + if (!redis) return null; + const raw = await redis.get(metaKey(ip)); + if (!raw) return null; + try { + return JSON.parse(raw) as CloudflareBlockMeta; + } catch { + return null; + } + }, + async set(meta) { + if (!redis) return; + await Promise.all([ + redis.set(metaKey(meta.ip), JSON.stringify(meta)), + redis.sadd(INDEX_KEY, meta.ip), + ]); + }, + async delete(ip) { + if (!redis) return; + await Promise.all([redis.del(metaKey(ip)), redis.srem(INDEX_KEY, ip)]); + }, + async list() { + if (!redis) return []; + return redis.smembers(INDEX_KEY); + }, + async lock(ip) { + if (!redis) return true; + const acquired = await redis.set(LOCK_PREFIX + ip, "1", "EX", 30, "NX"); + return acquired === "OK"; + }, +}; + +const memoryBlocks = new Map(); + +const memoryStore: BlockStore = { + async get(ip) { + return memoryBlocks.get(ip) ?? null; + }, + async set(meta) { + memoryBlocks.set(meta.ip, meta); + }, + async delete(ip) { + memoryBlocks.delete(ip); + }, + async list() { + return [...memoryBlocks.keys()]; + }, + async lock() { + return true; + }, +}; + +function activeStore(): BlockStore { + return redis ? redisStore : memoryStore; +} + +function isBlockExpired(meta: CloudflareBlockMeta): boolean { + return Date.now() - meta.createdAt >= meta.ttlSeconds * 1000; +} + +/** + * Mirror an escalated IP block to Cloudflare. Safe to call on the hot path: + * it is never awaited by the caller, does nothing when the Cloudflare API is + * not configured or the runtime toggle is off, and dedupes per IP until the + * block expires. Any API failure is logged and swallowed. + */ +export async function maybeAutoBlockCloudflare(input: { + ip: string; + ttlSeconds: number; + category: string; + enabled: boolean; +}): Promise { + const { ip, ttlSeconds, category, enabled } = input; + if (!enabled) return; + if (!cloudflareEnabled()) return; + if (!ip || ip === UNKNOWN_CLIENT_IP) return; + + try { + const store = activeStore(); + const existing = await store.get(ip); + if (existing && !isBlockExpired(existing)) return; + + if (existing) { + try { + await deleteIpRule(existing.ruleId); + } catch (error) { + logger.warn( + "[cloudflare-api] Could not refresh stale edge block — re-creating", + { ip, err: error }, + ); + } + } + + if (!(await store.lock(ip))) return; + + // Double-check after claiming the lock (another instance may have won). + const recheck = await store.get(ip); + if (recheck && !isBlockExpired(recheck)) return; + + const { ruleId } = await createIpRule(ip, ttlSeconds, category); + await store.set({ + ruleId, + ip, + ttlSeconds, + category, + createdAt: Date.now(), + }); + logger.info("[cloudflare-api] Automatic IP block created", { + ip, + ruleId, + ttlSeconds, + category, + }); + } catch (error) { + logger.error("[cloudflare-api] Automatic IP block failed", { + ip, + err: error, + }); + } +} + +/** + * Delete the Cloudflare edge block for an IP (used by the admin "unban" and + * the sweep job). Local tracking is only cleared after the API confirms the + * rule is gone, so a transient API failure keeps the rule + ttl bookkeeping + * intact and the next sweep retries. + */ +export async function removeCloudflareBlock( + ip: string, +): Promise<{ removed: boolean; message?: string }> { + const store = activeStore(); + const meta = await store.get(ip); + if (!meta) return { removed: false }; + + try { + await deleteIpRule(meta.ruleId); + } catch (error) { + const message = + error instanceof Error ? error.message : "Cloudflare API unavailable"; + return { removed: false, message }; + } + await store.delete(ip); + logger.info("[cloudflare-api] Automatic IP block removed", { + ip, + ruleId: meta.ruleId, + }); + return { removed: true }; +} + +/** Current tracked Cloudflare edge blocks, closest to expiry first. */ +export async function listCloudflareBlocks(): Promise { + const store = activeStore(); + const ips = await store.list(); + const blocks = ( + await Promise.all( + ips.map(async (ip) => { + const meta = await store.get(ip); + if (!meta) return null; + const remainingSeconds = Math.max( + 0, + Math.ceil(meta.ttlSeconds - (Date.now() - meta.createdAt) / 1000), + ); + return { + ...meta, + remainingSeconds, + expired: isBlockExpired(meta), + }; + }), + ) + ) + .filter((block): block is CloudflareBlockView => block !== null) + .sort((a, b) => a.remainingSeconds - b.remainingSeconds); + // Drop any orphaned index entries (a meta missing its rule). + for (const ip of ips) { + if (!blocks.some((block) => block.ip === ip)) { + await store.delete(ip); + } + } + return blocks; +} + +/** + * Remove Cloudflare edge blocks whose TTL has elapsed. Runs periodically from + * the instrumentation worker and from the admin panel render. + */ +export async function sweepExpiredCloudflareBlocks(): Promise { + const store = activeStore(); + const ips = await store.list(); + let removed = 0; + for (const ip of ips) { + const meta = await store.get(ip); + if (!meta || !isBlockExpired(meta)) continue; + const result = await removeCloudflareBlock(ip); + if (result.removed) removed += 1; + } + return removed; +} + +let lastVerifyMemory: CloudflareConnectionStatus | null = null; + +export async function getLastCloudflareVerify(): Promise { + if (redis) { + try { + const raw = await redis.get(LAST_VERIFY_KEY); + if (raw) return JSON.parse(raw) as CloudflareConnectionStatus; + } catch { + // fall back to in-process view + } + } + return lastVerifyMemory; +} + +export async function setLastCloudflareVerify( + status: CloudflareConnectionStatus, +): Promise { + lastVerifyMemory = status; + if (redis) { + try { + await redis.set(LAST_VERIFY_KEY, JSON.stringify(status)); + } catch { + // redis unavailable — in-process view is enough + } + } +} + +/** Test hook only — drop in-memory dedupe state between unit runs. */ +export function resetCloudflareAutoBlockCache(): void { + memoryBlocks.clear(); +} diff --git a/src/lib/ddos-guard-cloudflare.test.ts b/src/lib/ddos-guard-cloudflare.test.ts new file mode 100644 index 00000000..c06fbf01 --- /dev/null +++ b/src/lib/ddos-guard-cloudflare.test.ts @@ -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() })); + +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 { + 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; + + 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); + }); +}); diff --git a/src/lib/ddos-guard.ts b/src/lib/ddos-guard.ts index ad996fb7..6e892bb6 100644 --- a/src/lib/ddos-guard.ts +++ b/src/lib/ddos-guard.ts @@ -5,6 +5,8 @@ import { NextResponse } from "next/server"; import { env } from "@/env"; import { getAntiddosConfig } from "@/lib/antiddos-config"; import { resolveClientIp } from "@/lib/client-ip"; +import { isCloudflareProxied } from "@/lib/cloudflare"; +import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api"; import { classifyDdos, isSuspiciousPath } from "@/lib/ddos"; import { rateLimit } from "@/lib/rate-limit"; import { redis } from "@/lib/redis"; @@ -110,6 +112,17 @@ export async function enforceDdosRateLimit( const ttl = blockTtlForViolations(violations, config.blockTiers); if (violations >= config.maxViolations) { await redis.set(blockKey, "1", "EX", ttl); + // Mirror the host-level block to the Cloudflare edge (IP Access + // Rules) so a repeat offender is shed before it reaches the + // origin. Only when this request demonstrably transited + // Cloudflare — that is when the client IP is trustworthy. + void maybeAutoBlockCloudflare({ + ip, + ttlSeconds: ttl, + category, + enabled: + config.cloudflareAutoBlock && isCloudflareProxied(req.headers), + }); } return { outcome: "block", retryAfterSeconds: ttl }; } catch {