Add a reputation query API for pre-signup blocking
docker-build-push / build-push (push) Successful in 6s
docker-build-push / build-push (push) Successful in 6s
Everything the bot does today is reactive: Mastodon creates the account, the account.created webhook arrives, we classify the signup IP and email domain, and then we block the range and suspend what already exists. POST /api/reputation is the pre-emptive half. A caller asks about an IP and/or email domain; we classify it, record the answer, register the matching Admin::IpBlock / EmailDomainBlock, and only then return. A caller that runs its own block lookup immediately afterwards therefore finds a block that did not exist when its request started. The intended caller is Mastodon's signup path, which does exactly that. It writes, so unlike /api/blocks it is authenticated and refuses to serve without a key (REPUTATION_API_KEY): an unauthenticated endpoint that installs IP blocks is a denial-of-service lever, so there is no open mode. Notes on the pieces that aren't obvious: - REPUTATION_API_MAX_LOOKUPS_PER_HOUR caps *fresh* provider lookups. The caller hits this on every view of the signup form, not just on submission, so without a ceiling a crawler walking /auth/sign_up from a new range each time would set our ipapi.is bill. Over budget we answer "unknown" and install nothing -- never "flagged". Cache hits are free and uncounted, and the webhook path never spends from this budget. - Blocks installed here are recorded in their own table, keyed by the CIDR/domain rather than by account (there is no account yet), and are folded into /api/blocks and the reconcile pass so neither one under-reports as blocking shifts to the pre-signup path. The published entry shape is unchanged; a range blocked before anyone signed up from it simply reads "signups": 0. - A repeat query for an already-installed block skips the Mastodon round trip. This runs on a live signup request, so that is saved latency.
This commit is contained in:
1 parent
8490048d2d
commit
57234ca548
5 files changed
+758
-4
No files matched your search
@@ -258,3 +258,59 @@ BLOCKS_API_PUBLIC_URL=https://hooks.yttrx.com
|
||||
# changes when a signup is flagged, so a few minutes of client/intermediate
|
||||
# caching costs nothing.
|
||||
BLOCKS_API_CACHE_SECONDS=300
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reputation query API (pre-signup pre-blocking)
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/reputation turns the bot from reactive into pre-emptive. Mastodon
|
||||
# calls it during signup, immediately BEFORE running its own IpBlock /
|
||||
# EmailDomainBlock lookup:
|
||||
#
|
||||
# POST /api/reputation {"ip": "...", "email": "...", "source": "..."}
|
||||
# X-Reputation-Key: <REPUTATION_API_KEY>
|
||||
#
|
||||
# The handler classifies the IP (ipapi.is) and/or the email's domain
|
||||
# (check-mail.org), records the answer, registers the matching Admin::IpBlock
|
||||
# / EmailDomainBlock, and only THEN returns — so the lookup Mastodon makes
|
||||
# microseconds later finds the block this call just installed and rejects the
|
||||
# signup outright, instead of the bot cleaning up an account that already
|
||||
# exists. Both request fields are optional (the IP choke point has no address
|
||||
# to offer); at least one is required.
|
||||
#
|
||||
# The caller is the `call-welcomebot-reputation-api` patch on yttrx's Mastodon
|
||||
# fork — see yttrx-documentation/mastodon-fork-patches.md. It fails open: if
|
||||
# this endpoint is slow, down or disabled, Mastodon proceeds with its own
|
||||
# lookup exactly as before.
|
||||
#
|
||||
# Unlike the blocklist API above this one WRITES (it installs blocks), so it
|
||||
# is authenticated and refuses to serve without a key.
|
||||
REPUTATION_API_ENABLED=false
|
||||
|
||||
# Shared secret the caller presents as the X-Reputation-Key header; must match
|
||||
# WELCOMEBOT_REPUTATION_KEY on the Mastodon side. Generate with
|
||||
# `openssl rand -hex 32`. Empty = the route 404s even with ENABLED=true: an
|
||||
# unauthenticated endpoint that installs IP blocks is a denial-of-service
|
||||
# lever, so there is deliberately no "open" mode.
|
||||
REPUTATION_API_KEY=
|
||||
|
||||
# Whether a flagged lookup actually registers the block, or only classifies,
|
||||
# records and answers. false is the dry-run equivalent for this path — the way
|
||||
# to watch what it *would* pre-block before letting it act. The per-signal
|
||||
# IP_SCRUTINY_DRY_RUN / CHECK_MAIL_DRY_RUN and IP_SCRUTINY_AUTO_IPBLOCK /
|
||||
# CHECK_MAIL_AUTO_DOMAIN_BLOCK switches still apply on top of this.
|
||||
REPUTATION_API_INSTALL_BLOCKS=true
|
||||
|
||||
# Ceiling on *fresh* (cache-missing) provider lookups per rolling hour.
|
||||
# Mastodon calls this on every view of the signup form, not just on
|
||||
# submission, so without a ceiling a crawler walking /auth/sign_up from a new
|
||||
# range each time would set the ipapi.is / check-mail.org bill. Over budget an
|
||||
# uncached value answers "unknown" and installs nothing — never "flagged".
|
||||
# Cache hits are free and are never counted, and the webhook path never spends
|
||||
# from this budget (account.created is authoritative and must not be starved).
|
||||
# 0 = no ceiling.
|
||||
REPUTATION_API_MAX_LOOKUPS_PER_HOUR=500
|
||||
|
||||
# DM MOD_ALERT_ACCT when this endpoint installs a block it has never installed
|
||||
# before. Bounded by how many genuinely new bad ranges/domains appear, not by
|
||||
# request volume: repeat hits on an already-installed block are silent.
|
||||
REPUTATION_API_DM_MODERATOR=true
|
||||
+431
-4
@@ -34,6 +34,16 @@ Alongside the webhook, an optional read-only blocklist API publishes what the
|
||||
bot has auto-blocked — ``GET /api/blocks``, ``/api/blocks/ip-ranges``,
|
||||
``/api/blocks/email-domains``. It is unauthenticated and therefore off unless
|
||||
``BLOCKS_API_ENABLED`` is set; see that config entry.
|
||||
|
||||
Everything above is *reactive*: it acts on an account Mastodon has already
|
||||
created. ``POST /api/reputation`` is the pre-emptive counterpart — Mastodon
|
||||
asks about an IP/email domain during signup, before creating anything, and
|
||||
this classifies it, records it, and registers the Admin::IpBlock /
|
||||
EmailDomainBlock *before returning*, so the lookup Mastodon runs immediately
|
||||
afterwards rejects the signup outright. It writes, so it is authenticated
|
||||
(``X-Reputation-Key``) and off unless ``REPUTATION_API_ENABLED`` and
|
||||
``REPUTATION_API_KEY`` are both set. The caller is the
|
||||
``call-welcomebot-reputation-api`` patch on yttrx's Mastodon fork.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -45,12 +55,14 @@ import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -248,6 +260,54 @@ BLOCKS_API_PUBLIC_URL = os.environ.get(
|
||||
# intermediate cache hold it for a few minutes costs nothing.
|
||||
BLOCKS_API_CACHE_SECONDS = int(os.environ.get("BLOCKS_API_CACHE_SECONDS", "300"))
|
||||
|
||||
# --- Reputation query API (pre-signup pre-blocking) -------------------------
|
||||
# POST /api/reputation lets Mastodon ask "what do you know about this IP /
|
||||
# email domain?" *before* it runs its own IpBlock / EmailDomainBlock lookup at
|
||||
# signup time. The handler classifies, records the answer, registers the block
|
||||
# on the Mastodon side, and only then returns — so the lookup Mastodon makes
|
||||
# immediately afterwards finds the block this call just installed. That turns
|
||||
# the bot from purely reactive (suspend after the account exists) into
|
||||
# pre-emptive: a bad range is rejected on first contact rather than cleaned up
|
||||
# afterwards.
|
||||
#
|
||||
# Unlike the blocklist API this endpoint *writes* (it installs blocks), so it
|
||||
# is authenticated and refuses to serve at all without a key. See the
|
||||
# yttrx patch `call-welcomebot-reputation-api` on the Mastodon fork for the
|
||||
# caller.
|
||||
REPUTATION_API_ENABLED = os.environ.get(
|
||||
"REPUTATION_API_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
# Shared secret the caller presents as `X-Reputation-Key`. No key, no service:
|
||||
# an unauthenticated endpoint that installs IP blocks is a denial-of-service
|
||||
# lever, so an empty key disables the route entirely (404) rather than
|
||||
# defaulting to open.
|
||||
REPUTATION_API_KEY = os.environ.get("REPUTATION_API_KEY", "")
|
||||
# Whether a flagged lookup actually registers the block on the Mastodon side.
|
||||
# Off = classify, record and answer only, installing nothing — the dry-run
|
||||
# equivalent for this path, and the way to watch what it *would* block before
|
||||
# letting it act. The per-signal IP_SCRUTINY_DRY_RUN / CHECK_MAIL_DRY_RUN and
|
||||
# IP_SCRUTINY_AUTO_IPBLOCK / CHECK_MAIL_AUTO_DOMAIN_BLOCK switches still apply
|
||||
# on top of this, so a signal that is dry-run for the webhook path is dry-run
|
||||
# here too.
|
||||
REPUTATION_API_INSTALL_BLOCKS = os.environ.get(
|
||||
"REPUTATION_API_INSTALL_BLOCKS", "true").lower() in ("1", "true", "yes")
|
||||
# Ceiling on *fresh* (cache-missing) external provider lookups this endpoint
|
||||
# may make per rolling hour. Mastodon calls it on every view of the signup
|
||||
# form, not just on submission, so a crawl of /auth/sign_up from many distinct
|
||||
# ranges would otherwise burn paid ipapi.is / check-mail.org quota at whatever
|
||||
# rate the crawler chooses. Over budget, an uncached value answers "unknown"
|
||||
# (never "flagged") and nothing is installed. Cache hits are free and never
|
||||
# counted; 0 disables the ceiling.
|
||||
#
|
||||
# The webhook path deliberately does NOT consult this budget — account.created
|
||||
# is low-volume, authoritative, and must never be starved by crawler traffic.
|
||||
REPUTATION_API_MAX_LOOKUPS_PER_HOUR = int(
|
||||
os.environ.get("REPUTATION_API_MAX_LOOKUPS_PER_HOUR", "500"))
|
||||
# DM MOD_ALERT_ACCT when this endpoint installs a block it has never installed
|
||||
# before. Bounded by how many genuinely new bad ranges/domains show up, not by
|
||||
# request volume — repeat hits on an already-installed block are silent.
|
||||
REPUTATION_API_DM_MODERATOR = os.environ.get(
|
||||
"REPUTATION_API_DM_MODERATOR", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
if not WEBHOOK_SECRET:
|
||||
log.warning("WEBHOOK_SECRET is empty — signature verification will reject all requests.")
|
||||
if not BOT_ACCESS_TOKEN:
|
||||
@@ -256,6 +316,8 @@ if ABUSE_ENABLED and not ABUSE_BOT_TOKEN:
|
||||
log.warning("ABUSE_BOT_TOKEN is empty — report.created handling is disabled.")
|
||||
if CHECK_MAIL_ENABLED and not CHECK_MAIL_API_KEY:
|
||||
log.warning("CHECK_MAIL_API_KEY is empty — disposable-email signup scrutiny is disabled.")
|
||||
if REPUTATION_API_ENABLED and not REPUTATION_API_KEY:
|
||||
log.warning("REPUTATION_API_KEY is empty — the reputation query API stays disabled.")
|
||||
|
||||
app = FastAPI(title="yttrx welcome-bot + abuse-bot")
|
||||
|
||||
@@ -368,6 +430,49 @@ def _init_db() -> None:
|
||||
" cached_at TEXT DEFAULT CURRENT_TIMESTAMP"
|
||||
")"
|
||||
)
|
||||
# Audit log of every /api/reputation answer: what was asked about,
|
||||
# what the providers said, and whether it led to a block. The webhook
|
||||
# path has signup_ip/signup_email for this; a pre-signup query has no
|
||||
# account to hang a row off, so it gets its own table. Never stores a
|
||||
# full email address — the domain only, same rule as signup_email.
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS reputation_query ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" kind TEXT NOT NULL," # 'ip' | 'email'
|
||||
" value TEXT NOT NULL," # the IP, or the email domain
|
||||
" classification TEXT,"
|
||||
" org TEXT,"
|
||||
" risk INTEGER,"
|
||||
" flagged INTEGER,"
|
||||
" target TEXT," # cidr / domain the block covers
|
||||
" blocked INTEGER DEFAULT 0,"
|
||||
" source TEXT," # caller-supplied tag
|
||||
" queried_at TEXT DEFAULT CURRENT_TIMESTAMP"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS reputation_query_kind_value "
|
||||
"ON reputation_query (kind, value)"
|
||||
)
|
||||
# One row per block installed by the query API, keyed by what is
|
||||
# actually blocked. Two jobs: skip re-POSTing a block we already
|
||||
# installed (Mastodon answers 422, but the round trip is on the signup
|
||||
# request path), and give /api/blocks + the reconcile pass the same
|
||||
# view of these as of the signup-derived ones.
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS reputation_block ("
|
||||
" kind TEXT NOT NULL," # 'ip' | 'email'
|
||||
" target TEXT NOT NULL," # cidr, or domain
|
||||
" classification TEXT,"
|
||||
" org TEXT,"
|
||||
" queries INTEGER DEFAULT 1,"
|
||||
" first_blocked_at TEXT DEFAULT CURRENT_TIMESTAMP,"
|
||||
" last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,"
|
||||
" block_active INTEGER DEFAULT 1,"
|
||||
" block_checked_at TEXT,"
|
||||
" PRIMARY KEY (kind, target)"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS suspicious_watch ("
|
||||
" account_id TEXT PRIMARY KEY,"
|
||||
@@ -592,6 +697,63 @@ def start_suspicious_watch(account_id: str, acct: str, reasons: str,
|
||||
)
|
||||
|
||||
|
||||
def record_reputation_query(kind: str, value: str, classification: str, org: str,
|
||||
risk: int, flagged: bool, target: str, blocked: bool,
|
||||
source: str) -> None:
|
||||
"""Append one /api/reputation answer to the audit log."""
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO reputation_query "
|
||||
"(kind, value, classification, org, risk, flagged, target, blocked, source) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(kind, value, classification, org, int(risk), int(flagged),
|
||||
target or None, int(blocked), source or None),
|
||||
)
|
||||
|
||||
|
||||
def reputation_block_installed(kind: str, target: str) -> bool:
|
||||
"""True if the query API already installed this block and the reconcile
|
||||
pass hasn't since found it gone. Lets a repeat query skip the Mastodon
|
||||
round trip — this runs on the signup request path, so a saved POST is
|
||||
saved latency."""
|
||||
with _db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM reputation_block "
|
||||
"WHERE kind = ? AND target = ? AND block_active = 1",
|
||||
(kind, target),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def record_reputation_block(kind: str, target: str, classification: str, org: str) -> None:
|
||||
"""Record that this block is installed, or that an installed one was hit
|
||||
again. Reactivates a row the reconcile pass had marked gone, same as
|
||||
mark_ipblock_registered does for the signup-derived rows."""
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO reputation_block "
|
||||
"(kind, target, classification, org) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(kind, target) DO UPDATE SET "
|
||||
" queries = queries + 1,"
|
||||
" last_seen_at = CURRENT_TIMESTAMP,"
|
||||
" block_active = 1,"
|
||||
" classification = COALESCE(excluded.classification, classification),"
|
||||
" org = COALESCE(excluded.org, org)",
|
||||
(kind, target, classification or None, org or None),
|
||||
)
|
||||
|
||||
|
||||
def reputation_blocks(kind: str) -> list[tuple[str, str, str]]:
|
||||
"""(target, first_blocked_at, last_seen_at) for every still-active block
|
||||
the query API installed, newest first."""
|
||||
with _db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT target, first_blocked_at, last_seen_at FROM reputation_block "
|
||||
"WHERE kind = ? AND block_active = 1 ORDER BY last_seen_at DESC",
|
||||
(kind,),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _fallback_cidr(ip: str) -> str:
|
||||
"""The single-address CIDR for ip, used for signup_ip rows written before
|
||||
block_cidr existed — that's the block that was registered for them."""
|
||||
@@ -644,7 +806,8 @@ def blocked_ip_ranges(limit: int = 0) -> list[dict]:
|
||||
entry["signups"] += 1
|
||||
entry["first_blocked_at"] = classified_at
|
||||
|
||||
return _capped(list(grouped.values()), limit)
|
||||
_merge_reputation_blocks(grouped, "ip", "cidr")
|
||||
return _capped(_newest_first(grouped), limit)
|
||||
|
||||
|
||||
def blocked_email_domains(limit: int = 0) -> list[dict]:
|
||||
@@ -682,7 +845,44 @@ def blocked_email_domains(limit: int = 0) -> list[dict]:
|
||||
entry["signups"] += 1
|
||||
entry["first_blocked_at"] = classified_at
|
||||
|
||||
return _capped(list(grouped.values()), limit)
|
||||
_merge_reputation_blocks(grouped, "email", "domain")
|
||||
return _capped(_newest_first(grouped), limit)
|
||||
|
||||
|
||||
def _merge_reputation_blocks(grouped: dict[str, dict], kind: str, key: str) -> None:
|
||||
"""Fold the query API's blocks into a signup-derived blocklist, in place.
|
||||
|
||||
Both paths register the same kind of Admin::IpBlock / EmailDomainBlock, so
|
||||
the published list would silently under-report as more blocking moves to
|
||||
the pre-signup path. Entries keep the existing keys — no contract change
|
||||
for anyone already consuming this — which means `signups` continues to
|
||||
count *signups* specifically. A range blocked before anyone signed up from
|
||||
it therefore reads `"signups": 0`, which is exactly right: blocked, and
|
||||
nothing got through.
|
||||
"""
|
||||
for target, first_at, last_at in reputation_blocks(kind):
|
||||
if not target:
|
||||
continue
|
||||
entry = grouped.get(target)
|
||||
if entry is None:
|
||||
grouped[target] = {
|
||||
key: target,
|
||||
"signups": 0,
|
||||
"first_blocked_at": first_at,
|
||||
"last_blocked_at": last_at,
|
||||
}
|
||||
else:
|
||||
entry["first_blocked_at"] = min(
|
||||
filter(None, (entry["first_blocked_at"], first_at)), default=None)
|
||||
entry["last_blocked_at"] = max(
|
||||
filter(None, (entry["last_blocked_at"], last_at)), default=None)
|
||||
|
||||
|
||||
def _newest_first(grouped: dict[str, dict]) -> list[dict]:
|
||||
"""Most recently blocked first. The signup rows arrive in that order
|
||||
already, but merged-in query-API entries have their own timestamps, so the
|
||||
combined list has to be re-sorted to keep the documented ordering true."""
|
||||
return sorted(grouped.values(), key=lambda e: e["last_blocked_at"] or "", reverse=True)
|
||||
|
||||
|
||||
def _capped(items: list[dict], limit: int) -> list[dict]:
|
||||
@@ -1148,7 +1348,8 @@ def classify_signup_ip(ip: str) -> tuple[str, str, bool, str]:
|
||||
return classification, intel["org"], bool(reasons), intel.get("cidr") or f"{ip}/32"
|
||||
|
||||
|
||||
def register_ip_block(ip: str, acct: str, org: str, cidr: str = "") -> bool:
|
||||
def register_ip_block(ip: str, acct: str, org: str, cidr: str = "",
|
||||
comment: str = "") -> bool:
|
||||
"""Register a flagged signup's network in Mastodon's native Admin::IpBlock.
|
||||
|
||||
cidr should be classify_signup_ip's block_cidr — the ASN/route-level
|
||||
@@ -1157,6 +1358,10 @@ def register_ip_block(ip: str, acct: str, org: str, cidr: str = "") -> bool:
|
||||
Falls back to a single-address block if no cidr is given or ip itself
|
||||
doesn't parse (defensive only; classify_signup_ip always supplies one).
|
||||
|
||||
comment overrides the moderator-facing note stored with the block; the
|
||||
reputation query API passes its own, since a pre-signup block has no
|
||||
account to name.
|
||||
|
||||
Returns True if the range is blocked on the Mastodon side afterwards
|
||||
(including the 422 "already blocked" case), so the caller only records it
|
||||
as blocked when it really is — /api/blocks publishes those rows.
|
||||
@@ -1168,7 +1373,8 @@ def register_ip_block(ip: str, acct: str, org: str, cidr: str = "") -> bool:
|
||||
log.warning("skipping ip_block registration for unparseable ip=%r (acct=%s)", ip, acct)
|
||||
return False
|
||||
cidr = f"{ip}/{prefix_len}"
|
||||
comment = f"welcomebot: flagged signup @{acct} ({org or 'unknown org'})"[:200]
|
||||
comment = (comment or
|
||||
f"welcomebot: flagged signup @{acct} ({org or 'unknown org'})")[:200]
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{MASTODON_BASE_URL}/api/v1/admin/ip_blocks",
|
||||
@@ -1186,6 +1392,181 @@ def register_ip_block(ip: str, acct: str, org: str, cidr: str = "") -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# --- Reputation query API (pre-signup pre-blocking) -------------------------
|
||||
# The pre-emptive half of the bot: Mastodon asks about an IP/email *before*
|
||||
# creating anything, and this installs the block in time for Mastodon's own
|
||||
# lookup to find it. Everything here runs on a live signup request, so it is
|
||||
# written to be fast on the common path (sqlite cache hit) and to fail open —
|
||||
# never to make a signup wait on, or fail because of, a provider outage.
|
||||
|
||||
_lookup_budget_lock = threading.Lock()
|
||||
_lookup_budget: list[float] = []
|
||||
|
||||
|
||||
def _external_lookup_allowed() -> bool:
|
||||
"""Claim one slot from the rolling hourly budget for a fresh provider
|
||||
lookup. Returns False when the budget is spent, in which case the caller
|
||||
must answer "unknown" rather than making the call.
|
||||
|
||||
Only the query API spends budget — see REPUTATION_API_MAX_LOOKUPS_PER_HOUR.
|
||||
Mastodon calls it on every *view* of the signup form, so without a ceiling
|
||||
a crawler walking /auth/sign_up from a fresh range each time sets our paid
|
||||
provider spend.
|
||||
"""
|
||||
if REPUTATION_API_MAX_LOOKUPS_PER_HOUR <= 0:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
with _lookup_budget_lock:
|
||||
cutoff = now - 3600
|
||||
while _lookup_budget and _lookup_budget[0] < cutoff:
|
||||
_lookup_budget.pop(0)
|
||||
if len(_lookup_budget) >= REPUTATION_API_MAX_LOOKUPS_PER_HOUR:
|
||||
return False
|
||||
_lookup_budget.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _reputation_ip(ip: str, source: str) -> dict | None:
|
||||
"""Classify one IP for the query API and install the block if it's flagged.
|
||||
|
||||
Returns None when the IP side is switched off or the address doesn't parse.
|
||||
"""
|
||||
if not (IP_SCRUTINY_ENABLED and ip):
|
||||
return None
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
log.warning("reputation query: unparseable ip=%r", ip)
|
||||
return None
|
||||
|
||||
cached = cached_ip_intel(ip) is not None
|
||||
if not cached and not _external_lookup_allowed():
|
||||
# Budget spent. Answer "unknown", never "flagged" — the whole point of
|
||||
# the ceiling is to stop paying, and guessing in either direction here
|
||||
# would either block innocents or waste the guard.
|
||||
log.warning("reputation query: hourly lookup budget spent, ip=%s answered unknown", ip)
|
||||
bounds = _ip_range_bounds(ip)
|
||||
return {
|
||||
"ip": ip, "classification": "unknown", "org": "", "flagged": False,
|
||||
"cidr": bounds[3] if bounds else "", "blocked": False,
|
||||
"cached": False, "deferred": True,
|
||||
}
|
||||
|
||||
classification, org, flagged, cidr = classify_signup_ip(ip)
|
||||
blocked = False
|
||||
|
||||
if flagged:
|
||||
installable = (REPUTATION_API_INSTALL_BLOCKS and IP_SCRUTINY_AUTO_IPBLOCK
|
||||
and not IP_SCRUTINY_DRY_RUN)
|
||||
if not installable:
|
||||
log.info("reputation query: would block ip=%s cidr=%s (%s) — installing is off",
|
||||
ip, cidr, classification)
|
||||
elif reputation_block_installed("ip", cidr):
|
||||
# Already ours and still in force; Mastodon's own lookup will find
|
||||
# it. Skip the round trip, just count the hit.
|
||||
record_reputation_block("ip", cidr, classification, org)
|
||||
blocked = True
|
||||
elif register_ip_block(ip, "", org, cidr, comment=(
|
||||
f"welcomebot: pre-signup block, {classification} "
|
||||
f"({org or 'unknown org'})")):
|
||||
record_reputation_block("ip", cidr, classification, org)
|
||||
blocked = True
|
||||
log.warning("reputation query: pre-blocked ip=%s cidr=%s classification=%s org=%s source=%s",
|
||||
ip, cidr, classification, org, source or "-")
|
||||
if REPUTATION_API_DM_MODERATOR:
|
||||
dm_moderator(
|
||||
f"🛡️ Pre-blocked {cidr} before signup — {classification} "
|
||||
f"({org or 'unknown org'})."
|
||||
)
|
||||
|
||||
record_reputation_query("ip", ip, classification, org, 0, flagged, cidr, blocked, source)
|
||||
return {
|
||||
"ip": ip, "classification": classification, "org": org, "flagged": flagged,
|
||||
"cidr": cidr, "blocked": blocked, "cached": cached, "deferred": False,
|
||||
}
|
||||
|
||||
|
||||
def _reputation_email(email: str, source: str) -> dict | None:
|
||||
"""Classify one signup email's *domain* for the query API and install the
|
||||
domain block if it's flagged. The full address is never stored or logged —
|
||||
same rule as the webhook path (see classify_email_domain).
|
||||
|
||||
Returns None when the email side is switched off or there's no domain.
|
||||
"""
|
||||
if not (CHECK_MAIL_ENABLED and CHECK_MAIL_API_KEY and email and "@" in email):
|
||||
return None
|
||||
domain = email.rsplit("@", 1)[-1].strip().lower()
|
||||
if not domain:
|
||||
return None
|
||||
|
||||
cached = cached_check_mail(domain) is not None
|
||||
if not cached and not _external_lookup_allowed():
|
||||
log.warning("reputation query: hourly lookup budget spent, domain=%s answered unknown",
|
||||
domain)
|
||||
return {
|
||||
"domain": domain, "disposable": False, "risk": 0, "flagged": False,
|
||||
"blocked": False, "cached": False, "deferred": True,
|
||||
}
|
||||
|
||||
is_disposable, risk = classify_email_domain(domain)
|
||||
flagged = is_disposable or risk >= CHECK_MAIL_RISK_THRESHOLD
|
||||
blocked = False
|
||||
|
||||
if flagged:
|
||||
installable = (REPUTATION_API_INSTALL_BLOCKS and CHECK_MAIL_AUTO_DOMAIN_BLOCK
|
||||
and not CHECK_MAIL_DRY_RUN)
|
||||
if not installable:
|
||||
log.info("reputation query: would block domain=%s (disposable=%s risk=%d) "
|
||||
"— installing is off", domain, is_disposable, risk)
|
||||
elif reputation_block_installed("email", domain):
|
||||
record_reputation_block("email", domain, "", "")
|
||||
blocked = True
|
||||
elif register_email_domain_block(domain, "(pre-signup)"):
|
||||
record_reputation_block("email", domain, "", "")
|
||||
blocked = True
|
||||
log.warning("reputation query: pre-blocked domain=%s disposable=%s risk=%d source=%s",
|
||||
domain, is_disposable, risk, source or "-")
|
||||
if REPUTATION_API_DM_MODERATOR:
|
||||
dm_moderator(
|
||||
f"🛡️ Pre-blocked email domain {domain} before signup "
|
||||
f"(disposable={is_disposable}, risk={risk})."
|
||||
)
|
||||
|
||||
classification = "disposable" if is_disposable else ("high-risk" if flagged else "clean")
|
||||
record_reputation_query("email", domain, classification, "", risk, flagged,
|
||||
domain, blocked, source)
|
||||
return {
|
||||
"domain": domain, "disposable": is_disposable, "risk": risk, "flagged": flagged,
|
||||
"blocked": blocked, "cached": cached, "deferred": False,
|
||||
}
|
||||
|
||||
|
||||
def reputation_check(ip: str = "", email: str = "", source: str = "") -> dict:
|
||||
"""Classify an IP and/or email domain, install any block they earn, and
|
||||
report what happened. The write half of the bot's pre-emptive path.
|
||||
|
||||
Each side is independent: either may be absent from the request, switched
|
||||
off by config, or fail its provider lookup, without affecting the other.
|
||||
A provider failure classifies "unknown"/not-flagged (classify_signup_ip and
|
||||
classify_email_domain both swallow their own errors) — we never block on
|
||||
our own lookup errors.
|
||||
|
||||
`blocked` in the response means "something is blocked on the Mastodon side
|
||||
as a result of this call", which is what the caller acts on: it is true
|
||||
both for a block installed just now and for one we installed earlier that
|
||||
is still in force.
|
||||
"""
|
||||
result = {
|
||||
"ip": _reputation_ip(ip.strip(), source),
|
||||
"email": _reputation_email(email.strip(), source),
|
||||
"checked_at": _now_iso(),
|
||||
}
|
||||
sides = [side for side in (result["ip"], result["email"]) if side]
|
||||
result["flagged"] = any(side["flagged"] for side in sides)
|
||||
result["blocked"] = any(side["blocked"] for side in sides)
|
||||
return result
|
||||
|
||||
|
||||
def count_distinct_reporters(target_id: str) -> int:
|
||||
"""Count distinct accounts with an OPEN report against the target.
|
||||
|
||||
@@ -1507,6 +1888,52 @@ def api_blocks_email_domains(limit: int = 0, format: str = "json"):
|
||||
})
|
||||
|
||||
|
||||
# --- Reputation query API ---
|
||||
# POST /api/reputation — authenticated, and it WRITES (installs blocks), which
|
||||
# is what separates it from the read-only /api/blocks above. Mastodon calls it
|
||||
# from the signup path immediately before its own IpBlock/EmailDomainBlock
|
||||
# lookup; see the `call-welcomebot-reputation-api` patch on the fork.
|
||||
|
||||
|
||||
class ReputationRequest(BaseModel):
|
||||
"""What Mastodon knows at the moment it asks. Both fields are optional —
|
||||
the IP-block choke point has no email address to offer, the email
|
||||
validator has both."""
|
||||
|
||||
ip: str = ""
|
||||
email: str = ""
|
||||
source: str = ""
|
||||
|
||||
|
||||
def _reputation_guard(key: str | None) -> None:
|
||||
"""404 unless the endpoint is switched on AND has a key (a route that
|
||||
installs blocks must never be reachable unauthenticated); 401 on a bad
|
||||
key. Disabled is indistinguishable from absent, same as /api/blocks."""
|
||||
if not (REPUTATION_API_ENABLED and REPUTATION_API_KEY):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
if not key or not hmac.compare_digest(key, REPUTATION_API_KEY):
|
||||
raise HTTPException(status_code=401, detail="invalid key")
|
||||
|
||||
|
||||
@app.post("/api/reputation")
|
||||
def api_reputation(
|
||||
body: ReputationRequest,
|
||||
x_reputation_key: str | None = Header(default=None),
|
||||
):
|
||||
"""Look up an IP and/or email domain, record the answer, install whatever
|
||||
block it earns, and return — in that order, so that a caller running its
|
||||
own block lookup straight afterwards sees the block from this call.
|
||||
|
||||
Declared as a plain `def` on purpose: FastAPI runs those in a threadpool,
|
||||
so the blocking provider calls underneath never tie up the event loop and
|
||||
stall webhook deliveries.
|
||||
"""
|
||||
_reputation_guard(x_reputation_key)
|
||||
if not body.ip and not body.email:
|
||||
raise HTTPException(status_code=400, detail="ip or email is required")
|
||||
return reputation_check(ip=body.ip, email=body.email, source=body.source)
|
||||
|
||||
|
||||
@app.post("/webhook")
|
||||
async def webhook(
|
||||
request: Request,
|
||||
|
||||
@@ -12,6 +12,10 @@ This pass closes that gap: it fetches Mastodon's live `Admin::IpBlock` and
|
||||
rows whose block is gone (or has expired), back on for any that reappeared.
|
||||
The API then serves only entries that are actually in force.
|
||||
|
||||
It covers both sources of blocks: the signup-derived ones (`signup_ip` /
|
||||
`signup_email`, keyed by account) and the pre-signup ones the reputation query
|
||||
API installs (`reputation_block`, keyed by the blocked CIDR/domain itself).
|
||||
|
||||
Run on a schedule (cron on admin.yttrx.com, see CLAUDE.md):
|
||||
|
||||
docker exec yttrx-welcomebot python -m app.reconcile_blocks
|
||||
@@ -150,6 +154,8 @@ def reconcile(dry_run: bool = False) -> int:
|
||||
"UPDATE signup_ip SET block_active = ?, block_checked_at = ? "
|
||||
"WHERE account_id = ?", updates)
|
||||
log.info("ip_blocks: %d live, %d bot rows checked", len(live_ips), len(rows))
|
||||
changed += _reconcile_reputation(
|
||||
"ip", live_ips, now, dry_run, normalise=True)
|
||||
|
||||
if live_domains is not None:
|
||||
with _db() as conn:
|
||||
@@ -175,11 +181,50 @@ def reconcile(dry_run: bool = False) -> int:
|
||||
"WHERE account_id = ?", updates)
|
||||
log.info("email_domain_blocks: %d live, %d bot rows checked",
|
||||
len(live_domains), len(rows))
|
||||
changed += _reconcile_reputation(
|
||||
"email", live_domains, now, dry_run, normalise=False)
|
||||
|
||||
log.info("%s%d row(s) changed state", "[DRY-RUN] " if dry_run else "", changed)
|
||||
return changed
|
||||
|
||||
|
||||
def _reconcile_reputation(kind: str, live: set[str], now: str, dry_run: bool,
|
||||
normalise: bool) -> int:
|
||||
"""Same reconcile, for the blocks the query API installed pre-signup.
|
||||
|
||||
Those live in `reputation_block` keyed by what is actually blocked (a CIDR
|
||||
or a domain) rather than by account, since there is no account yet when
|
||||
they're written — but they are the same Admin::IpBlock /
|
||||
EmailDomainBlock records, so they go stale the same ways and /api/blocks
|
||||
publishes them alongside the signup-derived ones.
|
||||
"""
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT target, block_active FROM reputation_block WHERE kind = ?",
|
||||
(kind,),
|
||||
).fetchall()
|
||||
|
||||
changed = 0
|
||||
updates = []
|
||||
for target, active in rows:
|
||||
key = _normalise(target) if normalise else (target or "").strip().lower()
|
||||
present = bool(target) and key in live
|
||||
if bool(active) != present:
|
||||
changed += 1
|
||||
log.info("reputation %s block %s: %s -> %s", kind, target,
|
||||
"active" if active else "inactive",
|
||||
"active" if present else "inactive")
|
||||
updates.append((int(present), now, kind, target))
|
||||
|
||||
if not dry_run and updates:
|
||||
with _db() as conn:
|
||||
conn.executemany(
|
||||
"UPDATE reputation_block SET block_active = ?, block_checked_at = ? "
|
||||
"WHERE kind = ? AND target = ?", updates)
|
||||
log.info("reputation %s blocks: %d rows checked", kind, len(rows))
|
||||
return changed
|
||||
|
||||
|
||||
def _single_cidr(ip: str) -> str:
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
|
||||
@@ -43,6 +43,28 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Reputation query API (POST /api/reputation). Called by Mastodon on
|
||||
# mammut during signup, so it has to be reachable from off-box — unlike
|
||||
# /webhook this one is authenticated with a shared secret
|
||||
# (X-Reputation-Key) rather than an HMAC over the body, and it is gated in
|
||||
# the app by REPUTATION_API_ENABLED + REPUTATION_API_KEY: with either
|
||||
# unset the app 404s this path.
|
||||
#
|
||||
# It sits on a live signup request, so the proxy timeouts are short: the
|
||||
# caller gives up after ~2s anyway and proceeds without an answer, and a
|
||||
# request left hanging here would just hold a Puma thread on mammut for no
|
||||
# benefit.
|
||||
location = /api/reputation {
|
||||
proxy_pass http://127.0.0.1:8087;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 2s;
|
||||
proxy_send_timeout 5s;
|
||||
proxy_read_timeout 5s;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
|
||||
+204
@@ -979,6 +979,209 @@ def reconcile_blocks_tests():
|
||||
assert "198.18.5.0/24" in active_ips(), "re-registering must reactivate the row"
|
||||
|
||||
|
||||
def reputation_api_tests():
|
||||
"""Drive POST /api/reputation — the pre-signup query path Mastodon calls
|
||||
before its own IpBlock/EmailDomainBlock lookup. The load-bearing property
|
||||
is ordering: the block must be installed by the time the call returns,
|
||||
because the caller looks it up microseconds later."""
|
||||
dms = []
|
||||
ipblocks = []
|
||||
domain_blocks = []
|
||||
main.dm_moderator = lambda message: dms.append(message)
|
||||
main.register_ip_block = (
|
||||
lambda ip, acct, org, cidr="", comment="":
|
||||
ipblocks.append((ip, cidr, comment)) or True)
|
||||
main.register_email_domain_block = (
|
||||
lambda domain, acct: domain_blocks.append((domain, acct)) or True)
|
||||
|
||||
classifications = {
|
||||
"203.0.113.50": ("clean", "Example Residential ISP", False, "203.0.113.50/32"),
|
||||
"198.51.100.50": ("datacenter", "Example Cloud Inc", True, "198.51.100.0/24"),
|
||||
"198.51.100.51": ("datacenter", "Example Cloud Inc", True, "198.51.100.0/24"),
|
||||
"198.51.100.60": ("proxy", "Example Proxy Co", True, "198.51.100.64/26"),
|
||||
}
|
||||
main.classify_signup_ip = lambda ip: classifications[ip]
|
||||
main.classify_email_domain = lambda domain: {
|
||||
"rep-clean.test": (False, 0),
|
||||
"rep-spam.test": (True, 99),
|
||||
}[domain]
|
||||
|
||||
main.IP_SCRUTINY_ENABLED = True
|
||||
main.IP_SCRUTINY_DRY_RUN = False
|
||||
main.IP_SCRUTINY_AUTO_IPBLOCK = True
|
||||
main.CHECK_MAIL_ENABLED = True
|
||||
main.CHECK_MAIL_API_KEY = "dummy"
|
||||
main.CHECK_MAIL_DRY_RUN = False
|
||||
main.CHECK_MAIL_AUTO_DOMAIN_BLOCK = True
|
||||
main.CHECK_MAIL_RISK_THRESHOLD = 80
|
||||
main.REPUTATION_API_INSTALL_BLOCKS = True
|
||||
main.REPUTATION_API_DM_MODERATOR = True
|
||||
main.REPUTATION_API_MAX_LOOKUPS_PER_HOUR = 0 # no ceiling except where tested
|
||||
|
||||
def ask(payload, key="rep-secret"):
|
||||
headers = {"X-Reputation-Key": key} if key is not None else {}
|
||||
return client.post("/api/reputation", json=payload, headers=headers)
|
||||
|
||||
# A. The gate. Disabled, or enabled with no key configured, is a 404 —
|
||||
# a route that installs blocks must never be reachable unauthenticated,
|
||||
# and "off" should look like "not a route".
|
||||
main.REPUTATION_API_ENABLED = False
|
||||
main.REPUTATION_API_KEY = "rep-secret"
|
||||
assert ask({"ip": "203.0.113.50"}).status_code == 404
|
||||
|
||||
main.REPUTATION_API_ENABLED = True
|
||||
main.REPUTATION_API_KEY = ""
|
||||
assert ask({"ip": "203.0.113.50"}).status_code == 404, \
|
||||
"enabled without a key must stay closed, not open"
|
||||
|
||||
main.REPUTATION_API_KEY = "rep-secret"
|
||||
assert ask({"ip": "203.0.113.50"}, key="wrong").status_code == 401
|
||||
assert ask({"ip": "203.0.113.50"}, key=None).status_code == 401
|
||||
|
||||
# B. Something to look up is required.
|
||||
assert ask({}).status_code == 400
|
||||
assert ask({"source": "registration"}).status_code == 400
|
||||
|
||||
# C. A clean IP: answered, recorded, nothing installed.
|
||||
ipblocks.clear(); dms.clear()
|
||||
r = ask({"ip": "203.0.113.50", "source": "registration"})
|
||||
assert r.status_code == 200, r.status_code
|
||||
body = r.json()
|
||||
assert body["flagged"] is False and body["blocked"] is False, body
|
||||
assert body["ip"]["classification"] == "clean"
|
||||
assert body["email"] is None, "no email asked about, no email answer"
|
||||
assert ipblocks == [], "a clean IP must not be blocked"
|
||||
assert dms == [], "a clean IP must not wake a moderator"
|
||||
|
||||
# D. THE POINT OF THE FEATURE: a flagged IP is blocked *during* the call,
|
||||
# so the caller's own IpBlock lookup right afterwards finds it.
|
||||
r = ask({"ip": "198.51.100.50", "source": "registration"})
|
||||
body = r.json()
|
||||
assert body["flagged"] is True and body["blocked"] is True, body
|
||||
assert body["ip"]["cidr"] == "198.51.100.0/24"
|
||||
assert len(ipblocks) == 1, ipblocks
|
||||
assert ipblocks[0][1] == "198.51.100.0/24", "the range is blocked, not the address"
|
||||
assert "pre-signup" in ipblocks[0][2], "the block says why it exists"
|
||||
assert any("198.51.100.0/24" in d for d in dms), dms
|
||||
assert main.reputation_block_installed("ip", "198.51.100.0/24")
|
||||
|
||||
# E. A second address in the same range: still blocked, but we don't
|
||||
# re-POST a block we already installed — this runs on the signup
|
||||
# request path, so the saved round trip is saved latency.
|
||||
dms.clear()
|
||||
r = ask({"ip": "198.51.100.51"})
|
||||
assert r.json()["blocked"] is True, "an already-installed block still counts as blocked"
|
||||
assert len(ipblocks) == 1, "an already-installed block must not be re-POSTed"
|
||||
assert dms == [], "only a newly-installed block wakes a moderator"
|
||||
|
||||
# F. REPUTATION_API_INSTALL_BLOCKS=false classifies and records but
|
||||
# installs nothing — the dry-run equivalent for this path.
|
||||
main.REPUTATION_API_INSTALL_BLOCKS = False
|
||||
r = ask({"ip": "198.51.100.60"})
|
||||
body = r.json()
|
||||
assert body["flagged"] is True, body
|
||||
assert body["blocked"] is False, "install-blocks off must not install"
|
||||
assert len(ipblocks) == 1, ipblocks
|
||||
main.REPUTATION_API_INSTALL_BLOCKS = True
|
||||
|
||||
# G. The email side, and the standing rule that a full address is never
|
||||
# stored, logged or echoed — only its domain.
|
||||
domain_blocks.clear(); dms.clear()
|
||||
r = ask({"ip": "203.0.113.50", "email": "[email protected]", "source": "sign_up"})
|
||||
body = r.json()
|
||||
assert body["email"]["domain"] == "rep-spam.test", "domain is normalised to lower case"
|
||||
assert body["email"]["flagged"] is True and body["email"]["blocked"] is True
|
||||
assert body["blocked"] is True, "either side flagging makes the whole answer blocked"
|
||||
assert body["ip"]["flagged"] is False, "the two sides are independent"
|
||||
assert domain_blocks and domain_blocks[0][0] == "rep-spam.test", domain_blocks
|
||||
assert "SpamGuy" not in r.text and "spamguy" not in r.text.lower(), \
|
||||
"the local part must never come back in the response"
|
||||
with main._db() as conn:
|
||||
stored = conn.execute(
|
||||
"SELECT value FROM reputation_query WHERE kind = 'email'").fetchall()
|
||||
assert all("@" not in v[0] for v in stored), \
|
||||
"reputation_query must store domains, never addresses"
|
||||
|
||||
# H. A clean domain is answered but not blocked.
|
||||
domain_blocks.clear()
|
||||
r = ask({"email": "[email protected]"})
|
||||
assert r.json()["email"]["flagged"] is False
|
||||
assert r.json()["blocked"] is False
|
||||
assert domain_blocks == [], domain_blocks
|
||||
|
||||
# I. The hourly ceiling on fresh provider lookups. Over budget, an
|
||||
# uncached value answers "unknown" and blocks nothing — the guard
|
||||
# exists to stop paying, so it must never guess "flagged" instead.
|
||||
# Addresses here must fall outside every range cached by earlier tests,
|
||||
# or they'd be cache hits and never reach the budget at all.
|
||||
main._lookup_budget.clear()
|
||||
main.REPUTATION_API_MAX_LOOKUPS_PER_HOUR = 1
|
||||
classifications["198.51.102.10"] = ("datacenter", "Example Cloud Inc", True,
|
||||
"198.51.102.0/25")
|
||||
classifications["198.51.102.200"] = ("datacenter", "Other Cloud Inc", True,
|
||||
"198.51.102.128/25")
|
||||
before = len(ipblocks)
|
||||
first = ask({"ip": "198.51.102.10"}).json()["ip"]
|
||||
assert first["cached"] is False and first["deferred"] is False, first
|
||||
assert len(ipblocks) == before + 1, "the in-budget lookup still works normally"
|
||||
|
||||
over = ask({"ip": "198.51.102.200"}).json()["ip"]
|
||||
assert over["deferred"] is True, over
|
||||
assert over["classification"] == "unknown" and over["flagged"] is False, over
|
||||
assert over["blocked"] is False
|
||||
assert len(ipblocks) == before + 1, "an over-budget lookup must install nothing"
|
||||
assert not main.reputation_block_installed("ip", "198.51.102.128/25")
|
||||
|
||||
# A cache hit costs nothing, so it is answered normally even with the
|
||||
# budget spent — the ceiling limits provider spend, not service.
|
||||
cached_hit = ask({"ip": "198.51.100.51"}).json()["ip"]
|
||||
assert cached_hit["cached"] is True and cached_hit["deferred"] is False, cached_hit
|
||||
assert cached_hit["blocked"] is True, "a cached flagged range still answers blocked"
|
||||
main.REPUTATION_API_MAX_LOOKUPS_PER_HOUR = 0
|
||||
main._lookup_budget.clear()
|
||||
|
||||
# J. Blocks installed here show up in the public blocklist alongside the
|
||||
# signup-derived ones — otherwise the published list under-reports as
|
||||
# blocking shifts to the pre-signup path. `signups` still counts
|
||||
# signups, so a range blocked before anyone signed up reads 0.
|
||||
main.BLOCKS_API_ENABLED = True
|
||||
ranges = {e["cidr"]: e for e in client.get("/api/blocks/ip-ranges").json()["items"]}
|
||||
domains = {e["domain"]: e for e in client.get("/api/blocks/email-domains").json()["items"]}
|
||||
assert "198.51.100.0/24" in ranges, ranges
|
||||
assert ranges["198.51.100.0/24"]["signups"] == 0, \
|
||||
"nothing signed up from this range — it was blocked first"
|
||||
assert "rep-spam.test" in domains, domains
|
||||
assert set(ranges["198.51.100.0/24"]) == {
|
||||
"cidr", "signups", "first_blocked_at", "last_blocked_at"}, \
|
||||
"merging must not change the published entry shape"
|
||||
|
||||
# K. ...and the classification behind them stays out of the public list,
|
||||
# same rule as for the signup-derived entries.
|
||||
raw = client.get("/api/blocks").text
|
||||
for leak in ("Example Cloud Inc", "Example Proxy Co", "datacenter", "proxy"):
|
||||
assert leak not in raw, f"{leak!r} must not reach the public blocklist"
|
||||
|
||||
# L. The reconcile pass covers these rows too: a block a moderator removes
|
||||
# stops being published, and reappears if it's registered again.
|
||||
import app.reconcile_blocks as rec
|
||||
rec.live_ip_cidrs = lambda: set()
|
||||
rec.live_email_domains = lambda: set()
|
||||
rec.reconcile()
|
||||
active = {e["cidr"] for e in client.get("/api/blocks/ip-ranges").json()["items"]}
|
||||
assert "198.51.100.0/24" not in active, \
|
||||
"a query-API block removed on the Mastodon side must stop being published"
|
||||
assert not main.reputation_block_installed("ip", "198.51.100.0/24")
|
||||
|
||||
ipblocks.clear()
|
||||
r = ask({"ip": "198.51.100.50"})
|
||||
assert r.json()["blocked"] is True
|
||||
assert len(ipblocks) == 1, "a reconciled-away block is re-installed, not assumed present"
|
||||
active = {e["cidr"] for e in client.get("/api/blocks/ip-ranges").json()["items"]}
|
||||
assert "198.51.100.0/24" in active, "re-installing republishes it"
|
||||
|
||||
main.REPUTATION_API_ENABLED = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
routing_tests()
|
||||
policy_tests()
|
||||
@@ -991,4 +1194,5 @@ if __name__ == "__main__":
|
||||
suspicious_sweep_tests()
|
||||
blocks_api_tests()
|
||||
reconcile_blocks_tests()
|
||||
reputation_api_tests()
|
||||
print("ALL TESTS PASSED")
|
||||
Reference in new issue
Block a user