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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -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 (
|
||||||
|
cf ??
|
||||||
|
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
||||||
|
normalizeClientIp(headers.get("x-real-ip")) ??
|
||||||
|
UNKNOWN_CLIENT_IP
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
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-real-ip")) ??
|
||||||
|
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
||||||
UNKNOWN_CLIENT_IP
|
UNKNOWN_CLIENT_IP
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user