Expire auto-registered IP blocks after 24 hours
docker-build-push / build-push (push) Successful in 5s
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:
1 parent
147673fd49
commit
6952fbadf6
3 files changed
+124
-15
No files matched your search
+70
-15
@@ -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.
|
||||
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_SUSPEND_ENABLED = os.environ.get(
|
||||
"IP_SCRUTINY_STRONG_SUSPEND_ENABLED", "true"
|
||||
@@ -491,9 +507,15 @@ def _init_db() -> None:
|
||||
" last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,"
|
||||
" block_active INTEGER DEFAULT 1,"
|
||||
" block_checked_at TEXT,"
|
||||
" expires_at TEXT,"
|
||||
" 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(
|
||||
"CREATE TABLE IF NOT EXISTS suspicious_watch ("
|
||||
" 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:
|
||||
"""True if the query API already installed this block and the reconcile
|
||||
pass hasn't since found it gone. Lets a repeat query skip the Mastodon
|
||||
round trip — this runs on the signup request path, so a saved POST is
|
||||
saved latency."""
|
||||
"""True if the query API already installed this block, the reconcile pass
|
||||
hasn't since found it gone, and it hasn't expired. Lets a repeat query
|
||||
skip the Mastodon round trip — this runs on the signup request path, so a
|
||||
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:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM reputation_block "
|
||||
"WHERE kind = ? AND target = ? AND block_active = 1",
|
||||
(kind, target),
|
||||
"WHERE kind = ? AND target = ? AND block_active = 1 "
|
||||
" AND (expires_at IS NULL OR expires_at > ?)",
|
||||
(kind, target, _now_iso()),
|
||||
).fetchone()
|
||||
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
|
||||
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:
|
||||
conn.execute(
|
||||
"INSERT INTO reputation_block "
|
||||
"(kind, target, classification, org) VALUES (?, ?, ?, ?) "
|
||||
"(kind, target, classification, org, expires_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(kind, target) DO UPDATE SET "
|
||||
" queries = queries + 1,"
|
||||
" last_seen_at = CURRENT_TIMESTAMP,"
|
||||
" block_active = 1,"
|
||||
" expires_at = excluded.expires_at,"
|
||||
" classification = COALESCE(excluded.classification, classification),"
|
||||
" 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:
|
||||
return conn.execute(
|
||||
"SELECT target, first_blocked_at, last_seen_at FROM reputation_block "
|
||||
"WHERE kind = ? AND block_active = 1 ORDER BY last_seen_at DESC",
|
||||
(kind,),
|
||||
"WHERE kind = ? AND block_active = 1 "
|
||||
" AND (expires_at IS NULL OR expires_at > ?) "
|
||||
"ORDER BY last_seen_at DESC",
|
||||
(kind, _now_iso()),
|
||||
).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"
|
||||
|
||||
|
||||
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 = "",
|
||||
comment: str = "") -> bool:
|
||||
"""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(
|
||||
f"{MASTODON_BASE_URL}/api/v1/admin/ip_blocks",
|
||||
headers=_admin_headers(),
|
||||
data={"ip": cidr, "severity": IP_SCRUTINY_IPBLOCK_SEVERITY, "comment": comment},
|
||||
data=_ip_block_params(cidr, comment),
|
||||
timeout=15.0,
|
||||
)
|
||||
if resp.status_code == 422:
|
||||
@@ -1522,12 +1575,14 @@ def _reputation_ip(ip: str, source: str) -> dict | None:
|
||||
elif reputation_block_installed("ip", cidr):
|
||||
# Already ours and still in force; Mastodon's own lookup will find
|
||||
# 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
|
||||
elif register_ip_block(ip, "", org, cidr, comment=(
|
||||
f"welcomebot: pre-signup block, {classification} "
|
||||
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
|
||||
log.warning("reputation query: pre-blocked ip=%s cidr=%s classification=%s org=%s source=%s",
|
||||
ip, cidr, classification, org, source or "-")
|
||||
|
||||
Reference in new issue
Block a user