fix(security): drop URLhaus feed, validate CIDR ranges, pass unknown client IPs
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m46s
CI / tests-ui (push) Successful in 2m35s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m40s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m46s
CI / tests-ui (push) Successful in 2m35s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m40s
This commit is contained in:
1 parent
7392b843ad
commit
7f6febf906
4 files changed
+78
-12
No files matched your search
@@ -848,12 +848,17 @@ bash cms security blocklists
|
||||
|
||||
Defaults: Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
|
||||
Binary Defense, blocklist.de, Emerging Threats, BruteForceBlocker, abuse.ch
|
||||
Feodo/SSLBL/URLhaus, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
|
||||
(26 sources). The largest commercial/crowdsourced lists (AbuseIPDB, MaxMind,
|
||||
Cisco Talos, AlienVault OTX) are not included because they require an account
|
||||
or API key; IPsum already aggregates ~30 additional feeds. No account is
|
||||
needed, but internet access is — only for fetching; detection and blocking
|
||||
remain local. Sync hourly as a cron job:
|
||||
Feodo/SSLBL, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
|
||||
(25 sources). URLhaus was removed because its `text_online` feed lists URLs,
|
||||
not IPs; a malformed token in it could otherwise expand into a bogus
|
||||
huge CIDR. The validator only accepts whole-line bare IPs or proper CIDRs,
|
||||
enforces sane prefix bounds and drops reserved/private/loopback space, so a
|
||||
bad source entry can never block the origin or internal traffic. The largest
|
||||
commercial/crowdsourced lists (AbuseIPDB, MaxMind, Cisco Talos, AlienVault
|
||||
OTX) are not included because they require an account or API key; IPsum
|
||||
already aggregates ~30 additional feeds. No account is needed, but internet
|
||||
access is — only for fetching; detection and blocking remain local. Sync
|
||||
hourly as a cron job:
|
||||
|
||||
```bash
|
||||
bash cms security blocklists-install-cron
|
||||
|
||||
@@ -26,7 +26,6 @@ DEFAULT_SOURCES=(
|
||||
# Malware C2 / botnets
|
||||
"https://feodotracker.abuse.ch/downloads/ipblocklist.txt"
|
||||
"https://sslbl.abuse.ch/blacklist/sslipblacklist.txt"
|
||||
"https://urlhaus.abuse.ch/downloads/text_online/"
|
||||
"https://www.botvrij.eu/data/ioclist.ip-dst.raw"
|
||||
# Aggregated threat intel
|
||||
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt"
|
||||
@@ -143,14 +142,59 @@ build_lists() {
|
||||
fetch_sources "$work"
|
||||
|
||||
cat "$work"/source-*.txt 2>/dev/null | awk '{print $1}' \
|
||||
| grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]+)?|([0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F:]+(/[0-9]+)?' \
|
||||
| grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$|^([0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F:]*[0-9a-fA-F](/[0-9]{1,3})?$' \
|
||||
| awk '
|
||||
# Only globally routable attacker space may become a decision. Reserved,
|
||||
# private, loopback, link-local, CGNAT, test and multicast ranges never
|
||||
# represent an external attacker and must not be imported (they could
|
||||
# otherwise block the origin itself or internal traffic).
|
||||
function isReserved4(prefix, a, b, c) {
|
||||
if (a == 0 || a == 127 || a >= 224) return 1
|
||||
if (a == 10) return 1
|
||||
if (a == 100 && (prefix < 10 || (prefix >= 10 && b >= 64 && b <= 127))) return 1
|
||||
if (a == 169 && (prefix < 16 || (prefix >= 16 && b == 254))) return 1
|
||||
if (a == 172 && (prefix < 12 || (prefix >= 12 && b >= 16 && b <= 31))) return 1
|
||||
if (a == 192 && b == 168) return 1
|
||||
if (a == 192 && b == 0) return 1
|
||||
if ((a == 198 && (b == 18 || b == 19)) || (a == 198 && b == 51 && c == 100)) return 1
|
||||
if (a == 203 && b == 0 && c == 113) return 1
|
||||
return 0
|
||||
}
|
||||
function isReserved6(line, prefix, first, h) {
|
||||
if (prefix < 32) return 1
|
||||
if (line ~ /^::/) return 1
|
||||
first = tolower(line); sub(/^::?/, "", first); sub(/:.*/, "", first)
|
||||
h = "0x" substr(first, 1, 2)
|
||||
if (h >= 252) return 1 # ULA fc00::/7, link-local fe80::/10, multicast ff00::/8
|
||||
return 0
|
||||
}
|
||||
{
|
||||
if (index($0, "/") > 0) {
|
||||
n = split($0, seg, "/")
|
||||
if (n != 2 || seg[2] !~ /^[0-9]+$/) next
|
||||
if (index(seg[1], ":") > 0) { if (seg[2] + 0 <= 128) print; next }
|
||||
if (seg[2] + 0 <= 32) print
|
||||
if (index(seg[1], ":") > 0) {
|
||||
pref = seg[2] + 0
|
||||
if (pref < 32 || pref > 128) next
|
||||
if (isReserved6(seg[1], pref)) next
|
||||
print
|
||||
next
|
||||
}
|
||||
pref = seg[2] + 0
|
||||
if (pref < 8 || pref > 32) next
|
||||
split(seg[1], oct, ".")
|
||||
ok = 1
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if (oct[i] !~ /^[0-9]+$/ || oct[i] + 0 > 255) { ok = 0; break }
|
||||
if (length(oct[i]) > 1 && oct[i] ~ /^0/) { ok = 0; break }
|
||||
}
|
||||
if (!ok) next
|
||||
if (isReserved4(pref, oct[1] + 0, oct[2] + 0, oct[3] + 0)) next
|
||||
print
|
||||
next
|
||||
}
|
||||
if (index($0, ":") > 0) {
|
||||
if (isReserved6($0, 128)) next
|
||||
print
|
||||
next
|
||||
}
|
||||
n = split($0, part, ".")
|
||||
@@ -160,7 +204,9 @@ build_lists() {
|
||||
if (part[i] !~ /^[0-9]+$/ || part[i] + 0 > 255) { ok = 0; break }
|
||||
if (length(part[i]) > 1 && part[i] ~ /^0/) { ok = 0; break }
|
||||
}
|
||||
if (ok) print
|
||||
if (!ok) next
|
||||
if (isReserved4(32, part[1] + 0, part[2] + 0, part[3] + 0)) next
|
||||
print
|
||||
}' \
|
||||
| sort -u > "$work/candidates.txt"
|
||||
|
||||
|
||||
@@ -248,6 +248,15 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the unknown-IP sentinel through even when a stale block key exists", async () => {
|
||||
state.map.set("antiddos:block:0.0.0.0", "1");
|
||||
|
||||
const decision = await enforceDdosRateLimit(directRequest("0.0.0.0"));
|
||||
|
||||
expect(decision.outcome).toBe("pass");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => {
|
||||
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
|
||||
vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080");
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
||||
import { resolveClientIp } from "@/lib/client-ip";
|
||||
import { resolveClientIp, UNKNOWN_CLIENT_IP } from "@/lib/client-ip";
|
||||
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
|
||||
@@ -71,6 +71,12 @@ export async function enforceDdosRateLimit(
|
||||
}
|
||||
|
||||
const ip = resolveClientIp(req.headers);
|
||||
// A trusted ingress always resolves a real client address. `0.0.0.0` is the
|
||||
// sentinel for header-less loopback traffic (health checks, CI browser
|
||||
// gates, monitoring). If it were rate-limited or blocked it would occupy a
|
||||
// single shared key, and any burst of synthetic local traffic could then
|
||||
// shed all origin-verified requests — exactly what broke CI smoke tests.
|
||||
if (ip === UNKNOWN_CLIENT_IP) return { outcome: "pass" };
|
||||
const blockKey = `antiddos:block:${ip}`;
|
||||
if (redis) {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user