Never block narrower than a /24
docker-build-push / build-push (push) Successful in 24s

A /32 block is close to useless against what IP scrutiny fires on: a
datacenter/VPN/proxy customer has the rest of the range sitting right
there, so blocking the single address we saw stops nothing the same
actor does a minute later from the address next door. The width ceiling
added 2026-09-13 made this the common case rather than the exception:
its fallback for an over-wide ASN route was the bare address, so a
flagged signup out of a /18 hosting allocation got a /32.

Add IP_SCRUTINY_MIN_BLOCK_PREFIX_V4/_V6 (24/48) as a floor under that
ceiling. Anything narrower than the floor -- a missing route, the /32
recorded when a route didn't contain the IP, a small hosting allocation,
or the fallback from a route wider than the ceiling -- is widened to the
network of that width containing the address. At the defaults the floor
equals the ceiling, so every v4 block is now exactly the /24 containing
the flagged address.

State the collateral cost rather than waving at it: at the live settings
(sign_up_block severity, ~30d expiry) one flagged signup now hard-refuses
registration from 256 addresses for a month. Moving the severity to
sign_up_requires_approval or shortening the expiry buys that back, and
setting the floor to 32/128 restores the old behaviour entirely.
This commit is contained in:
pmb committed 2026-09-21 22:33:00 -07:00
1 parent 9e4456b7df
commit 233458d6b8
4 files changed
+189 -67

No files matched your search

