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

- 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:
openhands committed 2026-09-22 22:22:51 +02:00
1 parent fd4d0fa1cb
commit f0c27eb815
17 files changed
+1151 -63

No files matched your search

+393
View File
@@ -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>
);
}
+3
View File
@@ -71,6 +71,9 @@ export default async function DevOpsPage() {
<Link href="/admin/devops/deliveries" className="btn btn-outline">
{deliveries("title")}
</Link>
<Link href="/admin/devops/antiddos" className="btn btn-outline">
Anti-DDoS protection
</Link>
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>