From fd4d0fa1cb738c4610f4979cea9986b1887d06ca Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 22 Sep 2026 21:57:09 +0200 Subject: [PATCH] feat(security): harden anti-DDoS gate with scanner triage, tiered blocks and in-process global halt --- src/lib/ddos-guard.ts | 110 +++++++++++++++++++++++++++++------------- src/lib/ddos.test.ts | 25 +++++++++- src/lib/ddos.ts | 36 ++++++++++++++ src/proxy.ts | 9 ++-- 4 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/lib/ddos-guard.ts b/src/lib/ddos-guard.ts index 70da0eef..40185053 100644 --- a/src/lib/ddos-guard.ts +++ b/src/lib/ddos-guard.ts @@ -5,14 +5,14 @@ import { NextResponse } from "next/server"; import { env } from "@/env"; import { resolveClientIp } from "@/lib/client-ip"; -import { classifyDdos, type DdosCategory } from "@/lib/ddos"; +import { classifyDdos, type DdosCategory, isSuspiciousPath } from "@/lib/ddos"; import { rateLimit } from "@/lib/rate-limit"; import { redis } from "@/lib/redis"; -export interface DdosDecision { - limited: boolean; - retryAfterSeconds: number; -} +export type DdosDecision = + | { outcome: "pass" } + | { outcome: "suspect" } + | { outcome: "block"; retryAfterSeconds: number }; export interface DdosLimitRule { limit: number; @@ -34,32 +34,68 @@ const DEFAULT_LIMITS: Record = { // 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 }; -const VIOLATION_WINDOW_SECONDS = 600; -const MAX_VIOLATIONS = 10; -const BLOCK_TTL_SECONDS = 600; + +// 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) { + 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 rate-limit 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 the block-list is skipped. + * 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. + * + * `/api/health` is exempt so the Docker liveness probe never trips the gate. */ export async function enforceDdosRateLimit( req: NextRequest, ): Promise { - if (!isEnabled()) return { limited: false, retryAfterSeconds: 0 }; + if (!isEnabled()) 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 { limited: true, retryAfterSeconds: BLOCK_TTL_SECONDS }; + return { + outcome: "block", + retryAfterSeconds: blockTtlForViolations(0), + }; } } catch { // fail-open: never let the limiter itself take the site down. @@ -72,46 +108,52 @@ export async function enforceDdosRateLimit( GLOBAL_LIMIT.windowSeconds * 1000, ); if (!global.ok) { + globalHaltedUntil = now + GLOBAL_HALT_MS; return { - limited: true, + outcome: "block", retryAfterSeconds: Math.max(global.retryAfter, 1), }; } + if (globalHaltedUntil !== 0) globalHaltedUntil = 0; - const category = classifyDdos(req.nextUrl.pathname); + const category = classifyDdos(pathname); const rule = DEFAULT_LIMITS[category]; const bucket = await rateLimit( `antiddos:${category}:${ip}`, rule.limit, rule.windowSeconds * 1000, ); - if (bucket.ok) return { limited: false, retryAfterSeconds: 0 }; + if (bucket.ok) return { outcome: "pass" }; - const violations = await rateLimit( - `antiddos:v:${ip}`, - MAX_VIOLATIONS, - VIOLATION_WINDOW_SECONDS * 1000, - ); - if (!violations.ok && redis) { + let violations = 1; + if (redis) { try { - await redis.set(blockKey, "1", "EX", BLOCK_TTL_SECONDS); + const counterKey = `antiddos:v:${ip}`; + violations = await redis.incr(counterKey); + if (violations === 1) { + await redis.pexpire(counterKey, VIOLATION_COUNTER_TTL_SECONDS * 1000); + } + const ttl = blockTtlForViolations(violations); + await redis.set(blockKey, "1", "EX", ttl); + return { outcome: "block", retryAfterSeconds: ttl }; } catch { - // fail-open — Redis merely unavailable. + // fail-open — Redis merely unavailable; in-process buckets still shed. } } return { - limited: true, + outcome: "block", retryAfterSeconds: Math.max(bucket.retryAfter, 1), }; } -export function ddosRejected(retryAfterSeconds: number): NextResponse { - return new NextResponse(null, { - status: 429, - headers: { - "Retry-After": String(retryAfterSeconds), - "X-Rate-Limit": "1", - "Cache-Control": "no-store", - }, - }); +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 }); } diff --git a/src/lib/ddos.test.ts b/src/lib/ddos.test.ts index a6a00de7..1cbc9ee7 100644 --- a/src/lib/ddos.test.ts +++ b/src/lib/ddos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { classifyDdos } from "@/lib/ddos"; +import { classifyDdos, isSuspiciousPath } from "@/lib/ddos"; describe("classifyDdos", () => { it.each([ @@ -18,3 +18,26 @@ describe("classifyDdos", () => { expect(classifyDdos(pathname)).toBe(expected); }); }); + +describe("isSuspiciousPath", () => { + it.each([ + ["/wp-admin/index.php", true], + ["/wp-login.php", true], + ["/.env", true], + ["/.git/config", true], + ["/phpmyadmin/", true], + ["/server-status", true], + ["/index.php", true], + ["/shell.aspx", true], + ])(`flags scanner path %s`, (pathname, expected) => { + expect(isSuspiciousPath(pathname)).toBe(expected); + }); + + it("does not flag real app routes", () => { + expect(isSuspiciousPath("/")).toBe(false); + expect(isSuspiciousPath("/community")).toBe(false); + expect(isSuspiciousPath("/api/health")).toBe(false); + expect(isSuspiciousPath("/login")).toBe(false); + expect(isSuspiciousPath("/news/article/hello-world")).toBe(false); + }); +}); diff --git a/src/lib/ddos.ts b/src/lib/ddos.ts index 032eef2b..5afbfd4c 100644 --- a/src/lib/ddos.ts +++ b/src/lib/ddos.ts @@ -16,3 +16,39 @@ export function classifyDdos(pathname: string): DdosCategory { if (pathname.startsWith("/api/")) return "api"; return "pages"; } + +const HOSTILE_PATH_SEGMENTS = new Set([ + "wp-admin", + "wp-login.php", + "wp-includes", + "phpmyadmin", + "pma", + "adminer", + "server-status", + ".env", + ".git", +]); + +const HOSTILE_PATH_EXTENSIONS = [".php", ".asp", ".aspx", ".jsp", ".cgi"]; + +/** + * Cheap scanner/exploit triage. These paths are never routes in this app, so a + * hit is almost certainly an automated attack sweep; dropping it here costs no + * Redis work and never affects genuine traffic. + */ +export function isSuspiciousPath(pathname: string): boolean { + const lower = pathname.toLowerCase(); + for (const segment of lower.split("/")) { + if (HOSTILE_PATH_SEGMENTS.has(segment)) return true; + } + for (const extension of HOSTILE_PATH_EXTENSIONS) { + if ( + lower.endsWith(extension) || + lower.includes(`${extension}/`) || + lower.includes(`${extension}?`) + ) { + return true; + } + } + return false; +} diff --git a/src/proxy.ts b/src/proxy.ts index 0af53594..7212b68f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getToken } from "next-auth/jwt"; import { env } from "@/env"; import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp"; -import { ddosRejected, enforceDdosRateLimit } from "@/lib/ddos-guard"; +import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard"; import { shouldRedirectAdminRequest } from "@/lib/proxy-access"; const SECURITY_HEADERS: Record = { @@ -16,8 +16,11 @@ const SECURITY_HEADERS: Record = { export const proxy = async (req: import("next/server").NextRequest) => { const decision = await enforceDdosRateLimit(req); - if (decision.limited) { - return ddosRejected(decision.retryAfterSeconds); + if (decision.outcome === "suspect") { + return ddosReject(403); + } + if (decision.outcome === "block") { + return ddosReject(429, decision.retryAfterSeconds); } const pathname = req.nextUrl.pathname;