Expire auto-registered IP blocks after 24 hours
docker-build-push / build-push (push) Successful in 5s

Blocks were registered permanently. An IP block is a claim about who is
currently behind an address, and that goes stale: VPS addresses are recycled
between customers constantly, and now that the /24 ceiling means most blocks
cover a single address, a permanent block increasingly punishes whoever gets
that address next rather than the actor we saw.

24h is long enough to break a signup run -- the abuse arrives in bursts, not
as a steady trickle -- and short enough that the blocklist stays a live
picture rather than something that only ever grows.

Mastodon expires these itself and reconcile_blocks already reads an elapsed
expires_at as "gone", so expired blocks leave /api/blocks unaided. The one
piece that needed adding is an expiry on our own dedup record: reconcile runs
hourly, so between a block lapsing and the next pass, reputation_block_
installed would otherwise claim a block Mastodon had already dropped and skip
re-installing it.

expires_in is omitted rather than sent as 0 when the expiry is switched off.
Email domain blocks have no expiry in Mastodon and are untouched.
This commit is contained in:
pmb committed 2026-09-13 16:10:28 -07:00
1 parent 147673fd49
commit 6952fbadf6
3 files changed
+124 -15

No files matched your search

+16
View File
@@ -276,6 +276,22 @@ BLOCKS_API_CACHE_SECONDS=300
# #
# Applied at registration time, not retroactively: blocks already in force # Applied at registration time, not retroactively: blocks already in force
# keep whatever width they were registered with. # keep whatever width they were registered with.
# How long a registered Admin::IpBlock lasts, in seconds. 0 = never expires.
#
# An IP block is a statement about who is *currently* behind an address, and
# that goes stale: VPS addresses get recycled between customers constantly,
# and with the /24 ceiling below most blocks now cover a single address, so a
# permanent block increasingly punishes whoever gets that address next rather
# than the actor we saw. 24h (86400) breaks a signup run -- which is what the
# abuse actually looks like, bursts rather than a steady trickle -- and keeps
# the blocklist a live picture instead of something that only accumulates.
#
# Mastodon expires these itself (Scheduler::IpCleanupScheduler) and
# app/reconcile_blocks.py treats an elapsed expires_at as "gone", so an
# expired block drops out of /api/blocks without anyone doing anything.
# Email domain blocks have no expiry in Mastodon and are unaffected.
IP_SCRUTINY_IPBLOCK_EXPIRES_IN=86400
IP_SCRUTINY_MAX_BLOCK_PREFIX_V4=24 IP_SCRUTINY_MAX_BLOCK_PREFIX_V4=24
# The v6 ceiling. /48 is the standard end-site allocation — the rough # The v6 ceiling. /48 is the standard end-site allocation — the rough
+70 -15
View File
@@ -194,6 +194,22 @@ IP_SCRUTINY_MAX_BLOCK_PREFIX_V4 = int(os.environ.get("IP_SCRUTINY_MAX_BLOCK_PREF
# equivalent of "one customer's block" that a /24 represents in v4. # equivalent of "one customer's block" that a /24 represents in v4.
IP_SCRUTINY_MAX_BLOCK_PREFIX_V6 = int(os.environ.get("IP_SCRUTINY_MAX_BLOCK_PREFIX_V6", "48")) IP_SCRUTINY_MAX_BLOCK_PREFIX_V6 = int(os.environ.get("IP_SCRUTINY_MAX_BLOCK_PREFIX_V6", "48"))
# How long a registered Admin::IpBlock lasts, in seconds. 0 = never expires.
#
# An IP block is a statement about who is *currently* behind an address, and
# that goes stale: VPS addresses are recycled between customers constantly,
# and with the /24 ceiling above most blocks now cover a single address, so a
# permanent block increasingly punishes whoever gets that address next rather
# than the actor we saw. 24h is long enough to break a signup run (which is
# what the abuse actually looks like — bursts, not a steady trickle) and short
# enough that the blocklist stays a live picture instead of accumulating
# forever.
#
# Mastodon expires these itself (Scheduler::IpCleanupScheduler), and
# app/reconcile_blocks.py already treats an elapsed expires_at as "gone", so
# an expired block drops out of /api/blocks on its own.
IP_SCRUTINY_IPBLOCK_EXPIRES_IN = int(os.environ.get("IP_SCRUTINY_IPBLOCK_EXPIRES_IN", "86400"))
IP_SCRUTINY_STRONG_SIGNALS = frozenset({"datacenter", "proxy", "tor", "abuser"}) IP_SCRUTINY_STRONG_SIGNALS = frozenset({"datacenter", "proxy", "tor", "abuser"})
IP_SCRUTINY_STRONG_SUSPEND_ENABLED = os.environ.get( IP_SCRUTINY_STRONG_SUSPEND_ENABLED = os.environ.get(
"IP_SCRUTINY_STRONG_SUSPEND_ENABLED", "true" "IP_SCRUTINY_STRONG_SUSPEND_ENABLED", "true"
@@ -491,9 +507,15 @@ def _init_db() -> None:
" last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP," " last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,"
" block_active INTEGER DEFAULT 1," " block_active INTEGER DEFAULT 1,"
" block_checked_at TEXT," " block_checked_at TEXT,"
" expires_at TEXT,"
" PRIMARY KEY (kind, target)" " PRIMARY KEY (kind, target)"
")" ")"
) )
# expires_at arrived with IP_SCRUTINY_IPBLOCK_EXPIRES_IN. Rows written
# before it keep NULL, which reads as "no expiry" — correct, since
# they were registered without one.
if not _column_exists(conn, "reputation_block", "expires_at"):
conn.execute("ALTER TABLE reputation_block ADD COLUMN expires_at TEXT")
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS suspicious_watch (" "CREATE TABLE IF NOT EXISTS suspicious_watch ("
" account_id TEXT PRIMARY KEY," " account_id TEXT PRIMARY KEY,"
@@ -768,34 +790,53 @@ def record_reputation_query(kind: str, value: str, classification: str, org: str
def reputation_block_installed(kind: str, target: str) -> bool: def reputation_block_installed(kind: str, target: str) -> bool:
"""True if the query API already installed this block and the reconcile """True if the query API already installed this block, the reconcile pass
pass hasn't since found it gone. Lets a repeat query skip the Mastodon hasn't since found it gone, and it hasn't expired. Lets a repeat query
round trip — this runs on the signup request path, so a saved POST is skip the Mastodon round trip — this runs on the signup request path, so a
saved latency.""" saved POST is saved latency.
The expiry check matters independently of the reconcile pass: IP blocks
now last IP_SCRUTINY_IPBLOCK_EXPIRES_IN, and reconcile only runs hourly,
so between a block lapsing and the next reconcile this would otherwise
claim a block that Mastodon has already dropped — and skip re-installing
it. A NULL expires_at means no expiry (email domain blocks never expire,
and neither did IP blocks before this was introduced).
"""
with _db() as conn: with _db() as conn:
row = conn.execute( row = conn.execute(
"SELECT 1 FROM reputation_block " "SELECT 1 FROM reputation_block "
"WHERE kind = ? AND target = ? AND block_active = 1", "WHERE kind = ? AND target = ? AND block_active = 1 "
(kind, target), " AND (expires_at IS NULL OR expires_at > ?)",
(kind, target, _now_iso()),
).fetchone() ).fetchone()
return row is not None return row is not None
def record_reputation_block(kind: str, target: str, classification: str, org: str) -> None: def record_reputation_block(kind: str, target: str, classification: str, org: str,
expires_in: int = 0) -> None:
"""Record that this block is installed, or that an installed one was hit """Record that this block is installed, or that an installed one was hit
again. Reactivates a row the reconcile pass had marked gone, same as again. Reactivates a row the reconcile pass had marked gone, same as
mark_ipblock_registered does for the signup-derived rows.""" mark_ipblock_registered does for the signup-derived rows.
expires_in mirrors what was sent to Mastodon, so our dedup record lapses
at the same moment the block itself does; 0 means no expiry.
"""
expires_at = None
if expires_in > 0:
expires_at = (datetime.now(timezone.utc).replace(microsecond=0)
+ timedelta(seconds=expires_in)).isoformat()
with _db() as conn: with _db() as conn:
conn.execute( conn.execute(
"INSERT INTO reputation_block " "INSERT INTO reputation_block "
"(kind, target, classification, org) VALUES (?, ?, ?, ?) " "(kind, target, classification, org, expires_at) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(kind, target) DO UPDATE SET " "ON CONFLICT(kind, target) DO UPDATE SET "
" queries = queries + 1," " queries = queries + 1,"
" last_seen_at = CURRENT_TIMESTAMP," " last_seen_at = CURRENT_TIMESTAMP,"
" block_active = 1," " block_active = 1,"
" expires_at = excluded.expires_at,"
" classification = COALESCE(excluded.classification, classification)," " classification = COALESCE(excluded.classification, classification),"
" org = COALESCE(excluded.org, org)", " org = COALESCE(excluded.org, org)",
(kind, target, classification or None, org or None), (kind, target, classification or None, org or None, expires_at),
) )
@@ -805,8 +846,10 @@ def reputation_blocks(kind: str) -> list[tuple[str, str, str]]:
with _db() as conn: with _db() as conn:
return conn.execute( return conn.execute(
"SELECT target, first_blocked_at, last_seen_at FROM reputation_block " "SELECT target, first_blocked_at, last_seen_at FROM reputation_block "
"WHERE kind = ? AND block_active = 1 ORDER BY last_seen_at DESC", "WHERE kind = ? AND block_active = 1 "
(kind,), " AND (expires_at IS NULL OR expires_at > ?) "
"ORDER BY last_seen_at DESC",
(kind, _now_iso()),
).fetchall() ).fetchall()
@@ -1406,6 +1449,16 @@ def classify_signup_ip(ip: str) -> tuple[str, str, bool, str]:
return classification, intel["org"], bool(reasons), block_cidr or f"{ip}/32" return classification, intel["org"], bool(reasons), block_cidr or f"{ip}/32"
def _ip_block_params(cidr: str, comment: str) -> dict:
"""POST body for Admin::IpBlock. expires_in is omitted entirely when the
expiry is switched off, rather than sent as 0 — Mastodon validates it
against a fixed list of durations and would reject the 0."""
params = {"ip": cidr, "severity": IP_SCRUTINY_IPBLOCK_SEVERITY, "comment": comment}
if IP_SCRUTINY_IPBLOCK_EXPIRES_IN > 0:
params["expires_in"] = IP_SCRUTINY_IPBLOCK_EXPIRES_IN
return params
def register_ip_block(ip: str, acct: str, org: str, cidr: str = "", def register_ip_block(ip: str, acct: str, org: str, cidr: str = "",
comment: str = "") -> bool: comment: str = "") -> bool:
"""Register a flagged signup's network in Mastodon's native Admin::IpBlock. """Register a flagged signup's network in Mastodon's native Admin::IpBlock.
@@ -1437,7 +1490,7 @@ def register_ip_block(ip: str, acct: str, org: str, cidr: str = "",
resp = httpx.post( resp = httpx.post(
f"{MASTODON_BASE_URL}/api/v1/admin/ip_blocks", f"{MASTODON_BASE_URL}/api/v1/admin/ip_blocks",
headers=_admin_headers(), headers=_admin_headers(),
data={"ip": cidr, "severity": IP_SCRUTINY_IPBLOCK_SEVERITY, "comment": comment}, data=_ip_block_params(cidr, comment),
timeout=15.0, timeout=15.0,
) )
if resp.status_code == 422: if resp.status_code == 422:
@@ -1522,12 +1575,14 @@ def _reputation_ip(ip: str, source: str) -> dict | None:
elif reputation_block_installed("ip", cidr): elif reputation_block_installed("ip", cidr):
# Already ours and still in force; Mastodon's own lookup will find # Already ours and still in force; Mastodon's own lookup will find
# it. Skip the round trip, just count the hit. # it. Skip the round trip, just count the hit.
record_reputation_block("ip", cidr, classification, org) record_reputation_block("ip", cidr, classification, org,
IP_SCRUTINY_IPBLOCK_EXPIRES_IN)
blocked = True blocked = True
elif register_ip_block(ip, "", org, cidr, comment=( elif register_ip_block(ip, "", org, cidr, comment=(
f"welcomebot: pre-signup block, {classification} " f"welcomebot: pre-signup block, {classification} "
f"({org or 'unknown org'})")): f"({org or 'unknown org'})")):
record_reputation_block("ip", cidr, classification, org) record_reputation_block("ip", cidr, classification, org,
IP_SCRUTINY_IPBLOCK_EXPIRES_IN)
blocked = True blocked = True
log.warning("reputation query: pre-blocked ip=%s cidr=%s classification=%s org=%s source=%s", log.warning("reputation query: pre-blocked ip=%s cidr=%s classification=%s org=%s source=%s",
ip, cidr, classification, org, source or "-") ip, cidr, classification, org, source or "-")
+38
View File
@@ -1224,6 +1224,44 @@ def reputation_api_tests():
active = {e["cidr"] for e in client.get("/api/blocks/ip-ranges").json()["items"]} active = {e["cidr"] for e in client.get("/api/blocks/ip-ranges").json()["items"]}
assert "198.51.100.0/24" in active, "re-installing republishes it" assert "198.51.100.0/24" in active, "re-installing republishes it"
# M. IP blocks are registered with an expiry, and our own dedup record
# lapses with them. Without that, a query between a block expiring and
# the next hourly reconcile would claim a block Mastodon has already
# dropped -- and skip re-installing it.
main.REPUTATION_API_ENABLED = True
params = main._ip_block_params("198.51.100.0/24", "test")
assert params["expires_in"] == main.IP_SCRUTINY_IPBLOCK_EXPIRES_IN, params
assert main.IP_SCRUTINY_IPBLOCK_EXPIRES_IN == 86400, "default is 24 hours"
saved = main.IP_SCRUTINY_IPBLOCK_EXPIRES_IN
main.IP_SCRUTINY_IPBLOCK_EXPIRES_IN = 0
assert "expires_in" not in main._ip_block_params("198.51.100.0/24", "test"), \
"a 0 expiry omits the field rather than sending 0"
main.IP_SCRUTINY_IPBLOCK_EXPIRES_IN = saved
# An expired record reads as not-installed, so the next query
# re-installs rather than assuming it's still there.
main.record_reputation_block("ip", "203.0.113.0/24", "datacenter", "Expiring Inc", 86400)
assert main.reputation_block_installed("ip", "203.0.113.0/24")
with main._db() as conn:
conn.execute("UPDATE reputation_block SET expires_at = ? WHERE target = ?",
("2020-01-01T00:00:00+00:00", "203.0.113.0/24"))
assert not main.reputation_block_installed("ip", "203.0.113.0/24"), \
"an expired block must not count as installed"
active = {e["cidr"] for e in client.get("/api/blocks/ip-ranges").json()["items"]}
assert "203.0.113.0/24" not in active, "an expired block must not be published"
# Email domain blocks don't expire in Mastodon, so they carry none here
# either: recorded with no expiry, they stay installed indefinitely.
main.record_reputation_block("email", "rep-forever.test", "", "")
assert main.reputation_block_installed("email", "rep-forever.test"), \
"email domain blocks have no expiry and stay installed"
with main._db() as conn:
row = conn.execute(
"SELECT expires_at FROM reputation_block WHERE target = ?",
("rep-forever.test",)).fetchone()
assert row[0] is None, row
main.REPUTATION_API_ENABLED = False main.REPUTATION_API_ENABLED = False