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
+1149
-61
No files matched your search
@@ -0,0 +1,201 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { like } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
type AntiddosBlockTier,
|
||||||
|
type AntiddosConfig,
|
||||||
|
antiddosConfigToJson,
|
||||||
|
antiddosDefaultsFromEnv,
|
||||||
|
invalidateAntiddosConfig,
|
||||||
|
} from "@/lib/antiddos-config";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
const OVERRIDE_KEY = "antiddos:config";
|
||||||
|
|
||||||
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInt(raw: FormDataEntryValue | null, fallback: number): number {
|
||||||
|
const n = Number(str(raw));
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
|
||||||
|
const tiers: AntiddosBlockTier[] = [];
|
||||||
|
for (const part of str(raw).split(",")) {
|
||||||
|
const [minRaw, ttlRaw] = part.split(":");
|
||||||
|
const min = Number(minRaw);
|
||||||
|
const ttl = Number(ttlRaw);
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(ttl) || ttl <= 0) continue;
|
||||||
|
tiers.push({
|
||||||
|
minViolations: Math.max(1, Math.floor(min)),
|
||||||
|
ttlSeconds: Math.floor(ttl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tiers.sort((a, b) => a.minViolations - b.minViolations);
|
||||||
|
return tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFromForm(formData: FormData): AntiddosConfig {
|
||||||
|
const defaults = antiddosDefaultsFromEnv();
|
||||||
|
const tierRaw = str(formData.get("block_tiers")).trim();
|
||||||
|
return {
|
||||||
|
enabled: str(formData.get("enabled")) === "1",
|
||||||
|
pages: {
|
||||||
|
limit: positiveInt(formData.get("pages_limit"), defaults.pages.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("pages_window_sec"),
|
||||||
|
defaults.pages.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
limit: positiveInt(formData.get("api_limit"), defaults.api.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("api_window_sec"),
|
||||||
|
defaults.api.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
limit: positiveInt(formData.get("auth_limit"), defaults.auth.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("auth_window_sec"),
|
||||||
|
defaults.auth.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
limit: positiveInt(formData.get("global_limit"), defaults.global.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("global_window_sec"),
|
||||||
|
defaults.global.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
violationWindowSeconds: positiveInt(
|
||||||
|
formData.get("violation_window_sec"),
|
||||||
|
defaults.violationWindowSeconds,
|
||||||
|
),
|
||||||
|
maxViolations: positiveInt(
|
||||||
|
formData.get("max_violations"),
|
||||||
|
defaults.maxViolations,
|
||||||
|
),
|
||||||
|
blockTiers:
|
||||||
|
tierRaw.length > 0
|
||||||
|
? parseTiers(formData.get("block_tiers"))
|
||||||
|
: defaults.blockTiers,
|
||||||
|
globalHaltMs: positiveInt(
|
||||||
|
formData.get("global_halt_ms"),
|
||||||
|
defaults.globalHaltMs,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSettings(config: AntiddosConfig): Promise<void> {
|
||||||
|
const entries: [string, string][] = [
|
||||||
|
["antiddos_enabled", config.enabled ? "1" : "0"],
|
||||||
|
["antiddos_pages_limit", String(config.pages.limit)],
|
||||||
|
["antiddos_pages_window_sec", String(config.pages.windowSeconds)],
|
||||||
|
["antiddos_api_limit", String(config.api.limit)],
|
||||||
|
["antiddos_api_window_sec", String(config.api.windowSeconds)],
|
||||||
|
["antiddos_auth_limit", String(config.auth.limit)],
|
||||||
|
["antiddos_auth_window_sec", String(config.auth.windowSeconds)],
|
||||||
|
["antiddos_global_limit", String(config.global.limit)],
|
||||||
|
["antiddos_global_window_sec", String(config.global.windowSeconds)],
|
||||||
|
["antiddos_violation_window_sec", String(config.violationWindowSeconds)],
|
||||||
|
["antiddos_max_violations", String(config.maxViolations)],
|
||||||
|
[
|
||||||
|
"antiddos_block_tiers",
|
||||||
|
config.blockTiers
|
||||||
|
.map((t) => `${t.minViolations}:${t.ttlSeconds}`)
|
||||||
|
.join(","),
|
||||||
|
],
|
||||||
|
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
||||||
|
];
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(([key, value]) =>
|
||||||
|
db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment: "Anti-DDoS protection" })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save anti-DDoS settings from the admin panel. Persists to site settings
|
||||||
|
* (durable across Redis flushes) and pushes the same config to the Redis
|
||||||
|
* override the proxy reads, so the change is live within the proxy cache TTL.
|
||||||
|
*/
|
||||||
|
export async function saveAntiddosSettings(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
const config = configFromForm(formData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistSettings(config);
|
||||||
|
if (redis) {
|
||||||
|
await redis.set(OVERRIDE_KEY, antiddosConfigToJson(config));
|
||||||
|
}
|
||||||
|
invalidateAntiddosConfig();
|
||||||
|
siteSettings.reload();
|
||||||
|
logger.info("Anti-DDoS settings updated", {
|
||||||
|
staff: staff.username,
|
||||||
|
enabled: config.enabled,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to save anti-DDoS settings", { err });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert to the boot defaults (env) — drop the Redis live override and the
|
||||||
|
* DB-persisted settings. The gate immediately falls back to env ANTI_DDOS_*.
|
||||||
|
*/
|
||||||
|
export async function resetAntiddosSettings(): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (redis) await redis.del(OVERRIDE_KEY);
|
||||||
|
// Remove every persisted antiddos_* row.
|
||||||
|
await db
|
||||||
|
.delete(WebsiteSetting)
|
||||||
|
.where(like(WebsiteSetting.key, "antiddos_%"));
|
||||||
|
invalidateAntiddosConfig();
|
||||||
|
siteSettings.reload();
|
||||||
|
logger.info("Anti-DDoS settings reset to defaults", {
|
||||||
|
staff: staff.username,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to reset anti-DDoS settings", { err });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a single IP from the temporary DDoS block list. */
|
||||||
|
export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
const ip = str(formData.get("ip")).trim();
|
||||||
|
if (!ip) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (redis) {
|
||||||
|
await Promise.all([
|
||||||
|
redis.del(`antiddos:block:${ip}`),
|
||||||
|
redis.del(`antiddos:v:${ip}`),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
logger.info("Anti-DDoS block manually removed", {
|
||||||
|
staff: staff.username,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to remove anti-DDoS block", { err, ip });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
resetAntiddosSettings,
|
||||||
|
saveAntiddosSettings,
|
||||||
|
unbanAntiddosIp,
|
||||||
|
} from "@/actions/admin-antiddos";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
antiddosDefaultsFromEnv,
|
||||||
|
getAntiddosConfig,
|
||||||
|
} from "@/lib/antiddos-config";
|
||||||
|
import { resolveClientIp } from "@/lib/client-ip";
|
||||||
|
import { isCloudflareProxied, preferredClientIpHeader } from "@/lib/cloudflare";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
|
function seconds(ttlMs: number): string {
|
||||||
|
const s = Math.floor(ttlMs / 1000);
|
||||||
|
if (s <= 0) return "–";
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""}`;
|
||||||
|
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminAntiDdosPage() {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [effective, defaults, requestHeaders, persistedRows] =
|
||||||
|
await Promise.all([
|
||||||
|
getAntiddosConfig(),
|
||||||
|
antiddosDefaultsFromEnv(),
|
||||||
|
headers(),
|
||||||
|
db
|
||||||
|
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
|
||||||
|
.from(WebsiteSetting)
|
||||||
|
.then((rows) => new Map(rows.map((r) => [r.key, r.value])))
|
||||||
|
.catch(() => new Map() as Map<string, string>),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cloudflare = isCloudflareProxied(requestHeaders);
|
||||||
|
const sourceHeader = preferredClientIpHeader(requestHeaders);
|
||||||
|
const viewerIp = resolveClientIp(requestHeaders);
|
||||||
|
|
||||||
|
let blocks: { ip: string; ttlMs: number; count: number }[] = [];
|
||||||
|
let redisOk = false;
|
||||||
|
const rateStore = redis;
|
||||||
|
if (rateStore) {
|
||||||
|
redisOk = true;
|
||||||
|
try {
|
||||||
|
const blockKeys = await rateStore.keys("antiddos:block:*");
|
||||||
|
const violationKeys = await rateStore.keys("antiddos:v:*");
|
||||||
|
const violationCounts = new Map<string, number>();
|
||||||
|
for (const key of violationKeys.slice(0, 200)) {
|
||||||
|
// incr is used on writes; for display we just read the raw value.
|
||||||
|
const raw = await rateStore.get(key);
|
||||||
|
const n = Number(raw);
|
||||||
|
violationCounts.set(
|
||||||
|
key.replace(`antiddos:v:`, ""),
|
||||||
|
Number.isFinite(n) ? n : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const withTtl = await Promise.all(
|
||||||
|
blockKeys.slice(0, 100).map(async (key) => {
|
||||||
|
const ttlMs = await rateStore.pttl(key);
|
||||||
|
return {
|
||||||
|
ip: key.replace("antiddos:block:", ""),
|
||||||
|
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
||||||
|
count: violationCounts.get(key.replace("antiddos:block:", "")) ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
blocks = withTtl
|
||||||
|
.filter((b) => b.ttlMs > 0)
|
||||||
|
.sort((a, b) => a.ttlMs - b.ttlMs);
|
||||||
|
} catch {
|
||||||
|
redisOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = persistedRows;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Gate</CardTitle>
|
||||||
|
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant={effective.enabled ? "default" : "secondary"}>
|
||||||
|
{effective.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Boot default: {defaults.enabled ? "on" : "off"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Rates</CardTitle>
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{effective.api.limit}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
/min API
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Pages {effective.pages.limit} · Auth {effective.auth.limit} ·
|
||||||
|
Global {effective.global.limit}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Cloudflare</CardTitle>
|
||||||
|
<Cloud className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant={cloudflare ? "default" : "secondary"}>
|
||||||
|
{cloudflare ? "Detected" : "Not detected"}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
IP source: {sourceHeader}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active blocks</CardTitle>
|
||||||
|
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{
|
||||||
|
color: blocks.length ? "var(--destructive)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{blocks.length}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Temporary DDoS blocks
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Redis</CardTitle>
|
||||||
|
<BadgeCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant={redisOk ? "default" : "destructive"}>
|
||||||
|
{redisOk ? "Connected" : "Unavailable"}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Shared rate-limit state
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!cloudflare && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Cloud className="h-4 w-4" /> Cloudflare not detected
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This request did not arrive through Cloudflare. When the site DNS
|
||||||
|
is proxied (orange cloud), the CMS trusts the real visitor IP from{" "}
|
||||||
|
<span className="font-mono">CF-Connecting-IP</span>. A direct
|
||||||
|
client can spoof that header — put the origin behind Cloudflare
|
||||||
|
and restrict direct access to the origin ports for full DDoS
|
||||||
|
protection.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Your request is keyed as{" "}
|
||||||
|
<span className="font-mono">{viewerIp}</span> (via{" "}
|
||||||
|
<span className="font-mono">{sourceHeader}</span>).
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-4 w-4" /> Anti-DDoS settings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={saveAntiddosSettings} className="space-y-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="enabled"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={effective.enabled}
|
||||||
|
/>
|
||||||
|
Enable the app-layer anti-DDoS gate (production only)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["pages", "Pages", "pages_limit", "pages_window_sec"],
|
||||||
|
["api", "API", "api_limit", "api_window_sec"],
|
||||||
|
["auth", "Auth", "auth_limit", "auth_window_sec"],
|
||||||
|
] as const
|
||||||
|
).map(([category, label, limitName, windowName]) => (
|
||||||
|
<div key={category} className="rounded-md border p-3">
|
||||||
|
<p className="font-semibold text-sm mb-2">{label}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
name={limitName}
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective[category].limit}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
req/min
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name={windowName}
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective[category].windowSeconds}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
sec window
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Global valve (req/min)
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
name="global_limit"
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective.global.limit}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="global_window_sec"
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective.global.windowSeconds}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
sec window
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Global halt short-circuit (ms)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="global_halt_ms"
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective.globalHaltMs}
|
||||||
|
className="w-32 mt-1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Violation window (sec)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="violation_window_sec"
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective.violationWindowSeconds}
|
||||||
|
className="w-32 mt-1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Violations before block
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="max_violations"
|
||||||
|
type="number"
|
||||||
|
defaultValue={effective.maxViolations}
|
||||||
|
className="w-32 mt-1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Escalation tiers (min:ttlSeconds, comma separated)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="block_tiers"
|
||||||
|
defaultValue={effective.blockTiers
|
||||||
|
.map((t) => `${t.minViolations}:${t.ttlSeconds}`)
|
||||||
|
.join(",")}
|
||||||
|
className="w-full mt-1 font-mono"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Boot default:{" "}
|
||||||
|
{defaults.blockTiers
|
||||||
|
.map((t) => `${t.minViolations}:${t.ttlSeconds}`)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" variant="default">
|
||||||
|
Save settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={resetAntiddosSettings} className="mt-4">
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Reset to boot defaults (env)
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4" /> Blocked IPs ({blocks.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{blocks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No IPs are currently rate-limited into a temporary block.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{blocks.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.ip}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{b.ip}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
TTL {seconds(b.ttlMs)} · violations {b.count}
|
||||||
|
</span>
|
||||||
|
<form action={unbanAntiddosIp}>
|
||||||
|
<input type="hidden" name="ip" value={b.ip} />
|
||||||
|
<Button type="submit" size="sm" variant="outline">
|
||||||
|
Unban
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{stored.size === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Persisted site settings: none yet — the form values above reflect the
|
||||||
|
current effective configuration.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,9 @@ export default async function DevOpsPage() {
|
|||||||
<Link href="/admin/devops/deliveries" className="btn btn-outline">
|
<Link href="/admin/devops/deliveries" className="btn btn-outline">
|
||||||
{deliveries("title")}
|
{deliveries("title")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/devops/antiddos" className="btn btn-outline">
|
||||||
|
Anti-DDoS protection
|
||||||
|
</Link>
|
||||||
{/* Status cards */}
|
{/* Status cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
+23
@@ -106,6 +106,29 @@ const schema = z
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value !== "false" && value !== "0"),
|
.transform((value) => value !== "false" && value !== "0"),
|
||||||
|
// Anti-DDoS thresholds. These are the YAML-file boot defaults; the admin
|
||||||
|
// panel can override them at runtime (Redis `antiddos:config`).
|
||||||
|
ANTI_DDOS_PAGES_LIMIT: z.coerce.number().int().positive().default(300),
|
||||||
|
ANTI_DDOS_PAGES_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
||||||
|
ANTI_DDOS_API_LIMIT: z.coerce.number().int().positive().default(600),
|
||||||
|
ANTI_DDOS_API_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
||||||
|
ANTI_DDOS_AUTH_LIMIT: z.coerce.number().int().positive().default(20),
|
||||||
|
ANTI_DDOS_AUTH_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
||||||
|
ANTI_DDOS_GLOBAL_LIMIT: z.coerce.number().int().positive().default(18_000),
|
||||||
|
ANTI_DDOS_GLOBAL_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
||||||
|
ANTI_DDOS_VIOLATION_WINDOW_SEC: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(600),
|
||||||
|
ANTI_DDOS_MAX_VIOLATIONS: z.coerce.number().int().positive().default(10),
|
||||||
|
// Escalation tiers as "minViolations:ttlSeconds,minViolations:ttlSeconds".
|
||||||
|
ANTI_DDOS_BLOCK_TIERS: z.string().optional(),
|
||||||
|
ANTI_DDOS_GLOBAL_HALT_MS: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(10_000),
|
||||||
// Logging level.
|
// Logging level.
|
||||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||||
APP_VERSION: z.string().optional(),
|
APP_VERSION: z.string().optional(),
|
||||||
|
|||||||
@@ -621,15 +621,15 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
expect(html).toContain(`>${sentinel}</button>`);
|
expect(html).toContain(`>${sentinel}</button>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates the complete 146-row migration matrix without issues", () => {
|
it("validates the complete 147-row migration matrix without issues", () => {
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
const issues = validateMigrationEntries(
|
const issues = validateMigrationEntries(
|
||||||
discovered,
|
discovered,
|
||||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(147);
|
||||||
expect(discovered).toHaveLength(146);
|
expect(discovered).toHaveLength(147);
|
||||||
expect(issues).toEqual([]);
|
expect(issues).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
|||||||
it("discovers the exact legacy administration inventory", () => {
|
it("discovers the exact legacy administration inventory", () => {
|
||||||
const pages = discoverLegacyPages();
|
const pages = discoverLegacyPages();
|
||||||
|
|
||||||
expect(pages).toHaveLength(146);
|
expect(pages).toHaveLength(147);
|
||||||
expect(pages).toContainEqual({
|
expect(pages).toContainEqual({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/users/:id/edit",
|
legacyPath: "/admin/users/:id/edit",
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
|||||||
import { validateMigrationEntries } from "./validate-matrix";
|
import { validateMigrationEntries } from "./validate-matrix";
|
||||||
|
|
||||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||||
it("covers all 146 legacy pages exactly once", () => {
|
it("covers all 147 legacy pages exactly once", () => {
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(146);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(147);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("systemMigrationEntries", () => {
|
describe("systemMigrationEntries", () => {
|
||||||
it("covers all 23 System pages exactly once", () => {
|
it("covers all 24 System pages exactly once", () => {
|
||||||
expect(systemMigrationEntries).toHaveLength(23);
|
expect(systemMigrationEntries).toHaveLength(24);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(
|
validateMigrationEntries(
|
||||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||||
|
|||||||
@@ -272,6 +272,36 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [
|
|||||||
localization: "PARTIAL",
|
localization: "PARTIAL",
|
||||||
accessibility: "PARTIAL",
|
accessibility: "PARTIAL",
|
||||||
}),
|
}),
|
||||||
|
plannedSystemEntry({
|
||||||
|
surface: "admin",
|
||||||
|
legacyPath: "/admin/devops/antiddos",
|
||||||
|
sourceFile: "src/app/admin/devops/antiddos/page.tsx",
|
||||||
|
targetPath: "/admin/system/configuration/antiddos",
|
||||||
|
decision: "REHOST",
|
||||||
|
capabilities: {
|
||||||
|
read: [PERMS.SETTINGS_VIEW],
|
||||||
|
mutate: [PERMS.SETTINGS_EDIT],
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
queries: [
|
||||||
|
"antiddos:config override",
|
||||||
|
"WebsiteSetting antiddos_*",
|
||||||
|
"blocked anti-DDoS IPs",
|
||||||
|
"GET /api/health",
|
||||||
|
],
|
||||||
|
mutations: [
|
||||||
|
"saveAntiddosSettings",
|
||||||
|
"resetAntiddosSettings",
|
||||||
|
"unbanAntiddosIp",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
auditRequirement: "MUTATION",
|
||||||
|
localization: "PARTIAL",
|
||||||
|
accessibility: "PARTIAL",
|
||||||
|
notes: [
|
||||||
|
"Tunable anti-DDoS rates and block tiers; Cloudflare detection is read-only and driven by cf-ray/cdn-loop presence",
|
||||||
|
],
|
||||||
|
}),
|
||||||
plannedSystemEntry({
|
plannedSystemEntry({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/emulator",
|
legacyPath: "/admin/emulator",
|
||||||
|
|||||||
@@ -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: {
|
headers: {
|
||||||
"x-real-client-ip": "198.51.100.99",
|
"x-real-client-ip": "198.51.100.99",
|
||||||
"cf-connecting-ip": "2001:DB8:0:0::1",
|
"cf-connecting-ip": "2001:DB8:0:0::1",
|
||||||
|
"cf-ray": "abc123-FRA",
|
||||||
"x-forwarded-for": "192.0.2.10",
|
"x-forwarded-for": "192.0.2.10",
|
||||||
},
|
},
|
||||||
expected: "2001:db8::1",
|
expected: "2001:db8::1",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe("normalized client IP addresses", () => {
|
|||||||
expect(normalizeClientIp(input)).toBeNull();
|
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(
|
expect(
|
||||||
resolveClientIp(
|
resolveClientIp(
|
||||||
new Headers({
|
new Headers({
|
||||||
@@ -44,6 +44,20 @@ describe("normalized client IP addresses", () => {
|
|||||||
"x-real-client-ip": "198.51.100.99",
|
"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");
|
).toBe("192.0.2.10");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -1,4 +1,5 @@
|
|||||||
import { isIP } from "node:net";
|
import { isIP } from "node:net";
|
||||||
|
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||||
|
|
||||||
export const UNKNOWN_CLIENT_IP = "0.0.0.0";
|
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
|
* Forwarded headers must be overwritten by a trusted ingress and the origin must
|
||||||
* reject direct public access. Header syntax alone cannot establish peer trust.
|
* 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.
|
* 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 {
|
export function resolveClientIp(headers: Pick<Headers, "get">): string {
|
||||||
|
const cf = normalizeClientIp(headers.get("cf-connecting-ip"));
|
||||||
|
if (isCloudflareProxied(headers)) {
|
||||||
return (
|
return (
|
||||||
normalizeClientIp(headers.get("cf-connecting-ip")) ??
|
cf ??
|
||||||
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
||||||
normalizeClientIp(headers.get("x-real-ip")) ??
|
normalizeClientIp(headers.get("x-real-ip")) ??
|
||||||
UNKNOWN_CLIENT_IP
|
UNKNOWN_CLIENT_IP
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
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";
|
||||||
|
}
|
||||||
+27
-51
@@ -2,10 +2,10 @@ import "server-only";
|
|||||||
|
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
||||||
import { resolveClientIp } from "@/lib/client-ip";
|
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 { rateLimit } from "@/lib/rate-limit";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
@@ -14,52 +14,22 @@ export type DdosDecision =
|
|||||||
| { outcome: "suspect" }
|
| { outcome: "suspect" }
|
||||||
| { outcome: "block"; retryAfterSeconds: number };
|
| { 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 {
|
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 {
|
// Once the global valve trips, shed every request for a short spell from
|
||||||
let ttl = BLOCK_TIERS[0].ttlSeconds;
|
// process memory only — no further Redis round-trips — so a live flood can
|
||||||
for (const tier of BLOCK_TIERS) {
|
// 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;
|
if (violations >= tier.minViolations) ttl = tier.ttlSeconds;
|
||||||
}
|
}
|
||||||
return ttl;
|
return ttl;
|
||||||
@@ -71,12 +41,16 @@ function blockTtlForViolations(violations: number): number {
|
|||||||
* audited IP resolver. Fails open: if Redis is down, buckets degrade to
|
* audited IP resolver. Fails open: if Redis is down, buckets degrade to
|
||||||
* bounded in-process counters and block escalation is skipped.
|
* 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(
|
export async function enforceDdosRateLimit(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
): Promise<DdosDecision> {
|
): Promise<DdosDecision> {
|
||||||
if (!isEnabled()) return { outcome: "pass" };
|
if (!isEnabled()) return { outcome: "pass" };
|
||||||
|
const config = await getAntiddosConfig();
|
||||||
|
if (!config.enabled) return { outcome: "pass" };
|
||||||
|
|
||||||
const pathname = req.nextUrl.pathname;
|
const pathname = req.nextUrl.pathname;
|
||||||
if (pathname === "/api/health") return { outcome: "pass" };
|
if (pathname === "/api/health") return { outcome: "pass" };
|
||||||
@@ -94,7 +68,7 @@ export async function enforceDdosRateLimit(
|
|||||||
if ((await redis.get(blockKey)) !== null) {
|
if ((await redis.get(blockKey)) !== null) {
|
||||||
return {
|
return {
|
||||||
outcome: "block",
|
outcome: "block",
|
||||||
retryAfterSeconds: blockTtlForViolations(0),
|
retryAfterSeconds: blockTtlForViolations(0, config.blockTiers),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -104,11 +78,11 @@ export async function enforceDdosRateLimit(
|
|||||||
|
|
||||||
const global = await rateLimit(
|
const global = await rateLimit(
|
||||||
"antiddos:global:all",
|
"antiddos:global:all",
|
||||||
GLOBAL_LIMIT.limit,
|
config.global.limit,
|
||||||
GLOBAL_LIMIT.windowSeconds * 1000,
|
config.global.windowSeconds * 1000,
|
||||||
);
|
);
|
||||||
if (!global.ok) {
|
if (!global.ok) {
|
||||||
globalHaltedUntil = now + GLOBAL_HALT_MS;
|
globalHaltedUntil = now + config.globalHaltMs;
|
||||||
return {
|
return {
|
||||||
outcome: "block",
|
outcome: "block",
|
||||||
retryAfterSeconds: Math.max(global.retryAfter, 1),
|
retryAfterSeconds: Math.max(global.retryAfter, 1),
|
||||||
@@ -117,7 +91,7 @@ export async function enforceDdosRateLimit(
|
|||||||
if (globalHaltedUntil !== 0) globalHaltedUntil = 0;
|
if (globalHaltedUntil !== 0) globalHaltedUntil = 0;
|
||||||
|
|
||||||
const category = classifyDdos(pathname);
|
const category = classifyDdos(pathname);
|
||||||
const rule = DEFAULT_LIMITS[category];
|
const rule = config[category];
|
||||||
const bucket = await rateLimit(
|
const bucket = await rateLimit(
|
||||||
`antiddos:${category}:${ip}`,
|
`antiddos:${category}:${ip}`,
|
||||||
rule.limit,
|
rule.limit,
|
||||||
@@ -131,10 +105,12 @@ export async function enforceDdosRateLimit(
|
|||||||
const counterKey = `antiddos:v:${ip}`;
|
const counterKey = `antiddos:v:${ip}`;
|
||||||
violations = await redis.incr(counterKey);
|
violations = await redis.incr(counterKey);
|
||||||
if (violations === 1) {
|
if (violations === 1) {
|
||||||
await redis.pexpire(counterKey, VIOLATION_COUNTER_TTL_SECONDS * 1000);
|
await redis.pexpire(counterKey, config.violationWindowSeconds * 1000);
|
||||||
}
|
}
|
||||||
const ttl = blockTtlForViolations(violations);
|
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
||||||
|
if (violations >= config.maxViolations) {
|
||||||
await redis.set(blockKey, "1", "EX", ttl);
|
await redis.set(blockKey, "1", "EX", ttl);
|
||||||
|
}
|
||||||
return { outcome: "block", retryAfterSeconds: ttl };
|
return { outcome: "block", retryAfterSeconds: ttl };
|
||||||
} catch {
|
} catch {
|
||||||
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
||||||
|
|||||||
Reference in new issue
Block a user