feat(security): harden anti-DDoS gate with scanner triage, tiered blocks and in-process global halt
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m42s
CI / tests-ui (push) Successful in 2m27s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m0s

This commit is contained in:
openhands committed 2026-09-22 21:57:09 +02:00
1 parent 98a184953a
commit fd4d0fa1cb
4 files changed
+142 -38

No files matched your search

+76 -34
View File
@@ -5,14 +5,14 @@ import { NextResponse } from "next/server";
import { env } from "@/env"; import { env } from "@/env";
import { resolveClientIp } from "@/lib/client-ip"; 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 { rateLimit } from "@/lib/rate-limit";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
export interface DdosDecision { export type DdosDecision =
limited: boolean; | { outcome: "pass" }
retryAfterSeconds: number; | { outcome: "suspect" }
} | { outcome: "block"; retryAfterSeconds: number };
export interface DdosLimitRule { export interface DdosLimitRule {
limit: number; limit: number;
@@ -34,32 +34,68 @@ const DEFAULT_LIMITS: Record<DdosCategory, DdosLimitRule> = {
// many IPs, keeping the process and database alive with 429s instead of // many IPs, keeping the process and database alive with 429s instead of
// letting every connection through until the DB melts. // letting every connection through until the DB melts.
const GLOBAL_LIMIT: DdosLimitRule = { limit: 18_000, windowSeconds: 60 }; const GLOBAL_LIMIT: DdosLimitRule = { limit: 18_000, windowSeconds: 60 };
const VIOLATION_WINDOW_SECONDS = 600;
const MAX_VIOLATIONS = 10; // Escalating blocks so persistent / distributed offenders stay off longer
const BLOCK_TTL_SECONDS = 600; // 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 { function isEnabled(): boolean {
if (env.NODE_ENV !== "production") return false; if (env.NODE_ENV !== "production") return false;
return env.ANTI_DDOS_ENABLED; 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 * 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 * Redis/in-memory buckets (so multi-instance deployments share state) and the
* state) and the audited IP resolver. Fails open: if Redis is down, buckets * audited IP resolver. Fails open: if Redis is down, buckets degrade to
* degrade to bounded in-process counters and the block-list is skipped. * 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( export async function enforceDdosRateLimit(
req: NextRequest, req: NextRequest,
): Promise<DdosDecision> { ): Promise<DdosDecision> {
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 ip = resolveClientIp(req.headers);
const blockKey = `antiddos:block:${ip}`; const blockKey = `antiddos:block:${ip}`;
if (redis) { if (redis) {
try { try {
if ((await redis.get(blockKey)) !== null) { if ((await redis.get(blockKey)) !== null) {
return { limited: true, retryAfterSeconds: BLOCK_TTL_SECONDS }; return {
outcome: "block",
retryAfterSeconds: blockTtlForViolations(0),
};
} }
} catch { } catch {
// fail-open: never let the limiter itself take the site down. // fail-open: never let the limiter itself take the site down.
@@ -72,46 +108,52 @@ export async function enforceDdosRateLimit(
GLOBAL_LIMIT.windowSeconds * 1000, GLOBAL_LIMIT.windowSeconds * 1000,
); );
if (!global.ok) { if (!global.ok) {
globalHaltedUntil = now + GLOBAL_HALT_MS;
return { return {
limited: true, outcome: "block",
retryAfterSeconds: Math.max(global.retryAfter, 1), 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 rule = DEFAULT_LIMITS[category];
const bucket = await rateLimit( const bucket = await rateLimit(
`antiddos:${category}:${ip}`, `antiddos:${category}:${ip}`,
rule.limit, rule.limit,
rule.windowSeconds * 1000, rule.windowSeconds * 1000,
); );
if (bucket.ok) return { limited: false, retryAfterSeconds: 0 }; if (bucket.ok) return { outcome: "pass" };
const violations = await rateLimit( let violations = 1;
`antiddos:v:${ip}`, if (redis) {
MAX_VIOLATIONS,
VIOLATION_WINDOW_SECONDS * 1000,
);
if (!violations.ok && redis) {
try { 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 { } catch {
// fail-open — Redis merely unavailable. // fail-open — Redis merely unavailable; in-process buckets still shed.
} }
} }
return { return {
limited: true, outcome: "block",
retryAfterSeconds: Math.max(bucket.retryAfter, 1), retryAfterSeconds: Math.max(bucket.retryAfter, 1),
}; };
} }
export function ddosRejected(retryAfterSeconds: number): NextResponse { export function ddosReject(
return new NextResponse(null, { status: 403 | 429,
status: 429, retryAfterSeconds = 0,
headers: { ): NextResponse {
"Retry-After": String(retryAfterSeconds), const headers: Record<string, string> = {
"X-Rate-Limit": "1", "Cache-Control": "no-store",
"Cache-Control": "no-store", "X-Rate-Limit": "1",
}, };
}); if (retryAfterSeconds > 0) headers["Retry-After"] = String(retryAfterSeconds);
return new NextResponse(null, { status, headers });
} }
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { classifyDdos } from "@/lib/ddos"; import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
describe("classifyDdos", () => { describe("classifyDdos", () => {
it.each([ it.each([
@@ -18,3 +18,26 @@ describe("classifyDdos", () => {
expect(classifyDdos(pathname)).toBe(expected); 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);
});
});
+36
View File
@@ -16,3 +16,39 @@ export function classifyDdos(pathname: string): DdosCategory {
if (pathname.startsWith("/api/")) return "api"; if (pathname.startsWith("/api/")) return "api";
return "pages"; 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;
}
+6 -3
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import { env } from "@/env"; import { env } from "@/env";
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp"; 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"; import { shouldRedirectAdminRequest } from "@/lib/proxy-access";
const SECURITY_HEADERS: Record<string, string> = { const SECURITY_HEADERS: Record<string, string> = {
@@ -16,8 +16,11 @@ const SECURITY_HEADERS: Record<string, string> = {
export const proxy = async (req: import("next/server").NextRequest) => { export const proxy = async (req: import("next/server").NextRequest) => {
const decision = await enforceDdosRateLimit(req); const decision = await enforceDdosRateLimit(req);
if (decision.limited) { if (decision.outcome === "suspect") {
return ddosRejected(decision.retryAfterSeconds); return ddosReject(403);
}
if (decision.outcome === "block") {
return ddosReject(429, decision.retryAfterSeconds);
} }
const pathname = req.nextUrl.pathname; const pathname = req.nextUrl.pathname;