feat(security): mirror anti-DDoS blocks to Cloudflare edge via API

- 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:
openhands committed 2026-09-22 23:27:15 +02:00
1 parent f0c27eb815
commit 4479753160
9 files changed
+1157 -1

No files matched your search

+13 -1
View File
@@ -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,
};
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13
View File
@@ -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 {