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.
|
# 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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user