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,
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user