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
@@ -10,6 +10,11 @@ import {
|
|||||||
antiddosDefaultsFromEnv,
|
antiddosDefaultsFromEnv,
|
||||||
invalidateAntiddosConfig,
|
invalidateAntiddosConfig,
|
||||||
} from "@/lib/antiddos-config";
|
} from "@/lib/antiddos-config";
|
||||||
|
import {
|
||||||
|
removeCloudflareBlock,
|
||||||
|
setLastCloudflareVerify,
|
||||||
|
verifyCloudflareConnection,
|
||||||
|
} from "@/lib/cloudflare-api";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
@@ -93,6 +98,7 @@ function configFromForm(formData: FormData): AntiddosConfig {
|
|||||||
formData.get("global_halt_ms"),
|
formData.get("global_halt_ms"),
|
||||||
defaults.globalHaltMs,
|
defaults.globalHaltMs,
|
||||||
),
|
),
|
||||||
|
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +122,7 @@ async function persistSettings(config: AntiddosConfig): Promise<void> {
|
|||||||
.join(","),
|
.join(","),
|
||||||
],
|
],
|
||||||
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
||||||
|
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
||||||
];
|
];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(([key, value]) =>
|
entries.map(([key, value]) =>
|
||||||
@@ -190,12 +197,49 @@ export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
|||||||
redis.del(`antiddos:v:${ip}`),
|
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", {
|
logger.info("Anti-DDoS block manually removed", {
|
||||||
staff: staff.username,
|
staff: staff.username,
|
||||||
ip,
|
ip,
|
||||||
|
cloudflareCleared: cloudflare.removed,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Failed to remove anti-DDoS block", { err, ip });
|
logger.error("Failed to remove anti-DDoS block", { err, ip });
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/devops/antiddos");
|
revalidatePath("/admin/devops/antiddos");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove an automatic Cloudflare edge block for a tracked IP. */
|
||||||
|
export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
removeCloudflareRule,
|
||||||
resetAntiddosSettings,
|
resetAntiddosSettings,
|
||||||
saveAntiddosSettings,
|
saveAntiddosSettings,
|
||||||
unbanAntiddosIp,
|
unbanAntiddosIp,
|
||||||
|
verifyCloudflareConfiguration,
|
||||||
} from "@/actions/admin-antiddos";
|
} from "@/actions/admin-antiddos";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,6 +17,13 @@ import {
|
|||||||
} from "@/lib/antiddos-config";
|
} from "@/lib/antiddos-config";
|
||||||
import { resolveClientIp } from "@/lib/client-ip";
|
import { resolveClientIp } from "@/lib/client-ip";
|
||||||
import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare";
|
import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare";
|
||||||
|
import {
|
||||||
|
type CloudflareBlockView,
|
||||||
|
cloudflareEnabled,
|
||||||
|
getLastCloudflareVerify,
|
||||||
|
listCloudflareBlocks,
|
||||||
|
sweepExpiredCloudflareBlocks,
|
||||||
|
} from "@/lib/cloudflare-api";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
@@ -87,6 +96,14 @@ export default async function AdminAntiDdosPage() {
|
|||||||
|
|
||||||
const stored = persistedRows;
|
const stored = persistedRows;
|
||||||
|
|
||||||
|
const cloudflareConfigured = cloudflareEnabled();
|
||||||
|
const cloudflareBlocks: CloudflareBlockView[] = [];
|
||||||
|
if (cloudflareConfigured) {
|
||||||
|
await sweepExpiredCloudflareBlocks();
|
||||||
|
cloudflareBlocks.push(...(await listCloudflareBlocks()));
|
||||||
|
}
|
||||||
|
const lastVerify = await getLastCloudflareVerify();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
@@ -219,6 +236,24 @@ export default async function AdminAntiDdosPage() {
|
|||||||
Enable the app-layer anti-DDoS gate (production only)
|
Enable the app-layer anti-DDoS gate (production only)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="cfa_auto_block"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={effective.cloudflareAutoBlock}
|
||||||
|
/>
|
||||||
|
Automatically create Cloudflare edge blocks when an IP hits the
|
||||||
|
block threshold
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
Requires <span className="font-mono">CLOUDFLARE_API_TOKEN</span>{" "}
|
||||||
|
and <span className="font-mono">CLOUDFLARE_ZONE_ID</span> in the
|
||||||
|
environment. Blocks are only created for traffic that provably
|
||||||
|
transits Cloudflare, and expire together with the host-level
|
||||||
|
block.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -382,6 +417,84 @@ export default async function AdminAntiDdosPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Cloud className="h-4 w-4" /> Cloudflare edge blocks
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant={cloudflareConfigured ? "default" : "secondary"}>
|
||||||
|
{cloudflareConfigured ? "API configured" : "API not configured"}
|
||||||
|
</Badge>
|
||||||
|
{!cloudflareConfigured && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set <span className="font-mono">CLOUDFLARE_API_TOKEN</span> and{" "}
|
||||||
|
<span className="font-mono">CLOUDFLARE_ZONE_ID</span> to enable
|
||||||
|
automatic edge blocking via the Cloudflare API.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form action={verifyCloudflareConfiguration}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!cloudflareConfigured}
|
||||||
|
>
|
||||||
|
Verify connection
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastVerify && cloudflareConfigured && (
|
||||||
|
<p className="text-xs">
|
||||||
|
<Badge variant={lastVerify.ok ? "default" : "destructive"}>
|
||||||
|
{lastVerify.ok ? "Reachable" : "Failed"}
|
||||||
|
</Badge>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{lastVerify.ok
|
||||||
|
? `Zone ${lastVerify.zoneName ?? lastVerify.zoneId ?? ""} — verified ${new Date(lastVerify.at).toLocaleString()}`
|
||||||
|
: lastVerify.message}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cloudflareConfigured && cloudflareBlocks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No automatic Cloudflare blocks are active. When the gate blocks a
|
||||||
|
repeat offender behind Cloudflare, an IP Access Rule is created
|
||||||
|
here automatically.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
cloudflareConfigured && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cloudflareBlocks.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.ip}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{b.ip}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{b.category} · {seconds(b.remainingSeconds * 1000)} left
|
||||||
|
</span>
|
||||||
|
<form action={removeCloudflareRule}>
|
||||||
|
<input type="hidden" name="ip" value={b.ip} />
|
||||||
|
<Button type="submit" size="sm" variant="outline">
|
||||||
|
Remove rule
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Expired rules are swept automatically every 30s.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{stored.size === 0 && (
|
{stored.size === 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Persisted site settings: none yet — the form values above reflect the
|
Persisted site settings: none yet — the form values above reflect the
|
||||||
|
|||||||
+16
@@ -132,6 +132,22 @@ const schema = z
|
|||||||
// Logging level.
|
// Logging level.
|
||||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||||
APP_VERSION: z.string().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) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.NODE_ENV !== "production") return;
|
if (data.NODE_ENV !== "production") return;
|
||||||
|
|||||||
@@ -34,4 +34,9 @@ export async function register() {
|
|||||||
void drainFurnitureImports();
|
void drainFurnitureImports();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
timer.unref();
|
timer.unref();
|
||||||
|
const { sweepExpiredCloudflareBlocks } = await import("@/lib/cloudflare-api");
|
||||||
|
const cloudflareSweep = setInterval(() => {
|
||||||
|
void sweepExpiredCloudflareBlocks();
|
||||||
|
}, 30000);
|
||||||
|
cloudflareSweep.unref();
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ export interface AntiddosConfig {
|
|||||||
maxViolations: number;
|
maxViolations: number;
|
||||||
blockTiers: AntiddosBlockTier[];
|
blockTiers: AntiddosBlockTier[];
|
||||||
globalHaltMs: number;
|
globalHaltMs: number;
|
||||||
|
cloudflareAutoBlock: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: AntiddosConfig = {
|
const DEFAULT_CONFIG: AntiddosConfig = {
|
||||||
@@ -39,6 +40,7 @@ const DEFAULT_CONFIG: AntiddosConfig = {
|
|||||||
{ minViolations: 50, ttlSeconds: 86_400 },
|
{ minViolations: 50, ttlSeconds: 86_400 },
|
||||||
],
|
],
|
||||||
globalHaltMs: 10_000,
|
globalHaltMs: 10_000,
|
||||||
|
cloudflareAutoBlock: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function positiveInt(value: number | undefined, fallback: number): number {
|
function positiveInt(value: number | undefined, fallback: number): number {
|
||||||
@@ -65,11 +67,19 @@ function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null {
|
|||||||
return tiers;
|
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). */
|
/** Boot defaults from environment (explicitly set → overrides code; unset → sane value). */
|
||||||
export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
||||||
const tiers = parseTiers(env.ANTI_DDOS_BLOCK_TIERS);
|
const tiers = parseTiers(env.ANTI_DDOS_BLOCK_TIERS);
|
||||||
return {
|
return {
|
||||||
enabled: env.ANTI_DDOS_ENABLED !== false,
|
enabled: isTruthyFlag(env.ANTI_DDOS_ENABLED),
|
||||||
pages: {
|
pages: {
|
||||||
limit: positiveInt(env.ANTI_DDOS_PAGES_LIMIT, DEFAULT_CONFIG.pages.limit),
|
limit: positiveInt(env.ANTI_DDOS_PAGES_LIMIT, DEFAULT_CONFIG.pages.limit),
|
||||||
windowSeconds: positiveInt(
|
windowSeconds: positiveInt(
|
||||||
@@ -114,6 +124,7 @@ export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
|||||||
env.ANTI_DDOS_GLOBAL_HALT_MS,
|
env.ANTI_DDOS_GLOBAL_HALT_MS,
|
||||||
DEFAULT_CONFIG.globalHaltMs,
|
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)
|
.sort((a, b) => a.minViolations - b.minViolations)
|
||||||
: base.blockTiers,
|
: base.blockTiers,
|
||||||
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
||||||
|
cloudflareAutoBlock: config?.cloudflareAutoBlock !== false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T> {
|
||||||
|
success: boolean;
|
||||||
|
errors?: { code: number; message: string }[];
|
||||||
|
result: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstError<T>(envelope: CloudflareEnvelope<T>): 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<T>(
|
||||||
|
path: string,
|
||||||
|
init: { method?: string; body?: unknown } = {},
|
||||||
|
): Promise<CloudflareEnvelope<T>> {
|
||||||
|
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<T>;
|
||||||
|
try {
|
||||||
|
envelope = (await response.json()) as CloudflareEnvelope<T>;
|
||||||
|
} 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<CloudflareConnectionStatus> {
|
||||||
|
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<void> {
|
||||||
|
const config = getCloudflareApiConfig();
|
||||||
|
if (!config.zoneId) {
|
||||||
|
throw new CloudflareApiError("CLOUDFLARE_ZONE_ID is not configured");
|
||||||
|
}
|
||||||
|
await cloudflareRequest<void>(
|
||||||
|
`/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<CloudflareBlockMeta | null>;
|
||||||
|
set(meta: CloudflareBlockMeta): Promise<void>;
|
||||||
|
delete(ip: string): Promise<void>;
|
||||||
|
list(): Promise<string[]>;
|
||||||
|
/**
|
||||||
|
* 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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, CloudflareBlockMeta>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<CloudflareBlockView[]> {
|
||||||
|
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<number> {
|
||||||
|
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<CloudflareConnectionStatus | null> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
||||||
import { resolveClientIp } from "@/lib/client-ip";
|
import { resolveClientIp } from "@/lib/client-ip";
|
||||||
|
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||||
|
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||||
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
@@ -110,6 +112,17 @@ export async function enforceDdosRateLimit(
|
|||||||
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
||||||
if (violations >= config.maxViolations) {
|
if (violations >= config.maxViolations) {
|
||||||
await redis.set(blockKey, "1", "EX", ttl);
|
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 };
|
return { outcome: "block", retryAfterSeconds: ttl };
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in new issue
Block a user