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

+201
View File
@@ -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");
}