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
+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
|
||||
|
||||
Reference in new issue
Block a user