feat(security): Cloudflare-aware IP trust and admin-tunable anti-DDoS
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m50s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m32s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m50s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m32s
- resolveClientIp: trust cf-connecting-ip only behind cf-ray/cdn-loop, use nginx x-real-ip otherwise (anti-spoof) - antiddos-config: Redis-backed live config (antiddos:config) with 30s cache, 13 ANTI_DDOS_* env vars - ddos-guard: consume tunable rates/tiers via getAntiddosConfig - admin panel at /admin/devops/antiddos (save/reset/unban actions, PERMS.SETTINGS_VIEW) - register new admin page in housekeeping migration matrix (146 -> 147)
This commit is contained in:
1 parent
fd4d0fa1cb
commit
f0c27eb815
17 files changed
+1151
-63
No files matched your search
+28
-52
@@ -2,10 +2,10 @@ 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 { classifyDdos, type DdosCategory, isSuspiciousPath } from "@/lib/ddos";
|
||||
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
@@ -14,52 +14,22 @@ export type DdosDecision =
|
||||
| { outcome: "suspect" }
|
||||
| { outcome: "block"; retryAfterSeconds: number };
|
||||
|
||||
export interface DdosLimitRule {
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: Record<DdosCategory, DdosLimitRule> = {
|
||||
// Anonymous HTML is cached at the nginx layer for 60s, so Node only pays
|
||||
// for cache misses and authenticated traffic here.
|
||||
pages: { limit: 300, windowSeconds: 60 },
|
||||
// Game clients poll a handful of endpoints; generous burst headroom that
|
||||
// still cuts off single-IP floods.
|
||||
api: { limit: 600, windowSeconds: 60 },
|
||||
// Login, register and admin — the valuable brute-force surface.
|
||||
auth: { limit: 20, windowSeconds: 60 },
|
||||
};
|
||||
|
||||
// Global safety valve: sheds aggregate load even when a DDoS spreads over
|
||||
// many IPs, keeping the process and database alive with 429s instead of
|
||||
// letting every connection through until the DB melts.
|
||||
const GLOBAL_LIMIT: DdosLimitRule = { limit: 18_000, windowSeconds: 60 };
|
||||
|
||||
// Escalating blocks so persistent / distributed offenders stay off longer
|
||||
// than a single window. The violation counter lives for a day; after a quiet
|
||||
// day the counter and any block TTL both expire, so blocks are self-healing.
|
||||
const VIOLATION_COUNTER_TTL_SECONDS = 86_400;
|
||||
const BLOCK_TIERS: readonly { minViolations: number; ttlSeconds: number }[] = [
|
||||
{ minViolations: 5, ttlSeconds: 600 },
|
||||
{ minViolations: 20, ttlSeconds: 3_600 },
|
||||
{ minViolations: 50, ttlSeconds: 86_400 },
|
||||
];
|
||||
|
||||
// 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.
|
||||
const GLOBAL_HALT_MS = 10_000;
|
||||
|
||||
let globalHaltedUntil = 0;
|
||||
|
||||
function isEnabled(): boolean {
|
||||
if (env.NODE_ENV !== "production") return false;
|
||||
return env.ANTI_DDOS_ENABLED;
|
||||
}
|
||||
|
||||
function blockTtlForViolations(violations: number): number {
|
||||
let ttl = BLOCK_TIERS[0].ttlSeconds;
|
||||
for (const tier of BLOCK_TIERS) {
|
||||
// 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;
|
||||
@@ -71,12 +41,16 @@ function blockTtlForViolations(violations: number): number {
|
||||
* audited IP resolver. Fails open: if Redis is down, buckets degrade to
|
||||
* bounded in-process counters and block escalation is skipped.
|
||||
*
|
||||
* `/api/health` is exempt so the Docker liveness probe never trips the gate.
|
||||
* 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" };
|
||||
@@ -94,7 +68,7 @@ export async function enforceDdosRateLimit(
|
||||
if ((await redis.get(blockKey)) !== null) {
|
||||
return {
|
||||
outcome: "block",
|
||||
retryAfterSeconds: blockTtlForViolations(0),
|
||||
retryAfterSeconds: blockTtlForViolations(0, config.blockTiers),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -104,11 +78,11 @@ export async function enforceDdosRateLimit(
|
||||
|
||||
const global = await rateLimit(
|
||||
"antiddos:global:all",
|
||||
GLOBAL_LIMIT.limit,
|
||||
GLOBAL_LIMIT.windowSeconds * 1000,
|
||||
config.global.limit,
|
||||
config.global.windowSeconds * 1000,
|
||||
);
|
||||
if (!global.ok) {
|
||||
globalHaltedUntil = now + GLOBAL_HALT_MS;
|
||||
globalHaltedUntil = now + config.globalHaltMs;
|
||||
return {
|
||||
outcome: "block",
|
||||
retryAfterSeconds: Math.max(global.retryAfter, 1),
|
||||
@@ -117,7 +91,7 @@ export async function enforceDdosRateLimit(
|
||||
if (globalHaltedUntil !== 0) globalHaltedUntil = 0;
|
||||
|
||||
const category = classifyDdos(pathname);
|
||||
const rule = DEFAULT_LIMITS[category];
|
||||
const rule = config[category];
|
||||
const bucket = await rateLimit(
|
||||
`antiddos:${category}:${ip}`,
|
||||
rule.limit,
|
||||
@@ -131,10 +105,12 @@ export async function enforceDdosRateLimit(
|
||||
const counterKey = `antiddos:v:${ip}`;
|
||||
violations = await redis.incr(counterKey);
|
||||
if (violations === 1) {
|
||||
await redis.pexpire(counterKey, VIOLATION_COUNTER_TTL_SECONDS * 1000);
|
||||
await redis.pexpire(counterKey, config.violationWindowSeconds * 1000);
|
||||
}
|
||||
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
||||
if (violations >= config.maxViolations) {
|
||||
await redis.set(blockKey, "1", "EX", ttl);
|
||||
}
|
||||
const ttl = blockTtlForViolations(violations);
|
||||
await redis.set(blockKey, "1", "EX", ttl);
|
||||
return { outcome: "block", retryAfterSeconds: ttl };
|
||||
} catch {
|
||||
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
||||
|
||||
Reference in new issue
Block a user