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
File diff suppressed because it is too large. Load diff
+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;