+55 -21
View File
@@ -444,38 +444,53 @@ def range_cache_tests():
def block_width_cap_tests():
"""capped_block_cidr: the ASN route is the right unit to block for hosting
space and the wrong one for an ISP aggregate. ipapi.is reports routes as
wide as /8, so without a ceiling one `abuser` flag on a residential or
mobile address would wall off millions of consumer addresses."""
"""capped_block_cidr: every registered block lands in the width window
between the floor and the ceiling. The ceiling is there because ipapi.is
reports routes as wide as /8, so one `abuser` flag on a residential or
mobile address would otherwise wall off millions of consumer addresses.
The floor is there because the opposite extreme is just as useless: a /32
blocks the one address we saw while the next signup comes from its
neighbour in the same datacenter range."""
main.IP_SCRUTINY_MAX_BLOCK_PREFIX_V4 = 24
main.IP_SCRUTINY_MAX_BLOCK_PREFIX_V6 = 48
main.IP_SCRUTINY_MIN_BLOCK_PREFIX_V4 = 24
main.IP_SCRUTINY_MIN_BLOCK_PREFIX_V6 = 48
# A. At or narrower than the ceiling: block the route, as before.
# A. A route already inside the window is blocked as-is.
assert main.capped_block_cidr("5.255.96.30", "5.255.96.0/24") == "5.255.96.0/24", \
"the ceiling itself is allowed, not excluded"
assert main.capped_block_cidr("5.255.96.30", "5.255.96.0/28") == "5.255.96.0/28"
# B. Wider than the ceiling: fall back to the single address. A /11 is a
# consumer ISP aggregate — blocking it would take out 2m addresses.
assert main.capped_block_cidr("98.45.12.7", "98.32.0.0/11") == "98.45.12.7/32"
assert main.capped_block_cidr("197.211.45.9", "197.211.32.0/19") == "197.211.45.9/32"
# B. Narrower than the floor: widened to the floor network containing the
# address. A /28 hosting allocation is a fine cache answer but a poor
# block — and a bare /32 is the case this floor exists for.
assert main.capped_block_cidr("5.255.96.30", "5.255.96.0/28") == "5.255.96.0/24"
assert main.capped_block_cidr("5.255.96.30", "5.255.96.30/32") == "5.255.96.0/24", \
"a single-address route must widen to the /24, never block just the /32"
# C. Wider than the ceiling: back down to the floor network — NOT to the
# single address, which is what this used to do. A /11 is a consumer
# ISP aggregate; blocking it would take out 2m addresses.
assert main.capped_block_cidr("98.45.12.7", "98.32.0.0/11") == "98.45.12.0/24"
assert main.capped_block_cidr("197.211.45.9", "197.211.32.0/19") == "197.211.45.0/24"
# ...including the VPS-sized ranges the bot used to block wholesale.
assert main.capped_block_cidr("51.222.9.9", "51.222.0.0/16") == "51.222.9.9/32"
assert main.capped_block_cidr("194.163.140.1", "194.163.128.0/18") == "194.163.140.1/32"
assert main.capped_block_cidr("51.222.9.9", "51.222.0.0/16") == "51.222.9.0/24"
assert main.capped_block_cidr("194.163.140.1", "194.163.128.0/18") == "194.163.140.0/24"
# C. Same rule for v6, on its own ceiling (/48 = the standard end-site
# D. Same rules for v6, on its own window (/48 = the standard end-site
# allocation, the rough v6 equivalent of a v4 /24).
assert main.capped_block_cidr("2001:db8:1::5", "2001:db8:1::/48") == "2001:db8:1::/48"
assert main.capped_block_cidr("2001:db8:1::5", "2001:db8::/40") == "2001:db8:1::5/128"
assert main.capped_block_cidr("2001:db8:1::5", "2001:db8::/29") == "2001:db8:1::5/128"
assert main.capped_block_cidr("2001:db8:1::5", "2001:db8::/40") == "2001:db8:1::/48"
assert main.capped_block_cidr("2001:db8:1::5", "2001:db8::/29") == "2001:db8:1::/48"
assert main.capped_block_cidr("2001:db8:1::5", "2001:db8:1::5/128") == "2001:db8:1::/48"
# D. Missing or unparseable input degrades to the single address rather
# than to something wide.
assert main.capped_block_cidr("203.0.113.5", "") == "203.0.113.5/32"
assert main.capped_block_cidr("203.0.113.5", "not-a-cidr") == "203.0.113.5/32"
# E. Missing or unparseable input degrades to the floor network, not to
# something wide and not to the bare address.
assert main.capped_block_cidr("203.0.113.5", "") == "203.0.113.0/24"
assert main.capped_block_cidr("203.0.113.5", "not-a-cidr") == "203.0.113.0/24"
# ...and an unparseable IP yields nothing at all, so callers can refuse.
assert main.capped_block_cidr("not-an-ip", "") == ""
# E. The cap is applied to what gets BLOCKED, not to what gets CACHED:
# F. The window is applied to what gets BLOCKED, not to what gets CACHED:
# the wide route is still the right cache key, since the whole route
# genuinely belongs to one operator and one lookup answers for all of
# it. Narrowing the cache key instead would multiply provider lookups.
@@ -485,7 +500,26 @@ def block_width_cap_tests():
assert main.cached_ip_intel("100.64.200.200") is not None, \
"a wide route must still answer from cache for its whole range"
assert main.cached_ip_intel("100.64.200.200")["cidr"] == "100.64.0.0/10"
assert main.capped_block_cidr("100.64.200.200", "100.64.0.0/10") == "100.64.200.200/32"
assert main.capped_block_cidr("100.64.200.200", "100.64.0.0/10") == "100.64.200.0/24"
# G. The floor is a knob: setting it to /32 restores the old behaviour of
# blocking exactly what the route says, down to the single address.
main.IP_SCRUTINY_MIN_BLOCK_PREFIX_V4 = 32
try:
assert main.capped_block_cidr("98.45.12.7", "98.32.0.0/11") == "98.45.12.7/32"
assert main.capped_block_cidr("5.255.96.30", "5.255.96.0/28") == "5.255.96.0/28"
assert main.capped_block_cidr("203.0.113.5", "") == "203.0.113.5/32"
finally:
main.IP_SCRUTINY_MIN_BLOCK_PREFIX_V4 = 24
# H. A floor configured WIDER than the ceiling is nonsense; the ceiling
# wins, since it is the one bounding collateral damage.
main.IP_SCRUTINY_MIN_BLOCK_PREFIX_V4 = 16
try:
assert main.capped_block_cidr("98.45.12.7", "98.32.0.0/11") == "98.45.12.0/24"
assert main.capped_block_cidr("98.45.12.7", "") == "98.45.12.0/24"
finally:
main.IP_SCRUTINY_MIN_BLOCK_PREFIX_V4 = 24
def combined_signal_tests():