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
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:
1 parent
98a184953a
commit
fd4d0fa1cb
4 files changed
+141
-37
No files matched your search
+75
-33
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user