diff --git a/src/env.ts b/src/env.ts index dec979c5..6f3618c5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -100,6 +100,12 @@ const schema = z // Redis — strongly recommended in production (required for multi-instance). // Without it, rate limits / shared caches are in-process only. REDIS_URL: z.string().optional(), + // App-layer anti-DDoS gate (proxy rate limiter). On by default in + // production; set to "false" or "0" to disable without removing it. + ANTI_DDOS_ENABLED: z + .string() + .optional() + .transform((value) => value !== "false" && value !== "0"), // Logging level. LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(), APP_VERSION: z.string().optional(), diff --git a/src/lib/ddos-guard.ts b/src/lib/ddos-guard.ts new file mode 100644 index 00000000..70da0eef --- /dev/null +++ b/src/lib/ddos-guard.ts @@ -0,0 +1,117 @@ +import "server-only"; + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { env } from "@/env"; +import { resolveClientIp } from "@/lib/client-ip"; +import { classifyDdos, type DdosCategory } from "@/lib/ddos"; +import { rateLimit } from "@/lib/rate-limit"; +import { redis } from "@/lib/redis"; + +export interface DdosDecision { + limited: boolean; + retryAfterSeconds: number; +} + +export interface DdosLimitRule { + limit: number; + windowSeconds: number; +} + +const DEFAULT_LIMITS: Record = { + // 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 }; +const VIOLATION_WINDOW_SECONDS = 600; +const MAX_VIOLATIONS = 10; +const BLOCK_TTL_SECONDS = 600; + +function isEnabled(): boolean { + if (env.NODE_ENV !== "production") return false; + return env.ANTI_DDOS_ENABLED; +} + +/** + * 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. + */ +export async function enforceDdosRateLimit( + req: NextRequest, +): Promise { + if (!isEnabled()) return { limited: false, retryAfterSeconds: 0 }; + + 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 }; + } + } catch { + // fail-open: never let the limiter itself take the site down. + } + } + + const global = await rateLimit( + "antiddos:global:all", + GLOBAL_LIMIT.limit, + GLOBAL_LIMIT.windowSeconds * 1000, + ); + if (!global.ok) { + return { + limited: true, + retryAfterSeconds: Math.max(global.retryAfter, 1), + }; + } + + const category = classifyDdos(req.nextUrl.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 }; + + const violations = await rateLimit( + `antiddos:v:${ip}`, + MAX_VIOLATIONS, + VIOLATION_WINDOW_SECONDS * 1000, + ); + if (!violations.ok && redis) { + try { + await redis.set(blockKey, "1", "EX", BLOCK_TTL_SECONDS); + } catch { + // fail-open — Redis merely unavailable. + } + } + return { + limited: true, + 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", + }, + }); +} diff --git a/src/lib/ddos.test.ts b/src/lib/ddos.test.ts new file mode 100644 index 00000000..a6a00de7 --- /dev/null +++ b/src/lib/ddos.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { classifyDdos } from "@/lib/ddos"; + +describe("classifyDdos", () => { + it.each([ + ["/", "pages"], + ["/community", "pages"], + ["/login", "auth"], + ["/login/", "auth"], + ["/register", "auth"], + ["/admin", "auth"], + ["/admin/home", "auth"], + ["/api/auth/callback/credentials", "auth"], + ["/api/articles/hello", "api"], + ["/api/radio/stream", "api"], + ])(`classifies %s as %s`, (pathname, expected) => { + expect(classifyDdos(pathname)).toBe(expected); + }); +}); diff --git a/src/lib/ddos.ts b/src/lib/ddos.ts new file mode 100644 index 00000000..032eef2b --- /dev/null +++ b/src/lib/ddos.ts @@ -0,0 +1,18 @@ +export type DdosCategory = "pages" | "api" | "auth"; + +export function classifyDdos(pathname: string): DdosCategory { + if ( + pathname === "/api/auth" || + pathname.startsWith("/api/auth/") || + pathname === "/login" || + pathname.startsWith("/login/") || + pathname === "/register" || + pathname.startsWith("/register/") || + pathname === "/admin" || + pathname.startsWith("/admin/") + ) { + return "auth"; + } + if (pathname.startsWith("/api/")) return "api"; + return "pages"; +} diff --git a/src/proxy.ts b/src/proxy.ts index a07b0307..0af53594 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,6 +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 { shouldRedirectAdminRequest } from "@/lib/proxy-access"; const SECURITY_HEADERS: Record = { @@ -14,13 +15,25 @@ const SECURITY_HEADERS: Record = { }; export const proxy = async (req: import("next/server").NextRequest) => { - const token = await getToken({ - req, - secret: env.AUTH_SECRET, - secureCookie: true, - }); + const decision = await enforceDdosRateLimit(req); + if (decision.limited) { + return ddosRejected(decision.retryAfterSeconds); + } - if (shouldRedirectAdminRequest(req.nextUrl.pathname, token)) { + const pathname = req.nextUrl.pathname; + + // Only /admin needs a real session token for the redirect guard; skipping + // JWT decoding on every other request keeps the proxy cheap under load. + const adminPath = pathname === "/admin" || pathname.startsWith("/admin/"); + const token = adminPath + ? await getToken({ + req, + secret: env.AUTH_SECRET, + secureCookie: true, + }) + : null; + + if (shouldRedirectAdminRequest(pathname, token)) { return NextResponse.redirect(new URL("/login", req.url)); } @@ -28,7 +41,7 @@ export const proxy = async (req: import("next/server").NextRequest) => { const csp = buildContentSecurityPolicy(nonce); const headers = new Headers(req.headers); - headers.set("x-pathname", req.nextUrl.pathname); + headers.set("x-pathname", pathname); headers.set("x-nonce", nonce); // A client may supply this legacy derived header; no consumer should trust it. @@ -54,5 +67,7 @@ export const proxy = async (req: import("next/server").NextRequest) => { }; export const config = { - matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico).*)"], + matcher: [ + "/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging).*)", + ], };