feat(security): mirror anti-DDoS blocks to Cloudflare edge via API
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Failing after 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Failing after 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- gate creates a zone IP Access Rule (block) for proxied offenders that hit the block threshold, deduped until the tiered block expires - cloudflare-api lib: verified endpoints, create/delete/verify/list helpers, Redis-backed tracking + 30s TTL sweep (instrumentation worker + admin render) - runtime toggle cloudflareAutoBlock in antiddos config; boot default CLOUDFLARE_AUTO_BLOCK_ENABLED - admin panel: Cloudflare edge-blocks card with verify + remove-rule actions; unban also lifts the edge block - credentials live in env only (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID)
This commit is contained in:
1 parent
f0c27eb815
commit
4479753160
9 files changed
+1157
-1
No files matched your search
@@ -10,6 +10,11 @@ import {
|
|||||||
antiddosDefaultsFromEnv,
|
antiddosDefaultsFromEnv,
|
||||||
invalidateAntiddosConfig,
|
invalidateAntiddosConfig,
|
||||||
} from "@/lib/antiddos-config";
|
} from "@/lib/antiddos-config";
|
||||||
|
import {
|
||||||
|
removeCloudflareBlock,
|
||||||
|
setLastCloudflareVerify,
|
||||||
|
verifyCloudflareConnection,
|
||||||
|
} from "@/lib/cloudflare-api";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
@@ -93,6 +98,7 @@ function configFromForm(formData: FormData): AntiddosConfig {
|
|||||||
formData.get("global_halt_ms"),
|
formData.get("global_halt_ms"),
|
||||||
defaults.globalHaltMs,
|
defaults.globalHaltMs,
|
||||||
),
|
),
|
||||||
|
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +122,7 @@ async function persistSettings(config: AntiddosConfig): Promise<void> {
|
|||||||
.join(","),
|
.join(","),
|
||||||
],
|
],
|
||||||
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
||||||
|
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
||||||
];
|
];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(([key, value]) =>
|
entries.map(([key, value]) =>
|
||||||
@@ -190,12 +197,49 @@ export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
|||||||
redis.del(`antiddos:v:${ip}`),
|
redis.del(`antiddos:v:${ip}`),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
// Also lift a matching Cloudflare edge block (best effort).
|
||||||
|
const cloudflare = await removeCloudflareBlock(ip);
|
||||||
logger.info("Anti-DDoS block manually removed", {
|
logger.info("Anti-DDoS block manually removed", {
|
||||||
staff: staff.username,
|
staff: staff.username,
|
||||||
ip,
|
ip,
|
||||||
|
cloudflareCleared: cloudflare.removed,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Failed to remove anti-DDoS block", { err, ip });
|
logger.error("Failed to remove anti-DDoS block", { err, ip });
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/devops/antiddos");
|
revalidatePath("/admin/devops/antiddos");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove an automatic Cloudflare edge block for a tracked IP. */
|
||||||
|
export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
const ip = str(formData.get("ip")).trim();
|
||||||
|
if (!ip) return;
|
||||||
|
|
||||||
|
const result = await removeCloudflareBlock(ip);
|
||||||
|
logger.info(
|
||||||
|
result.removed
|
||||||
|
? "Cloudflare automatic block removed"
|
||||||
|
: "Cloudflare automatic block removal skipped",
|
||||||
|
{
|
||||||
|
staff: staff.username,
|
||||||
|
ip,
|
||||||
|
message: result.message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test the configured Cloudflare API credentials against the zone. */
|
||||||
|
export async function verifyCloudflareConfiguration(): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
const status = await verifyCloudflareConnection();
|
||||||
|
await setLastCloudflareVerify(status);
|
||||||
|
logger.info("Cloudflare API configuration verified", {
|
||||||
|
staff: staff.username,
|
||||||
|
ok: status.ok,
|
||||||
|
zoneName: status.zoneName,
|
||||||
|
message: status.message,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+16
@@ -132,6 +132,22 @@ const schema = z
|
|||||||
// 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(),
|
||||||
|
// Cloudflare API — optional. When the API token + zone id are set, the
|
||||||
|
// anti-DDoS gate can automatically mirror escalated IP blocks to the
|
||||||
|
// zone's IP Access Rules so attackers are dropped at the edge. The token
|
||||||
|
// lives in env only and is never persisted into Redis-visible config.
|
||||||
|
CLOUDFLARE_API_BASE_URL: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.default("https://api.cloudflare.com/client/v4"),
|
||||||
|
CLOUDFLARE_API_TOKEN: z.string().optional(),
|
||||||
|
CLOUDFLARE_ZONE_ID: z.string().optional(),
|
||||||
|
// Boot default for the runtime "auto-create Cloudflare blocks" toggle
|
||||||
|
// (overridable via the admin panel / antiddos:config).
|
||||||
|
CLOUDFLARE_AUTO_BLOCK_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value !== "false" && value !== "0"),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.NODE_ENV !== "production") return;
|
if (data.NODE_ENV !== "production") return;
|
||||||
|
|||||||
@@ -34,4 +34,9 @@ export async function register() {
|
|||||||
void drainFurnitureImports();
|
void drainFurnitureImports();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
timer.unref();
|
timer.unref();
|
||||||
|
const { sweepExpiredCloudflareBlocks } = await import("@/lib/cloudflare-api");
|
||||||
|
const cloudflareSweep = setInterval(() => {
|
||||||
|
void sweepExpiredCloudflareBlocks();
|
||||||
|
}, 30000);
|
||||||
|
cloudflareSweep.unref();
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ export interface AntiddosConfig {
|
|||||||
maxViolations: number;
|
maxViolations: number;
|
||||||
blockTiers: AntiddosBlockTier[];
|
blockTiers: AntiddosBlockTier[];
|
||||||
globalHaltMs: number;
|
globalHaltMs: number;
|
||||||
|
cloudflareAutoBlock: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: AntiddosConfig = {
|
const DEFAULT_CONFIG: AntiddosConfig = {
|
||||||
@@ -39,6 +40,7 @@ const DEFAULT_CONFIG: AntiddosConfig = {
|
|||||||
{ minViolations: 50, ttlSeconds: 86_400 },
|
{ minViolations: 50, ttlSeconds: 86_400 },
|
||||||
],
|
],
|
||||||
globalHaltMs: 10_000,
|
globalHaltMs: 10_000,
|
||||||
|
cloudflareAutoBlock: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function positiveInt(value: number | undefined, fallback: number): number {
|
function positiveInt(value: number | undefined, fallback: number): number {
|
||||||
@@ -65,11 +67,19 @@ function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null {
|
|||||||
return tiers;
|
return tiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate on a boolean-flag env value that is either already transformed to a
|
||||||
|
* real boolean (production schema) or still a raw string (SKIP-env tests).
|
||||||
|
*/
|
||||||
|
function isTruthyFlag(value: string | boolean | undefined): boolean {
|
||||||
|
return !(value === false || value === "false" || value === "0");
|
||||||
|
}
|
||||||
|
|
||||||
/** Boot defaults from environment (explicitly set → overrides code; unset → sane value). */
|
/** Boot defaults from environment (explicitly set → overrides code; unset → sane value). */
|
||||||
export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
||||||
const tiers = parseTiers(env.ANTI_DDOS_BLOCK_TIERS);
|
const tiers = parseTiers(env.ANTI_DDOS_BLOCK_TIERS);
|
||||||
return {
|
return {
|
||||||
enabled: env.ANTI_DDOS_ENABLED !== false,
|
enabled: isTruthyFlag(env.ANTI_DDOS_ENABLED),
|
||||||
pages: {
|
pages: {
|
||||||
limit: positiveInt(env.ANTI_DDOS_PAGES_LIMIT, DEFAULT_CONFIG.pages.limit),
|
limit: positiveInt(env.ANTI_DDOS_PAGES_LIMIT, DEFAULT_CONFIG.pages.limit),
|
||||||
windowSeconds: positiveInt(
|
windowSeconds: positiveInt(
|
||||||
@@ -114,6 +124,7 @@ export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
|||||||
env.ANTI_DDOS_GLOBAL_HALT_MS,
|
env.ANTI_DDOS_GLOBAL_HALT_MS,
|
||||||
DEFAULT_CONFIG.globalHaltMs,
|
DEFAULT_CONFIG.globalHaltMs,
|
||||||
),
|
),
|
||||||
|
cloudflareAutoBlock: isTruthyFlag(env.CLOUDFLARE_AUTO_BLOCK_ENABLED),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +166,7 @@ function sanitize(config: AntiddosConfig): AntiddosConfig {
|
|||||||
.sort((a, b) => a.minViolations - b.minViolations)
|
.sort((a, b) => a.minViolations - b.minViolations)
|
||||||
: base.blockTiers,
|
: base.blockTiers,
|
||||||
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
||||||
|
cloudflareAutoBlock: config?.cloudflareAutoBlock !== false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
||||||
import { resolveClientIp } from "@/lib/client-ip";
|
import { resolveClientIp } from "@/lib/client-ip";
|
||||||
|
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||||
|
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||||
import { classifyDdos, 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";
|
||||||
@@ -110,6 +112,17 @@ export async function enforceDdosRateLimit(
|
|||||||
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
||||||
if (violations >= config.maxViolations) {
|
if (violations >= config.maxViolations) {
|
||||||
await redis.set(blockKey, "1", "EX", ttl);
|
await redis.set(blockKey, "1", "EX", ttl);
|
||||||
|
// Mirror the host-level block to the Cloudflare edge (IP Access
|
||||||
|
// Rules) so a repeat offender is shed before it reaches the
|
||||||
|
// origin. Only when this request demonstrably transited
|
||||||
|
// Cloudflare — that is when the client IP is trustworthy.
|
||||||
|
void maybeAutoBlockCloudflare({
|
||||||
|
ip,
|
||||||
|
ttlSeconds: ttl,
|
||||||
|
category,
|
||||||
|
enabled:
|
||||||
|
config.cloudflareAutoBlock && isCloudflareProxied(req.headers),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { outcome: "block", retryAfterSeconds: ttl };
|
return { outcome: "block", retryAfterSeconds: ttl };
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in new issue
Block a user