feat(security): Cloudflare-aware IP trust and admin-tunable anti-DDoS
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m50s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m32s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m50s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m32s
- resolveClientIp: trust cf-connecting-ip only behind cf-ray/cdn-loop, use nginx x-real-ip otherwise (anti-spoof) - antiddos-config: Redis-backed live config (antiddos:config) with 30s cache, 13 ANTI_DDOS_* env vars - ddos-guard: consume tunable rates/tiers via getAntiddosConfig - admin panel at /admin/devops/antiddos (save/reset/unban actions, PERMS.SETTINGS_VIEW) - register new admin page in housekeeping migration matrix (146 -> 147)
This commit is contained in:
1 parent
fd4d0fa1cb
commit
f0c27eb815
17 files changed
+1151
-63
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
get: async () => state.value,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
import {
|
||||
antiddosConfigToJson,
|
||||
antiddosDefaultsFromEnv,
|
||||
getAntiddosConfig,
|
||||
invalidateAntiddosConfig,
|
||||
} from "@/lib/antiddos-config";
|
||||
|
||||
describe("antiddos-config", () => {
|
||||
beforeEach(() => {
|
||||
state.value = null;
|
||||
invalidateAntiddosConfig();
|
||||
});
|
||||
|
||||
it("returns sane boot defaults without a live override", async () => {
|
||||
const config = await getAntiddosConfig();
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.pages.limit).toBeGreaterThan(0);
|
||||
expect(config.api.limit).toBeGreaterThan(config.auth.limit);
|
||||
expect(config.blockTiers.length).toBeGreaterThan(0);
|
||||
expect(config.globalHaltMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("applies the admin live override from Redis", async () => {
|
||||
state.value = JSON.stringify({
|
||||
enabled: false,
|
||||
auth: { limit: 5, windowSeconds: 30 },
|
||||
global: { limit: 1000, windowSeconds: 60 },
|
||||
blockTiers: [
|
||||
{ minViolations: 3, ttlSeconds: 120 },
|
||||
{ minViolations: 9, ttlSeconds: 900 },
|
||||
],
|
||||
});
|
||||
const config = await getAntiddosConfig();
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(config.auth.limit).toBe(5);
|
||||
expect(config.auth.windowSeconds).toBe(30);
|
||||
expect(config.pages.limit).toBeGreaterThan(0);
|
||||
expect(config.blockTiers.map((t) => t.minViolations)).toEqual([3, 9]);
|
||||
});
|
||||
|
||||
it("sanitizes malformed overrides instead of trusting them", async () => {
|
||||
state.value = JSON.stringify({
|
||||
enabled: true,
|
||||
pages: { limit: -5, windowSeconds: "x" },
|
||||
blockTiers: "garbage",
|
||||
});
|
||||
const config = await getAntiddosConfig();
|
||||
expect(config.pages.limit).toBeGreaterThan(0);
|
||||
expect(Array.isArray(config.blockTiers)).toBe(true);
|
||||
expect(config.blockTiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("stores the config JSON serialization", () => {
|
||||
const raw = JSON.parse(antiddosConfigToJson(antiddosDefaultsFromEnv()));
|
||||
expect(raw.enabled).toBe(true);
|
||||
expect(typeof raw.pages.limit).toBe("number");
|
||||
expect(Array.isArray(raw.blockTiers)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
export interface AntiddosCategoryConfig {
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
export interface AntiddosBlockTier {
|
||||
minViolations: number;
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface AntiddosConfig {
|
||||
enabled: boolean;
|
||||
pages: AntiddosCategoryConfig;
|
||||
api: AntiddosCategoryConfig;
|
||||
auth: AntiddosCategoryConfig;
|
||||
global: AntiddosCategoryConfig;
|
||||
violationWindowSeconds: number;
|
||||
maxViolations: number;
|
||||
blockTiers: AntiddosBlockTier[];
|
||||
globalHaltMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AntiddosConfig = {
|
||||
enabled: true,
|
||||
pages: { limit: 300, windowSeconds: 60 },
|
||||
api: { limit: 600, windowSeconds: 60 },
|
||||
auth: { limit: 20, windowSeconds: 60 },
|
||||
global: { limit: 18_000, windowSeconds: 60 },
|
||||
violationWindowSeconds: 600,
|
||||
maxViolations: 10,
|
||||
blockTiers: [
|
||||
{ minViolations: 5, ttlSeconds: 600 },
|
||||
{ minViolations: 20, ttlSeconds: 3_600 },
|
||||
{ minViolations: 50, ttlSeconds: 86_400 },
|
||||
],
|
||||
globalHaltMs: 10_000,
|
||||
};
|
||||
|
||||
function positiveInt(value: number | undefined, fallback: number): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null {
|
||||
if (!raw?.trim()) return null;
|
||||
const tiers: AntiddosBlockTier[] = [];
|
||||
for (const part of raw.split(",")) {
|
||||
const [minRaw, ttlRaw] = part.split(":");
|
||||
const min = Number(minRaw);
|
||||
const ttl = Number(ttlRaw);
|
||||
if (!Number.isFinite(min) || !Number.isFinite(ttl) || ttl <= 0) return null;
|
||||
tiers.push({
|
||||
minViolations: Math.max(1, Math.floor(min)),
|
||||
ttlSeconds: ttl,
|
||||
});
|
||||
}
|
||||
if (tiers.length === 0) return null;
|
||||
tiers.sort((a, b) => a.minViolations - b.minViolations);
|
||||
return tiers;
|
||||
}
|
||||
|
||||
/** Boot defaults from environment (explicitly set → overrides code; unset → sane value). */
|
||||
export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
||||
const tiers = parseTiers(env.ANTI_DDOS_BLOCK_TIERS);
|
||||
return {
|
||||
enabled: env.ANTI_DDOS_ENABLED !== false,
|
||||
pages: {
|
||||
limit: positiveInt(env.ANTI_DDOS_PAGES_LIMIT, DEFAULT_CONFIG.pages.limit),
|
||||
windowSeconds: positiveInt(
|
||||
env.ANTI_DDOS_PAGES_WINDOW_SEC,
|
||||
DEFAULT_CONFIG.pages.windowSeconds,
|
||||
),
|
||||
},
|
||||
api: {
|
||||
limit: positiveInt(env.ANTI_DDOS_API_LIMIT, DEFAULT_CONFIG.api.limit),
|
||||
windowSeconds: positiveInt(
|
||||
env.ANTI_DDOS_API_WINDOW_SEC,
|
||||
DEFAULT_CONFIG.api.windowSeconds,
|
||||
),
|
||||
},
|
||||
auth: {
|
||||
limit: positiveInt(env.ANTI_DDOS_AUTH_LIMIT, DEFAULT_CONFIG.auth.limit),
|
||||
windowSeconds: positiveInt(
|
||||
env.ANTI_DDOS_AUTH_WINDOW_SEC,
|
||||
DEFAULT_CONFIG.auth.windowSeconds,
|
||||
),
|
||||
},
|
||||
global: {
|
||||
limit: positiveInt(
|
||||
env.ANTI_DDOS_GLOBAL_LIMIT,
|
||||
DEFAULT_CONFIG.global.limit,
|
||||
),
|
||||
windowSeconds: positiveInt(
|
||||
env.ANTI_DDOS_GLOBAL_WINDOW_SEC,
|
||||
DEFAULT_CONFIG.global.windowSeconds,
|
||||
),
|
||||
},
|
||||
violationWindowSeconds: positiveInt(
|
||||
env.ANTI_DDOS_VIOLATION_WINDOW_SEC,
|
||||
DEFAULT_CONFIG.violationWindowSeconds,
|
||||
),
|
||||
maxViolations: positiveInt(
|
||||
env.ANTI_DDOS_MAX_VIOLATIONS,
|
||||
DEFAULT_CONFIG.maxViolations,
|
||||
),
|
||||
blockTiers: tiers ?? DEFAULT_CONFIG.blockTiers,
|
||||
globalHaltMs: positiveInt(
|
||||
env.ANTI_DDOS_GLOBAL_HALT_MS,
|
||||
DEFAULT_CONFIG.globalHaltMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const OVERRIDE_KEY = "antiddos:config";
|
||||
const MEMORY_TTL_MS = 30_000;
|
||||
const ABSENT_CACHE_MS = 30_000;
|
||||
|
||||
let cachedAt = 0;
|
||||
let cachedConfig: AntiddosConfig | null = null;
|
||||
|
||||
function sanitize(config: AntiddosConfig): AntiddosConfig {
|
||||
const base = antiddosDefaultsFromEnv();
|
||||
const cat = (
|
||||
c: AntiddosCategoryConfig,
|
||||
fallback: AntiddosCategoryConfig,
|
||||
): AntiddosCategoryConfig => ({
|
||||
limit: positiveInt(c?.limit, fallback.limit),
|
||||
windowSeconds: positiveInt(c?.windowSeconds, fallback.windowSeconds),
|
||||
});
|
||||
return {
|
||||
enabled: Boolean(config?.enabled),
|
||||
pages: cat(config?.pages, base.pages),
|
||||
api: cat(config?.api, base.api),
|
||||
auth: cat(config?.auth, base.auth),
|
||||
global: cat(config?.global, base.global),
|
||||
violationWindowSeconds: positiveInt(
|
||||
config?.violationWindowSeconds,
|
||||
base.violationWindowSeconds,
|
||||
),
|
||||
maxViolations: positiveInt(config?.maxViolations, base.maxViolations),
|
||||
blockTiers:
|
||||
Array.isArray(config?.blockTiers) && config.blockTiers.length > 0
|
||||
? config.blockTiers
|
||||
.filter((t) => t && t.ttlSeconds > 0)
|
||||
.map((t) => ({
|
||||
minViolations: positiveInt(t.minViolations, 1),
|
||||
ttlSeconds: positiveInt(t.ttlSeconds, 600),
|
||||
}))
|
||||
.sort((a, b) => a.minViolations - b.minViolations)
|
||||
: base.blockTiers,
|
||||
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective anti-DDoS configuration. The admin panel writes the full JSON to
|
||||
* the Redis `antiddos:config` key (and mirrors it into site settings for
|
||||
* durability); the proxy reads it with a short in-process TTL so the running
|
||||
* deployment picks changes up quickly. On Redis miss it returns the env-derived
|
||||
* boot defaults.
|
||||
*/
|
||||
export async function getAntiddosConfig(): Promise<AntiddosConfig> {
|
||||
const now = Date.now();
|
||||
if (cachedConfig !== null && now - cachedAt < MEMORY_TTL_MS) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(OVERRIDE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<AntiddosConfig>;
|
||||
const config = sanitize(parsed as AntiddosConfig);
|
||||
cachedConfig = config;
|
||||
cachedAt = now;
|
||||
return config;
|
||||
}
|
||||
} catch {
|
||||
// fall through to env defaults; stale in-process config kept serving.
|
||||
}
|
||||
}
|
||||
|
||||
if (now - cachedAt < ABSENT_CACHE_MS && cachedConfig !== null) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
const config = antiddosDefaultsFromEnv();
|
||||
cachedConfig = config;
|
||||
cachedAt = now;
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Reset the in-process view (after the admin writes a new config). */
|
||||
export function invalidateAntiddosConfig(): void {
|
||||
cachedConfig = null;
|
||||
cachedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the live config for the `antiddos:config` value the admin persists
|
||||
* and the proxy consumes.
|
||||
*/
|
||||
export function antiddosConfigToJson(config: AntiddosConfig): string {
|
||||
return JSON.stringify(config);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ describe("client IP security consumers", () => {
|
||||
headers: {
|
||||
"x-real-client-ip": "198.51.100.99",
|
||||
"cf-connecting-ip": "2001:DB8:0:0::1",
|
||||
"cf-ray": "abc123-FRA",
|
||||
"x-forwarded-for": "192.0.2.10",
|
||||
},
|
||||
expected: "2001:db8::1",
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("normalized client IP addresses", () => {
|
||||
expect(normalizeClientIp(input)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the first forwarded address after an invalid higher-priority header", () => {
|
||||
it("falls back to the trusted ingress header when a spoofed Cloudflare header lacks cf-ray", () => {
|
||||
expect(
|
||||
resolveClientIp(
|
||||
new Headers({
|
||||
@@ -44,6 +44,20 @@ describe("normalized client IP addresses", () => {
|
||||
"x-real-client-ip": "198.51.100.99",
|
||||
}),
|
||||
),
|
||||
).toBe("192.0.2.30");
|
||||
});
|
||||
|
||||
it("ignores an invalid Cloudflare header on proxied traffic and uses the first forwarding entry", () => {
|
||||
expect(
|
||||
resolveClientIp(
|
||||
new Headers({
|
||||
"cf-ray": "8a9b-AMS",
|
||||
"cf-connecting-ip": "invalid",
|
||||
"x-forwarded-for": " 192.0.2.10, 192.0.2.20 ",
|
||||
"x-real-ip": "192.0.2.30",
|
||||
"x-real-client-ip": "198.51.100.99",
|
||||
}),
|
||||
),
|
||||
).toBe("192.0.2.10");
|
||||
});
|
||||
|
||||
|
||||
+17
-2
@@ -1,4 +1,5 @@
|
||||
import { isIP } from "node:net";
|
||||
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||
|
||||
export const UNKNOWN_CLIENT_IP = "0.0.0.0";
|
||||
|
||||
@@ -26,12 +27,26 @@ export function normalizeClientIp(
|
||||
* Forwarded headers must be overwritten by a trusted ingress and the origin must
|
||||
* reject direct public access. Header syntax alone cannot establish peer trust.
|
||||
* Never consume x-real-client-ip: API routes bypass the proxy that once set it.
|
||||
*
|
||||
* Trust order is Cloudflare-aware: only when a request demonstrably arrived via
|
||||
* Cloudflare (CF-Connecting-IP / CF-Ray / CDN-Loop) is `CF-Connecting-IP` used.
|
||||
* Otherwise the client-supplied CF header is ignored and only the ingress-set
|
||||
* `X-Real-IP` (`$remote_addr`) / `X-Forwarded-For` are trusted, so a DDoS that
|
||||
* hits the origin directly cannot re-key itself behind a spoofed header.
|
||||
*/
|
||||
export function resolveClientIp(headers: Pick<Headers, "get">): string {
|
||||
const cf = normalizeClientIp(headers.get("cf-connecting-ip"));
|
||||
if (isCloudflareProxied(headers)) {
|
||||
return (
|
||||
cf ??
|
||||
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
||||
normalizeClientIp(headers.get("x-real-ip")) ??
|
||||
UNKNOWN_CLIENT_IP
|
||||
);
|
||||
}
|
||||
return (
|
||||
normalizeClientIp(headers.get("cf-connecting-ip")) ??
|
||||
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
||||
normalizeClientIp(headers.get("x-real-ip")) ??
|
||||
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
||||
UNKNOWN_CLIENT_IP
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveClientIp } from "@/lib/client-ip";
|
||||
import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare";
|
||||
|
||||
function headers(entries: Record<string, string>): Headers {
|
||||
const h = new Headers();
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
if (v === "") h.set(k, "");
|
||||
else h.set(k, v);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
describe("isCloudflareProxied", () => {
|
||||
it("detects Cloudflare from edge-stamped cf-ray or cdn-loop", () => {
|
||||
expect(isCloudflareProxied(headers({ "cf-ray": "abc123-FRA" }))).toBe(true);
|
||||
expect(isCloudflareProxied(headers({ "cdn-loop": "cloudflare" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isCloudflareProxied(
|
||||
headers({ "cf-connecting-ip": "1.2.3.4", "cf-ray": "abc-FRA" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a bare cf-connecting-ip as spoofable and not proof", () => {
|
||||
expect(
|
||||
isCloudflareProxied(headers({ "cf-connecting-ip": "1.2.3.4" })),
|
||||
).toBe(false);
|
||||
expect(isCloudflareProxied(headers({ "x-forwarded-for": "1.2.3.4" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCloudflareProxied(headers({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preferredClientIpHeader", () => {
|
||||
it("prefers cf-connecting-ip behind Cloudflare", () => {
|
||||
expect(
|
||||
preferredClientIpHeader(
|
||||
headers({ "cf-connecting-ip": "1.2.3.4", "cf-ray": "abc-FRA" }),
|
||||
),
|
||||
).toBe("cf-connecting-ip");
|
||||
});
|
||||
|
||||
it("prefers ingress x-real-ip outside Cloudflare", () => {
|
||||
expect(
|
||||
preferredClientIpHeader(
|
||||
headers({
|
||||
"x-real-ip": "198.51.100.9",
|
||||
"cf-connecting-ip": "20.0.0.1",
|
||||
}),
|
||||
),
|
||||
).toBe("x-real-ip");
|
||||
expect(
|
||||
preferredClientIpHeader(headers({ "x-forwarded-for": "192.0.2.1" })),
|
||||
).toBe("x-forwarded-for");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClientIp cloudflare-aware trust order", () => {
|
||||
it("trusts cf-connecting-ip only behind Cloudflare", () => {
|
||||
expect(
|
||||
resolveClientIp(
|
||||
headers({
|
||||
"cf-connecting-ip": "20.0.0.1",
|
||||
"x-real-ip": "192.0.2.30",
|
||||
}),
|
||||
),
|
||||
).toBe("192.0.2.30");
|
||||
expect(
|
||||
resolveClientIp(
|
||||
headers({
|
||||
"cf-connecting-ip": "20.0.0.1",
|
||||
"cf-ray": "abc-FRA",
|
||||
"x-real-ip": "192.0.2.30",
|
||||
}),
|
||||
),
|
||||
).toBe("20.0.0.1");
|
||||
});
|
||||
|
||||
it("drops client spoofed cf-connecting-ip when not proxied", () => {
|
||||
expect(resolveClientIp(headers({ "cf-connecting-ip": "20.0.0.1" }))).toBe(
|
||||
"0.0.0.0",
|
||||
);
|
||||
expect(
|
||||
resolveClientIp(
|
||||
headers({
|
||||
"cf-connecting-ip": "20.0.0.1",
|
||||
"x-forwarded-for": "192.0.2.10",
|
||||
}),
|
||||
),
|
||||
).toBe("192.0.2.10");
|
||||
});
|
||||
|
||||
it("keeps normalized IPv6 and fallbacks identical to the audited resolver", () => {
|
||||
expect(
|
||||
resolveClientIp(
|
||||
headers({
|
||||
"cf-connecting-ip": "2001:DB8:0:0::1",
|
||||
"cf-ray": "abc-FRA",
|
||||
"x-forwarded-for": "192.0.2.10",
|
||||
}),
|
||||
),
|
||||
).toBe("2001:db8::1");
|
||||
expect(
|
||||
resolveClientIp(
|
||||
headers({
|
||||
"cf-connecting-ip": "",
|
||||
"x-forwarded-for": "malformed, 192.0.2.10",
|
||||
"x-real-ip": "192.0.2.30",
|
||||
}),
|
||||
),
|
||||
).toBe("192.0.2.30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Cloudflare presence detection.
|
||||
*
|
||||
* Only headers that a Cloudflare edge adds to every transit are treated as
|
||||
* proof the request passed through Cloudflare: `CF-Ray` is stamped by the
|
||||
* edge and `CDN-Loop: cloudflare` is prepended on CDN transits. A bare
|
||||
* `CF-Connecting-IP` is *not* sufficient — a direct client to the origin can
|
||||
* send that header itself — so it is only trusted in combination with one of
|
||||
* the edge-stamped fingerprints.
|
||||
*/
|
||||
export function isCloudflareProxied(headers: Pick<Headers, "get">): boolean {
|
||||
const ray = headers.get("cf-ray")?.trim();
|
||||
if (ray) return true;
|
||||
const loop = headers.get("cdn-loop")?.trim();
|
||||
if (loop) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which client-IP header the stack should trust for a given request. When the
|
||||
* request demonstrably transited Cloudflare, `CF-Connecting-IP` carries the
|
||||
* real client; otherwise only the ingress-set `X-Real-IP` / `X-Forwarded-For`
|
||||
* (derived by nginx from the actual TCP peer) may be trusted.
|
||||
*/
|
||||
export function preferredClientIpHeader(
|
||||
headers: Pick<Headers, "get">,
|
||||
): "cf-connecting-ip" | "x-forwarded-for" | "x-real-ip" | "none" {
|
||||
if (isCloudflareProxied(headers)) return "cf-connecting-ip";
|
||||
if (headers.get("x-real-ip")?.trim()) return "x-real-ip";
|
||||
if (headers.get("x-forwarded-for")?.trim()) return "x-forwarded-for";
|
||||
return "none";
|
||||
}
|
||||
+28
-52
@@ -2,10 +2,10 @@ import "server-only";
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
||||
import { resolveClientIp } from "@/lib/client-ip";
|
||||
import { classifyDdos, type DdosCategory, isSuspiciousPath } from "@/lib/ddos";
|
||||
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
@@ -14,52 +14,22 @@ export type DdosDecision =
|
||||
| { outcome: "suspect" }
|
||||
| { outcome: "block"; retryAfterSeconds: number };
|
||||
|
||||
export interface DdosLimitRule {
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: Record<DdosCategory, DdosLimitRule> = {
|
||||
// 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 };
|
||||
|
||||
// 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) {
|
||||
// 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.
|
||||
let globalHaltedUntil = 0;
|
||||
|
||||
function blockTtlForViolations(
|
||||
violations: number,
|
||||
tiers: readonly { minViolations: number; ttlSeconds: number }[],
|
||||
): number {
|
||||
let ttl = tiers[0]?.ttlSeconds ?? 600;
|
||||
for (const tier of tiers) {
|
||||
if (violations >= tier.minViolations) ttl = tier.ttlSeconds;
|
||||
}
|
||||
return ttl;
|
||||
@@ -71,12 +41,16 @@ function blockTtlForViolations(violations: number): number {
|
||||
* 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.
|
||||
* Tunables come from the anti-DDoS config (env boot defaults, live-overridden
|
||||
* by the admin panel via Redis). `/api/health` is exempt so the Docker
|
||||
* liveness probe never trips the gate.
|
||||
*/
|
||||
export async function enforceDdosRateLimit(
|
||||
req: NextRequest,
|
||||
): Promise<DdosDecision> {
|
||||
if (!isEnabled()) return { outcome: "pass" };
|
||||
const config = await getAntiddosConfig();
|
||||
if (!config.enabled) return { outcome: "pass" };
|
||||
|
||||
const pathname = req.nextUrl.pathname;
|
||||
if (pathname === "/api/health") return { outcome: "pass" };
|
||||
@@ -94,7 +68,7 @@ export async function enforceDdosRateLimit(
|
||||
if ((await redis.get(blockKey)) !== null) {
|
||||
return {
|
||||
outcome: "block",
|
||||
retryAfterSeconds: blockTtlForViolations(0),
|
||||
retryAfterSeconds: blockTtlForViolations(0, config.blockTiers),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -104,11 +78,11 @@ export async function enforceDdosRateLimit(
|
||||
|
||||
const global = await rateLimit(
|
||||
"antiddos:global:all",
|
||||
GLOBAL_LIMIT.limit,
|
||||
GLOBAL_LIMIT.windowSeconds * 1000,
|
||||
config.global.limit,
|
||||
config.global.windowSeconds * 1000,
|
||||
);
|
||||
if (!global.ok) {
|
||||
globalHaltedUntil = now + GLOBAL_HALT_MS;
|
||||
globalHaltedUntil = now + config.globalHaltMs;
|
||||
return {
|
||||
outcome: "block",
|
||||
retryAfterSeconds: Math.max(global.retryAfter, 1),
|
||||
@@ -117,7 +91,7 @@ export async function enforceDdosRateLimit(
|
||||
if (globalHaltedUntil !== 0) globalHaltedUntil = 0;
|
||||
|
||||
const category = classifyDdos(pathname);
|
||||
const rule = DEFAULT_LIMITS[category];
|
||||
const rule = config[category];
|
||||
const bucket = await rateLimit(
|
||||
`antiddos:${category}:${ip}`,
|
||||
rule.limit,
|
||||
@@ -131,10 +105,12 @@ export async function enforceDdosRateLimit(
|
||||
const counterKey = `antiddos:v:${ip}`;
|
||||
violations = await redis.incr(counterKey);
|
||||
if (violations === 1) {
|
||||
await redis.pexpire(counterKey, VIOLATION_COUNTER_TTL_SECONDS * 1000);
|
||||
await redis.pexpire(counterKey, config.violationWindowSeconds * 1000);
|
||||
}
|
||||
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
||||
if (violations >= config.maxViolations) {
|
||||
await redis.set(blockKey, "1", "EX", ttl);
|
||||
}
|
||||
const ttl = blockTtlForViolations(violations);
|
||||
await redis.set(blockKey, "1", "EX", ttl);
|
||||
return { outcome: "block", retryAfterSeconds: ttl };
|
||||
} catch {
|
||||
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
||||
|
||||
Reference in new issue
Block a user