diff --git a/.env.example b/.env.example index 1016df4..27e3bfd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 1d45775..6fa1ddc 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/app/main.py b/app/main.py index a4003c6..b01cfbe 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/test_local.py b/test_local.py index d54dcb6..61f5475 100644 --- a/test_local.py +++ b/test_local.py @@ -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", "x+tag@icloud.com", 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()