Purge junk-username signups at account.created
docker-build-push / build-push (push) Successful in 13s
docker-build-push / build-push (push) Successful in 13s
Suspend (without the warning email) and delete any signup whose username
fully matches JUNK_USERNAME_PATTERNS, default bp[0-9a-f]{16}: a campaign of
~100/day never-confirmed signups whose confirmation emails were bouncing
into the yttrx inbox and bombing plus-tagged real inboxes. Runs before any
IP/email lookup or welcome; ships dry-run by default.
This commit is contained in:
1 parent
233458d6b8
commit
95ca996a72
4 files changed
+164
-3
No files matched your search
@@ -223,6 +223,16 @@ SUSPICIOUS_COMBINED_ACTION=suspend
|
||||
# brand-new action path. Flip to "false" once the moderator DMs look right.
|
||||
SUSPICIOUS_COMBINED_DRY_RUN=true
|
||||
|
||||
# --- Junk-username purge (app/main.py, account.created) ---------------------
|
||||
# Whitespace-separated, case-insensitive regexes, full-matched against the
|
||||
# username. A match is suspended (no warning email) and deleted at signup,
|
||||
# before any IP/email lookup or welcome. Default catches the 2026-09
|
||||
# "bp<16 hex>" bounce/mailbombing campaign. Reuses ABUSE_ALLOWLIST.
|
||||
JUNK_USERNAME_PATTERNS=bp[0-9a-f]{16}
|
||||
|
||||
# Rollout safety switch — ships "true" (log only).
|
||||
JUNK_USERNAME_DRY_RUN=true
|
||||
|
||||
# --- Blocklist API (app/main.py, GET /api/blocks) ---------------------------
|
||||
# Read-only JSON of the IP ranges and email domains this bot has auto-blocked:
|
||||
# GET /api/blocks both lists in one document
|
||||
|
||||
@@ -154,6 +154,32 @@ Registering email_domain_blocks requires the `ABUSE_BOT_TOKEN` to carry the
|
||||
`admin:write:email_domain_blocks` scope in addition to its existing scopes
|
||||
(see `CLAUDE.md`).
|
||||
|
||||
## Junk-username purge
|
||||
|
||||
On `account.created`, a username that fully matches one of
|
||||
`JUNK_USERNAME_PATTERNS` (whitespace-separated regexes, case-insensitive;
|
||||
default `bp[0-9a-f]{16}`) is **suspended and deleted** immediately, before
|
||||
any IP/email lookup, welcome, or moderator DM. Mastodon's own username
|
||||
blocks only do equals/contains, hence regexes here.
|
||||
|
||||
Added 2026-10-03 for a campaign of ~100/day API signups named `bp` + 16 hex,
|
||||
from residential JP IPs, never confirmed, using dead JP carrier addresses
|
||||
and plus-tagged real icloud/gmail inboxes — every confirmation email (and
|
||||
re-send) bounced into the yttrx inbox or bombed a stranger.
|
||||
|
||||
- The suspend is sent with `send_email_notification=false`, so no warning
|
||||
email goes to the junk address. The signup's own confirmation email has
|
||||
usually already gone out by the time the webhook lands; this stops the
|
||||
re-sends and the pile-up, not that first send.
|
||||
- Mastodon's suspend registers a **canonical email block** on the address
|
||||
with `+tag` and dots stripped, so every later plus-variant of the same
|
||||
inbox is rejected at signup.
|
||||
- The follow-up `DELETE /api/v1/admin/accounts/:id` needs the existing
|
||||
`admin:write:accounts` scope and an Admin role on `bot`.
|
||||
- `ABUSE_ALLOWLIST` is honoured. If the purge call fails the signup falls
|
||||
through to the normal path. `JUNK_USERNAME_DRY_RUN=true` (shipped default)
|
||||
only logs `[DRY-RUN] would purge`.
|
||||
|
||||
## Suspicious-signup sweep
|
||||
|
||||
A scheduled companion to the two signup-scrutiny signals above (`app/suspicious_sweep.py`,
|
||||
|
||||
+65
-3
@@ -53,6 +53,7 @@ import hmac
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
@@ -303,6 +304,28 @@ SUSPICIOUS_COMBINED_ACTION = os.environ.get("SUSPICIOUS_COMBINED_ACTION", "suspe
|
||||
# by-design "false" default above) since this is a brand-new action path.
|
||||
SUSPICIOUS_COMBINED_DRY_RUN = os.environ.get("SUSPICIOUS_COMBINED_DRY_RUN", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
# --- Junk-username purge -----------------------------------------------------
|
||||
# Signups whose username fully matches one of these (whitespace-separated,
|
||||
# case-insensitive) regexes are suspended and deleted at account.created —
|
||||
# before any IP/email lookup, welcome, or moderator DM. Default catches the
|
||||
# 2026-09 "bp<16 hex>" campaign: ~100/day API signups from residential JP IPs,
|
||||
# never confirmed, using dead carrier addresses and plus-tagged real
|
||||
# icloud/gmail inboxes, so every confirmation (and re-send) bounced or
|
||||
# bombed a stranger. Mastodon's own UsernameBlock only does equals/contains,
|
||||
# hence regexes here.
|
||||
#
|
||||
# The suspend is sent with send_email_notification=false (Mastodon's default
|
||||
# would mail a warning to the junk address and bounce). Mastodon's suspend
|
||||
# also registers a CanonicalEmailBlock on the +tag/dot-stripped address, so
|
||||
# every other plus-variant of the same inbox is then rejected at signup.
|
||||
JUNK_USERNAME_PATTERNS = [
|
||||
re.compile(p, re.IGNORECASE)
|
||||
for p in os.environ.get("JUNK_USERNAME_PATTERNS", r"bp[0-9a-f]{16}").split()
|
||||
]
|
||||
# Rollout safety switch — ships dry-run-first (log only), same as the other
|
||||
# new action paths.
|
||||
JUNK_USERNAME_DRY_RUN = os.environ.get("JUNK_USERNAME_DRY_RUN", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
# --- Public blocklist API ---------------------------------------------------
|
||||
# Read-only JSON of the blocks this bot has registered (GET /api/blocks and
|
||||
# friends). Served WITHOUT authentication when enabled, so it defaults to
|
||||
@@ -1185,6 +1208,22 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "",
|
||||
sweep (app/suspicious_sweep.py), which still separately handles
|
||||
single-signal flags after SUSPICIOUS_GRACE_HOURS of inactivity.
|
||||
"""
|
||||
allowlisted = (acct.split("@")[0].lower() in ABUSE_ALLOWLIST
|
||||
or acct.lower() in ABUSE_ALLOWLIST)
|
||||
|
||||
if is_junk_username(acct) and not allowlisted:
|
||||
if JUNK_USERNAME_DRY_RUN:
|
||||
log.warning("[DRY-RUN] would purge acct=%s (junk username pattern)", acct)
|
||||
else:
|
||||
try:
|
||||
purge_account(account_id, "Auto-purged at signup: junk username pattern.")
|
||||
except httpx.HTTPError as exc:
|
||||
log.error("acct=%s: failed to purge junk-username signup, "
|
||||
"falling back to the normal signup path: %s", acct, exc)
|
||||
else:
|
||||
log.warning("purged acct=%s at signup (junk username pattern)", acct)
|
||||
return
|
||||
|
||||
reasons: list[str] = []
|
||||
hold = False
|
||||
ip_flagged = False
|
||||
@@ -1222,9 +1261,6 @@ def process_signup(account_id: str, acct: str, ip: str, email: str = "",
|
||||
reasons.append(f"{prefix}email domain {domain} (disposable={is_disposable}, risk={risk})")
|
||||
hold = hold or (CHECK_MAIL_HOLD_WELCOME and not CHECK_MAIL_DRY_RUN)
|
||||
|
||||
allowlisted = (acct.split("@")[0].lower() in ABUSE_ALLOWLIST
|
||||
or acct.lower() in ABUSE_ALLOWLIST)
|
||||
|
||||
if ip_strong_flagged and IP_SCRUTINY_STRONG_SUSPEND_ENABLED and not allowlisted:
|
||||
action = IP_SCRUTINY_STRONG_SUSPEND_ACTION
|
||||
note = (
|
||||
@@ -1786,6 +1822,29 @@ def apply_action(target_id: str, action: str, text: str) -> None:
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def is_junk_username(acct: str) -> bool:
|
||||
username = acct.split("@")[0]
|
||||
return any(p.fullmatch(username) for p in JUNK_USERNAME_PATTERNS)
|
||||
|
||||
|
||||
def purge_account(target_id: str, text: str) -> None:
|
||||
"""Suspend WITHOUT the warning email, then delete the account."""
|
||||
resp = httpx.post(
|
||||
f"{MASTODON_BASE_URL}/api/v1/admin/accounts/{target_id}/action",
|
||||
headers=_admin_headers(),
|
||||
data={"type": "suspend", "text": text, "send_email_notification": "false"},
|
||||
timeout=15.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# DELETE only works on a suspended account (Mastodon's destroy? policy).
|
||||
resp = httpx.delete(
|
||||
f"{MASTODON_BASE_URL}/api/v1/admin/accounts/{target_id}",
|
||||
headers=_admin_headers(),
|
||||
timeout=15.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _post_direct(text: str, *, what: str) -> None:
|
||||
"""Post a direct-visibility status from the moderator bot."""
|
||||
try:
|
||||
@@ -2192,6 +2251,9 @@ def _handle_account_created(obj: dict, background: BackgroundTasks, event: str):
|
||||
# process_signup must act now instead of waiting for it.
|
||||
approved = bool(obj.get("approved"))
|
||||
background.add_task(process_signup, account_id, acct, ip, email, approved)
|
||||
elif is_junk_username(acct) and not JUNK_USERNAME_DRY_RUN:
|
||||
# Being purged by the account.created path — don't welcome it.
|
||||
return {"ok": True, "skipped": "junk username"}
|
||||
else:
|
||||
# account.approved: a human has cleared the approval-required
|
||||
# registration gate. Always welcome (dedup makes this safe) — this is
|
||||
|
||||
@@ -715,6 +715,68 @@ def strong_ip_suspend_tests():
|
||||
assert ("607", "failstrong") not in sent, "still held, falls back to the normal flagged path"
|
||||
|
||||
|
||||
def junk_username_tests():
|
||||
"""Drive process_signup's junk-username purge (JUNK_USERNAME_*): a
|
||||
matching username is purged before any lookup or welcome; near-misses,
|
||||
allowlisted accts, dry-run, and a failed purge all fall through."""
|
||||
sent = []
|
||||
purged = []
|
||||
lookups = []
|
||||
main.send_welcome = lambda account_id, acct: sent.append((account_id, acct))
|
||||
main.dm_moderator = lambda message: None
|
||||
main.purge_account = lambda target_id, text: purged.append(target_id)
|
||||
main.classify_signup_ip = lambda ip: lookups.append(ip) or ("residential", "Example ISP", False, "")
|
||||
main.IP_SCRUTINY_ENABLED = True
|
||||
main.CHECK_MAIL_ENABLED = False
|
||||
main.ABUSE_ALLOWLIST = {"bp0000000000000000"}
|
||||
main.JUNK_USERNAME_PATTERNS = [main.re.compile(r"bp[0-9a-f]{16}", main.re.IGNORECASE)]
|
||||
main.JUNK_USERNAME_DRY_RUN = False
|
||||
|
||||
# A. matching username -> purged, no IP lookup, no welcome.
|
||||
main.process_signup("701", "bp56ac8d816ff8d793", "203.0.113.70", "[email protected]", True)
|
||||
assert purged == ["701"], purged
|
||||
assert not lookups, "junk signups must not spend IP/email lookups"
|
||||
assert not sent
|
||||
|
||||
# B. near-misses (too short, extra suffix, non-hex) -> normal path.
|
||||
for i, name in enumerate(["bp56ac8d816ff8d79", "bp56ac8d816ff8d7930", "bp56ac8d816ff8d79z", "bpm"]):
|
||||
main.process_signup(f"71{i}", name, "203.0.113.71", "", True)
|
||||
assert purged == ["701"], purged
|
||||
assert len(sent) == 4, sent
|
||||
|
||||
# C. allowlisted -> not purged.
|
||||
main.process_signup("720", "bp0000000000000000", "203.0.113.72", "", True)
|
||||
assert "720" not in purged
|
||||
|
||||
# D. dry-run -> not purged, normal path.
|
||||
purged.clear(); sent.clear()
|
||||
main.JUNK_USERNAME_DRY_RUN = True
|
||||
main.process_signup("730", "bpaaaaaaaaaaaaaaaa", "203.0.113.73", "", True)
|
||||
assert not purged and ("730", "bpaaaaaaaaaaaaaaaa") in sent
|
||||
|
||||
# E. purge fails -> falls back to the normal signup path.
|
||||
main.JUNK_USERNAME_DRY_RUN = False
|
||||
sent.clear()
|
||||
|
||||
def boom(target_id, text):
|
||||
raise main.httpx.HTTPError("boom")
|
||||
|
||||
main.purge_account = boom
|
||||
main.process_signup("740", "bpbbbbbbbbbbbbbbbb", "203.0.113.74", "", True)
|
||||
assert ("740", "bpbbbbbbbbbbbbbbbb") in sent
|
||||
|
||||
# F. account.approved for a junk username (live) -> no welcome queued.
|
||||
sent.clear()
|
||||
r = post({"event": "account.approved",
|
||||
"object": {"id": "750", "username": "bpcccccccccccccccc",
|
||||
"account": {"id": "750", "acct": "bpcccccccccccccccc"}}})
|
||||
assert r.status_code == 200 and r.json().get("skipped") == "junk username", r.json()
|
||||
assert not sent
|
||||
|
||||
main.ABUSE_ALLOWLIST = set()
|
||||
main.JUNK_USERNAME_DRY_RUN = True
|
||||
|
||||
|
||||
def suspicious_watch_tests():
|
||||
"""Drive maybe_start_suspicious_watch: baseline capture on flagged
|
||||
signups only, never on clean ones, and never twice for the same account."""
|
||||
@@ -1342,6 +1404,7 @@ if __name__ == "__main__":
|
||||
block_width_cap_tests()
|
||||
combined_signal_tests()
|
||||
strong_ip_suspend_tests()
|
||||
junk_username_tests()
|
||||
suspicious_watch_tests()
|
||||
suspicious_sweep_tests()
|
||||
blocks_api_tests()
|
||||
|
||||
Reference in new issue
Block a user