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.
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
+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
(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
View File
@@ -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
+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"
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()