Files
EpicNext-Cms/src/lib/ddos-guard.ts
T
openhands 4479753160
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
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)
2026-09-22 23:27:15 +02:00

149 lines
4.5 KiB
TypeScript

import "server-only";
import type { NextRequest } from "next/server";
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";
export type DdosDecision =
| { outcome: "pass" }
| { outcome: "suspect" }
| { outcome: "block"; retryAfterSeconds: number };
function isEnabled(): boolean {
if (env.NODE_ENV !== "production") return false;
return env.ANTI_DDOS_ENABLED;
}
// Once the global valve trips, shed every request for a short spell from
// process memory only — no further Redis round-trips — so a live flood can
// never pile request-handling work onto the limiter itself.
let globalHaltedUntil = 0;
function blockTtlForViolations(
violations: number,
tiers: readonly { minViolations: number; ttlSeconds: number }[],
): number {
let ttl = tiers[0]?.ttlSeconds ?? 600;
for (const tier of tiers) {
if (violations >= tier.minViolations) ttl = tier.ttlSeconds;
}
return ttl;
}
/**
* App-layer anti-DDoS gate for the Next.js proxy. Reuses the app-wide
* Redis/in-memory buckets (so multi-instance deployments share state) and the
* audited IP resolver. Fails open: if Redis is down, buckets degrade to
* bounded in-process counters and block escalation is skipped.
*
* Tunables come from the anti-DDoS config (env boot defaults, live-overridden
* by the admin panel via Redis). `/api/health` is exempt so the Docker
* liveness probe never trips the gate.
*/
export async function enforceDdosRateLimit(
req: NextRequest,
): Promise<DdosDecision> {
if (!isEnabled()) return { outcome: "pass" };
const config = await getAntiddosConfig();
if (!config.enabled) return { outcome: "pass" };
const pathname = req.nextUrl.pathname;
if (pathname === "/api/health") return { outcome: "pass" };
if (isSuspiciousPath(pathname)) return { outcome: "suspect" };
const now = Date.now();
if (now < globalHaltedUntil) {
return { outcome: "block", retryAfterSeconds: 1 };
}
const ip = resolveClientIp(req.headers);
const blockKey = `antiddos:block:${ip}`;
if (redis) {
try {
if ((await redis.get(blockKey)) !== null) {
return {
outcome: "block",
retryAfterSeconds: blockTtlForViolations(0, config.blockTiers),
};
}
} catch {
// fail-open: never let the limiter itself take the site down.
}
}
const global = await rateLimit(
"antiddos:global:all",
config.global.limit,
config.global.windowSeconds * 1000,
);
if (!global.ok) {
globalHaltedUntil = now + config.globalHaltMs;
return {
outcome: "block",
retryAfterSeconds: Math.max(global.retryAfter, 1),
};
}
if (globalHaltedUntil !== 0) globalHaltedUntil = 0;
const category = classifyDdos(pathname);
const rule = config[category];
const bucket = await rateLimit(
`antiddos:${category}:${ip}`,
rule.limit,
rule.windowSeconds * 1000,
);
if (bucket.ok) return { outcome: "pass" };
let violations = 1;
if (redis) {
try {
const counterKey = `antiddos:v:${ip}`;
violations = await redis.incr(counterKey);
if (violations === 1) {
await redis.pexpire(counterKey, config.violationWindowSeconds * 1000);
}
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 {
// fail-open — Redis merely unavailable; in-process buckets still shed.
}
}
return {
outcome: "block",
retryAfterSeconds: Math.max(bucket.retryAfter, 1),
};
}
export function ddosReject(
status: 403 | 429,
retryAfterSeconds = 0,
): NextResponse {
const headers: Record<string, string> = {
"Cache-Control": "no-store",
"X-Rate-Limit": "1",
};
if (retryAfterSeconds > 0) headers["Retry-After"] = String(retryAfterSeconds);
return new NextResponse(null, { status, headers });
}