Purge junk-username signups at account.created
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:
pmb committed 2026-10-03 20:54:12 -07:00
1 parent 233458d6b8
commit 95ca996a72
4 files changed
+164 -3

No files matched your search

+10
View File
@@ -223,6 +223,16 @@ SUSPICIOUS_COMBINED_ACTION=suspend
# brand-new action path. Flip to "false" once the moderator DMs look right. # brand-new action path. Flip to "false" once the moderator DMs look right.
SUSPICIOUS_COMBINED_DRY_RUN=true 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) --------------------------- # --- Blocklist API (app/main.py, GET /api/blocks) ---------------------------
# Read-only JSON of the IP ranges and email domains this bot has auto-blocked: # Read-only JSON of the IP ranges and email domains this bot has auto-blocked:
# GET /api/blocks both lists in one document # GET /api/blocks both lists in one document
+26
View File
@@ -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 `admin:write:email_domain_blocks` scope in addition to its existing scopes
(see `CLAUDE.md`). (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 ## Suspicious-signup sweep
A scheduled companion to the two signup-scrutiny signals above (`app/suspicious_sweep.py`, A scheduled companion to the two signup-scrutiny signals above (`app/suspicious_sweep.py`,
+65 -3
View File
@@ -53,6 +53,7 @@ import hmac
import ipaddress import ipaddress
import logging import logging
import os import os
import re
import sqlite3 import sqlite3
import threading import threading
import time 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. # 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") 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 --------------------------------------------------- # --- Public blocklist API ---------------------------------------------------
# Read-only JSON of the blocks this bot has registered (GET /api/blocks and # Read-only JSON of the blocks this bot has registered (GET /api/blocks and
# friends). Served WITHOUT authentication when enabled, so it defaults to # 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 sweep (app/suspicious_sweep.py), which still separately handles
single-signal flags after SUSPICIOUS_GRACE_HOURS of inactivity. 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] = [] reasons: list[str] = []
hold = False hold = False
ip_flagged = 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})") 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) 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: if ip_strong_flagged and IP_SCRUTINY_STRONG_SUSPEND_ENABLED and not allowlisted:
action = IP_SCRUTINY_STRONG_SUSPEND_ACTION action = IP_SCRUTINY_STRONG_SUSPEND_ACTION
note = ( note = (
@@ -1786,6 +1822,29 @@ def apply_action(target_id: str, action: str, text: str) -> None:
resp.raise_for_status() 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: def _post_direct(text: str, *, what: str) -> None:
"""Post a direct-visibility status from the moderator bot.""" """Post a direct-visibility status from the moderator bot."""
try: 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. # process_signup must act now instead of waiting for it.
approved = bool(obj.get("approved")) approved = bool(obj.get("approved"))
background.add_task(process_signup, account_id, acct, ip, email, 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: else:
# account.approved: a human has cleared the approval-required # account.approved: a human has cleared the approval-required
# registration gate. Always welcome (dedup makes this safe) — this is # registration gate. Always welcome (dedup makes this safe) — this is
+63
View File
@@ -715,6 +715,68 @@ def strong_ip_suspend_tests():
assert ("607", "failstrong") not in sent, "still held, falls back to the normal flagged path" 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(): def suspicious_watch_tests():
"""Drive maybe_start_suspicious_watch: baseline capture on flagged """Drive maybe_start_suspicious_watch: baseline capture on flagged
signups only, never on clean ones, and never twice for the same account.""" signups only, never on clean ones, and never twice for the same account."""
@@ -1342,6 +1404,7 @@ if __name__ == "__main__":
block_width_cap_tests() block_width_cap_tests()
combined_signal_tests() combined_signal_tests()
strong_ip_suspend_tests() strong_ip_suspend_tests()
junk_username_tests()
suspicious_watch_tests() suspicious_watch_tests()
suspicious_sweep_tests() suspicious_sweep_tests()
blocks_api_tests() blocks_api_tests()