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, UNKNOWN_CLIENT_IP } from "@/lib/client-ip"; import { isCloudflareProxied } from "@/lib/cloudflare"; import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api"; import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api"; import { checkCrowdsecLocalBlock } from "@/lib/crowdsec-local"; import { reportCrowdsecSignal } from "@/lib/crowdsec-report"; import { bumpCrowdsecBreakdownStat, bumpCrowdsecStat, } from "@/lib/crowdsec-stats"; 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 { 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); // A trusted ingress always resolves a real client address. `0.0.0.0` is the // sentinel for header-less loopback traffic (health checks, CI browser // gates, monitoring). If it were rate-limited or blocked it would occupy a // single shared key, and any burst of synthetic local traffic could then // shed all origin-verified requests — exactly what broke CI smoke tests. if (ip === UNKNOWN_CLIENT_IP) return { outcome: "pass" }; 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 localBlock = await checkCrowdsecLocalBlock(ip); if (localBlock.blocked) { return { outcome: "block", retryAfterSeconds: Math.max(localBlock.retryAfterSeconds, 1), }; } 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); // Share the block in the daily activity histogram + breakdown. void bumpCrowdsecStat("blocks"); void bumpCrowdsecBreakdownStat("category", category); // Opt-in: also push our OWN detection (not just CrowdSec- // reputation blocks) into the community blocklist via the same // CAPI channel. Fire-and-forget; deduped per IP internally. void reportCrowdsecSignal({ ip, category, ttlSeconds: ttl, verdict: { ip, reputation: null, score: 0, aggressiveness: 0, confidence: null, behaviors: [`gate:${category}`], falsePositive: false, checkedAt: Date.now(), }, meta: { source: "gate", category, reputation: null, score: 0, behaviors: [`gate:${category}`], ttlSeconds: ttl, blockedAt: Date.now(), }, }); // 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), }); } // Consult CrowdSec's community reputation for repeat offenders. // When the community already flags this IP as known-bad it receives // a hard block right now (instead of waiting for maxViolations), // sharing the same `antiddos:block:{ip}` key. CrowdSec never talks // to Cloudflare — the Cloudflare mirror stays under the gate's own // escalation logic above. Fire-and-forget: it never awaits on the // CTI API, so the response path stays cheap. void maybeAutoBlockCrowdsec({ ip, category, ttlSeconds: config.crowdsecBlockTtlSeconds, scoreThreshold: config.crowdsecBlockScore, enabled: config.crowdsecAutoBlock, }); 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 = { "Cache-Control": "no-store", "X-Rate-Limit": "1", }; if (retryAfterSeconds > 0) headers["Retry-After"] = String(retryAfterSeconds); return new NextResponse(null, { status, headers }); }