feat(security): auto-block repeat offenders via CrowdSec community reputation
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Failing after 17s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Failing after 17s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- new crowdsec-api lib: CTI lookup (GET /smoke/{ip}, freemium x-api-key), verdict parser with false-positive veto, 1h Redis + in-memory verdict cache, NX lock dedupe, 403/429 backoff; writes only the shared antiddos:block:{ip} key (value "crowdsec") and never touches Cloudflare
- gate fires it fire-and-forget for IPs that already tripped a rate bucket, so known-bad IPs are hard-blocked before the local maxViolations threshold
- runtime config: crowdsecAutoBlock toggle, score threshold (0-5, default 4), block TTL (default 24h); boot defaults CROWDSEC_AUTO_BLOCK_ENABLED / CROWDSEC_BLOCK_SCORE / CROWDSEC_BLOCK_TTL_SECONDS
- admin panel: CrowdSec stat card, verify-connection action, score/TTL settings, CrowdSec source badge in the blocked-IPs list
- credentials live in env only (CROWDSEC_API_KEY); block is enforced per-request via proxy on the resolved X-Forwarded-For / CF-Connecting-IP
- tests: crowdsec-api unit suite + ddos-guard integration suite (early-block, threshold, cache dedupe, backoff)
This commit is contained in:
1 parent
64edb81ab7
commit
f32a6dadd0
9 files changed
+1414
-5
No files matched your search
@@ -72,6 +72,24 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
|||||||
# Override for tests/staging (production uses the public endpoint by default).
|
# Override for tests/staging (production uses the public endpoint by default).
|
||||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
||||||
|
|
||||||
|
# --- CROWDSEC API (community reputation auto-block, optional) ---
|
||||||
|
# Free CTI API key: https://app.crowdsec.net/ → Settings → CTI API Keys.
|
||||||
|
# When set, the anti-DDoS gate checks the community reputation of repeat
|
||||||
|
# offenders (CTI GET /smoke/{ip}) and immediately hard-blocks known-bad IPs.
|
||||||
|
# Lookups only happen for IPs that already tripped a rate bucket and are
|
||||||
|
# cached in Redis for 1h, so quota usage stays minimal.
|
||||||
|
CROWDSEC_API_KEY=
|
||||||
|
# Runtime toggle for reputation-based auto-blocking (also overridable live
|
||||||
|
# from the admin panel). Requires CROWDSEC_API_KEY.
|
||||||
|
CROWDSEC_AUTO_BLOCK_ENABLED=true
|
||||||
|
# Minimum malevolence score 0-5 (CrowdSec scale; 4-5 = "malicious") before an
|
||||||
|
# IP is treated as known-bad. IPs with false-positive tags are never blocked.
|
||||||
|
CROWDSEC_BLOCK_SCORE=4
|
||||||
|
# How long a CrowdSec-confirmed bad IP stays blocked (seconds).
|
||||||
|
CROWDSEC_BLOCK_TTL_SECONDS=86400
|
||||||
|
# Endpoint — override only for tests/staging.
|
||||||
|
CROWDSEC_CTI_BASE_URL=https://cti.api.crowdsec.net/v2
|
||||||
|
|
||||||
# --- PATHS ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
setLastCloudflareVerify,
|
setLastCloudflareVerify,
|
||||||
verifyCloudflareConnection,
|
verifyCloudflareConnection,
|
||||||
} from "@/lib/cloudflare-api";
|
} from "@/lib/cloudflare-api";
|
||||||
|
import {
|
||||||
|
setLastCrowdsecVerify,
|
||||||
|
verifyCrowdsecConnection,
|
||||||
|
} from "@/lib/crowdsec-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";
|
||||||
@@ -33,6 +37,17 @@ function positiveInt(raw: FormDataEntryValue | null, fallback: number): number {
|
|||||||
return Math.floor(n);
|
return Math.floor(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampInt(
|
||||||
|
raw: FormDataEntryValue | null,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
const n = Number(str(raw));
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
|
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
|
||||||
const tiers: AntiddosBlockTier[] = [];
|
const tiers: AntiddosBlockTier[] = [];
|
||||||
for (const part of str(raw).split(",")) {
|
for (const part of str(raw).split(",")) {
|
||||||
@@ -99,6 +114,17 @@ function configFromForm(formData: FormData): AntiddosConfig {
|
|||||||
defaults.globalHaltMs,
|
defaults.globalHaltMs,
|
||||||
),
|
),
|
||||||
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
||||||
|
crowdsecAutoBlock: str(formData.get("cs_auto_block")) === "1",
|
||||||
|
crowdsecBlockScore: clampInt(
|
||||||
|
formData.get("cs_block_score"),
|
||||||
|
defaults.crowdsecBlockScore,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
crowdsecBlockTtlSeconds: positiveInt(
|
||||||
|
formData.get("cs_block_ttl_sec"),
|
||||||
|
defaults.crowdsecBlockTtlSeconds,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +149,9 @@ async function persistSettings(config: AntiddosConfig): Promise<void> {
|
|||||||
],
|
],
|
||||||
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
||||||
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
||||||
|
["antiddos_cs_auto_block", config.crowdsecAutoBlock ? "1" : "0"],
|
||||||
|
["antiddos_cs_block_score", String(config.crowdsecBlockScore)],
|
||||||
|
["antiddos_cs_block_ttl", String(config.crowdsecBlockTtlSeconds)],
|
||||||
];
|
];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(([key, value]) =>
|
entries.map(([key, value]) =>
|
||||||
@@ -230,6 +259,19 @@ export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
|||||||
revalidatePath("/admin/devops/antiddos");
|
revalidatePath("/admin/devops/antiddos");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test the configured CrowdSec API credentials against the CTI endpoint. */
|
||||||
|
export async function verifyCrowdsecConfiguration(): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
const status = await verifyCrowdsecConnection();
|
||||||
|
await setLastCrowdsecVerify(status);
|
||||||
|
logger.info("CrowdSec API configuration verified", {
|
||||||
|
staff: staff.username,
|
||||||
|
ok: status.ok,
|
||||||
|
message: status.message,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
/** Test the configured Cloudflare API credentials against the zone. */
|
/** Test the configured Cloudflare API credentials against the zone. */
|
||||||
export async function verifyCloudflareConfiguration(): Promise<void> {
|
export async function verifyCloudflareConfiguration(): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+29
@@ -148,6 +148,35 @@ const schema = z
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value !== "false" && value !== "0"),
|
.transform((value) => value !== "false" && value !== "0"),
|
||||||
|
// CrowdSec API — optional. When the CTI API key is set, the anti-DDoS
|
||||||
|
// gate consults the community reputation of repeat offenders (CTI
|
||||||
|
// GET /smoke/{ip}) and hard-blocks known-bad IPs immediately. Like the
|
||||||
|
// Cloudflare token, the key lives in env only and is never written into
|
||||||
|
// the admin-visible config. Free/community key: app.crowdsec.net →
|
||||||
|
// Settings → CTI API Keys.
|
||||||
|
CROWDSEC_API_KEY: z.string().optional(),
|
||||||
|
// Reputation lookup (CTI) endpoint; overridden for tests/staging.
|
||||||
|
CROWDSEC_CTI_BASE_URL: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.default("https://cti.api.crowdsec.net/v2"),
|
||||||
|
// Boot default for the runtime "auto-block from CrowdSec reputation"
|
||||||
|
// toggle (overridable via the admin panel / antiddos:config).
|
||||||
|
CROWDSEC_AUTO_BLOCK_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value !== "false" && value !== "0"),
|
||||||
|
// Minimum malevolence score (CrowdSec scores are 0-5; 4-5 maps to
|
||||||
|
// "malicious") an IP must reach before the gate treats it as known-bad.
|
||||||
|
// An IP the community already labels "malicious" is always blocked,
|
||||||
|
// unless it carries false-positive classification tags.
|
||||||
|
CROWDSEC_BLOCK_SCORE: z.coerce.number().int().min(0).max(5).default(4),
|
||||||
|
// How long a CrowdSec-confirmed bad IP stays blocked by the gate.
|
||||||
|
CROWDSEC_BLOCK_TTL_SECONDS: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(86_400),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.NODE_ENV !== "production") return;
|
if (data.NODE_ENV !== "production") return;
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface AntiddosConfig {
|
|||||||
blockTiers: AntiddosBlockTier[];
|
blockTiers: AntiddosBlockTier[];
|
||||||
globalHaltMs: number;
|
globalHaltMs: number;
|
||||||
cloudflareAutoBlock: boolean;
|
cloudflareAutoBlock: boolean;
|
||||||
|
crowdsecAutoBlock: boolean;
|
||||||
|
/** Minimum CrowdSec malevolence score (0-5) treated as known-bad. */
|
||||||
|
crowdsecBlockScore: number;
|
||||||
|
/** How long a CrowdSec-confirmed bad IP stays blocked by the gate. */
|
||||||
|
crowdsecBlockTtlSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: AntiddosConfig = {
|
const DEFAULT_CONFIG: AntiddosConfig = {
|
||||||
@@ -41,6 +46,9 @@ const DEFAULT_CONFIG: AntiddosConfig = {
|
|||||||
],
|
],
|
||||||
globalHaltMs: 10_000,
|
globalHaltMs: 10_000,
|
||||||
cloudflareAutoBlock: true,
|
cloudflareAutoBlock: true,
|
||||||
|
crowdsecAutoBlock: true,
|
||||||
|
crowdsecBlockScore: 4,
|
||||||
|
crowdsecBlockTtlSeconds: 86_400,
|
||||||
};
|
};
|
||||||
|
|
||||||
function positiveInt(value: number | undefined, fallback: number): number {
|
function positiveInt(value: number | undefined, fallback: number): number {
|
||||||
@@ -49,6 +57,17 @@ function positiveInt(value: number | undefined, fallback: number): number {
|
|||||||
return Math.floor(n);
|
return Math.floor(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampInt(
|
||||||
|
value: number | undefined,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null {
|
function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null {
|
||||||
if (!raw?.trim()) return null;
|
if (!raw?.trim()) return null;
|
||||||
const tiers: AntiddosBlockTier[] = [];
|
const tiers: AntiddosBlockTier[] = [];
|
||||||
@@ -125,6 +144,17 @@ export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
|||||||
DEFAULT_CONFIG.globalHaltMs,
|
DEFAULT_CONFIG.globalHaltMs,
|
||||||
),
|
),
|
||||||
cloudflareAutoBlock: isTruthyFlag(env.CLOUDFLARE_AUTO_BLOCK_ENABLED),
|
cloudflareAutoBlock: isTruthyFlag(env.CLOUDFLARE_AUTO_BLOCK_ENABLED),
|
||||||
|
crowdsecAutoBlock: isTruthyFlag(env.CROWDSEC_AUTO_BLOCK_ENABLED),
|
||||||
|
crowdsecBlockScore: clampInt(
|
||||||
|
env.CROWDSEC_BLOCK_SCORE,
|
||||||
|
DEFAULT_CONFIG.crowdsecBlockScore,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
crowdsecBlockTtlSeconds: positiveInt(
|
||||||
|
env.CROWDSEC_BLOCK_TTL_SECONDS,
|
||||||
|
DEFAULT_CONFIG.crowdsecBlockTtlSeconds,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +197,17 @@ function sanitize(config: AntiddosConfig): AntiddosConfig {
|
|||||||
: base.blockTiers,
|
: base.blockTiers,
|
||||||
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
||||||
cloudflareAutoBlock: config?.cloudflareAutoBlock !== false,
|
cloudflareAutoBlock: config?.cloudflareAutoBlock !== false,
|
||||||
|
crowdsecAutoBlock: config?.crowdsecAutoBlock !== false,
|
||||||
|
crowdsecBlockScore: clampInt(
|
||||||
|
config?.crowdsecBlockScore,
|
||||||
|
base.crowdsecBlockScore,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
crowdsecBlockTtlSeconds: positiveInt(
|
||||||
|
config?.crowdsecBlockTtlSeconds,
|
||||||
|
base.crowdsecBlockTtlSeconds,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
@@ -7,6 +7,7 @@ import { getAntiddosConfig } from "@/lib/antiddos-config";
|
|||||||
import { resolveClientIp } from "@/lib/client-ip";
|
import { resolveClientIp } from "@/lib/client-ip";
|
||||||
import { isCloudflareProxied } from "@/lib/cloudflare";
|
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||||
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||||
|
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-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";
|
||||||
@@ -124,6 +125,20 @@ export async function enforceDdosRateLimit(
|
|||||||
config.cloudflareAutoBlock && isCloudflareProxied(req.headers),
|
config.cloudflareAutoBlock && isCloudflareProxied(req.headers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Consult CrowdSec's community reputation for repeat offenders.
|
||||||
|
// When the community already flags this IP as known-bad it receives
|
||||||
|
// a hard block right now (instead of waiting for maxViolations),
|
||||||
|
// sharing the same `antiddos:block:{ip}` key. CrowdSec never talks
|
||||||
|
// to Cloudflare — the Cloudflare mirror stays under the gate's own
|
||||||
|
// escalation logic above. Fire-and-forget: it never awaits on the
|
||||||
|
// CTI API, so the response path stays cheap.
|
||||||
|
void maybeAutoBlockCrowdsec({
|
||||||
|
ip,
|
||||||
|
category,
|
||||||
|
ttlSeconds: config.crowdsecBlockTtlSeconds,
|
||||||
|
scoreThreshold: config.crowdsecBlockScore,
|
||||||
|
enabled: config.crowdsecAutoBlock,
|
||||||
|
});
|
||||||
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