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

This commit is contained in:
openhands committed 2026-09-24 19:19:22 +02:00
1 parent 7392b843ad
commit 7f6febf906
4 files changed
+78 -12

No files matched your search

+11 -6
View File
@@ -848,12 +848,17 @@ bash cms security blocklists
Defaults: Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam, Defaults: Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
Binary Defense, blocklist.de, Emerging Threats, BruteForceBlocker, abuse.ch Binary Defense, blocklist.de, Emerging Threats, BruteForceBlocker, abuse.ch
Feodo/SSLBL/URLhaus, Botvrij, IPsum, Firehol ipsets and Tor exit nodes Feodo/SSLBL, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
(26 sources). The largest commercial/crowdsourced lists (AbuseIPDB, MaxMind, (25 sources). URLhaus was removed because its `text_online` feed lists URLs,
Cisco Talos, AlienVault OTX) are not included because they require an account not IPs; a malformed token in it could otherwise expand into a bogus
or API key; IPsum already aggregates ~30 additional feeds. No account is huge CIDR. The validator only accepts whole-line bare IPs or proper CIDRs,
needed, but internet access is — only for fetching; detection and blocking enforces sane prefix bounds and drops reserved/private/loopback space, so a
remain local. Sync hourly as a cron job: 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
bash cms security blocklists-install-cron bash cms security blocklists-install-cron
+51 -5
View File
@@ -26,7 +26,6 @@ DEFAULT_SOURCES=(
# Malware C2 / botnets # Malware C2 / botnets
"https://feodotracker.abuse.ch/downloads/ipblocklist.txt" "https://feodotracker.abuse.ch/downloads/ipblocklist.txt"
"https://sslbl.abuse.ch/blacklist/sslipblacklist.txt" "https://sslbl.abuse.ch/blacklist/sslipblacklist.txt"
"https://urlhaus.abuse.ch/downloads/text_online/"
"https://www.botvrij.eu/data/ioclist.ip-dst.raw" "https://www.botvrij.eu/data/ioclist.ip-dst.raw"
# Aggregated threat intel # Aggregated threat intel
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt" "https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt"
@@ -143,14 +142,59 @@ build_lists() {
fetch_sources "$work" fetch_sources "$work"
cat "$work"/source-*.txt 2>/dev/null | awk '{print $1}' \ 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 ' | 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) { if (index($0, "/") > 0) {
n = split($0, seg, "/") n = split($0, seg, "/")
if (n != 2 || seg[2] !~ /^[0-9]+$/) next if (n != 2 || seg[2] !~ /^[0-9]+$/) next
if (index(seg[1], ":") > 0) { if (seg[2] + 0 <= 128) print; next } if (index(seg[1], ":") > 0) {
if (seg[2] + 0 <= 32) print 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 next
} }
n = split($0, part, ".") n = split($0, part, ".")
@@ -160,7 +204,9 @@ build_lists() {
if (part[i] !~ /^[0-9]+$/ || part[i] + 0 > 255) { ok = 0; break } if (part[i] !~ /^[0-9]+$/ || part[i] + 0 > 255) { ok = 0; break }
if (length(part[i]) > 1 && part[i] ~ /^0/) { 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" | sort -u > "$work/candidates.txt"
+9
View File
@@ -248,6 +248,15 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
expect(fetchMock).not.toHaveBeenCalled(); 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 () => { it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => {
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true"); vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080"); vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080");
+7 -1
View File
@@ -4,7 +4,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; 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, UNKNOWN_CLIENT_IP } 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 { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
@@ -71,6 +71,12 @@ export async function enforceDdosRateLimit(
} }
const ip = resolveClientIp(req.headers); 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}`; const blockKey = `antiddos:block:${ip}`;
if (redis) { if (redis) {
try { try {