From 392b5e4a53aace725794c7bbabf9e95fb4e1b9c5 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 14 Sep 2026 01:53:04 +0200 Subject: fix(hub): a per-account sign-in lockout, and a reviewed unauthenticated surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passphrase sign-in locks per username: after `login.max_failures` wrong passphrases (default 4) the name is refused with `429 account_locked` and a `Retry-After` for `login.lockout_minutes` (default 60), without the passphrase being checked. Both numbers are instance policy an admin sets from the panel; zero failures turns it off. The per-IP limit bounds one address, and IPv6 gives every subscriber a /64 of them — an online guess targets an account, so the account is what is counted. - Counted by the name as typed, existing or not, so `login` stays uniform (M1). The key is a hash: people type passphrases into the username field. - The attempt is taken before the check in one `INSERT … ON CONFLICT DO UPDATE … WHERE … RETURNING`, so a concurrent burst gets no more than the limit. - Sign-in, passphrase change and account deletion count on the same row; the last had no rate limit at all. - A lockout refuses passphrase sign-in and nothing else: sessions, renewal and device sign-in continue, and a reset code clears it (AV26). A session learns its own lockout from `/v1/users/me`, and the passphrase change checks it before re-wrapping any node's bundle — the hub accepts the new passphrase only after the nodes have it. The SPA now shows what the hub said. `loginAndRecover` threw "Login failed: {json}", so `email_verification_required` never matched and was never shown; the passphrase-change form rendered no error at all in its first phase. The unauthenticated surface, reviewed route by route: - No `/docs`, `/redoc` or `/openapi.json`, in the code. The Caddyfile hid them on meshbay.org only; a packaged hub behind any other proxy published all three. - The node socket's first message must arrive within ten seconds. It is accepted before anyone is known, and an unbounded read is a connection any stranger holds for free. - `/v1/relays` answers 503 behind `relay.RELAYS_ENABLED`, as federation does: nothing in the tree calls it and two of its routes take no account. - `test_unauthenticated_surface.py` walks every route and fails on one without an authentication dependency that is not listed with its reason. Verified in Chrome against a local hub: the lockout and wrong-passphrase messages, the admin section saving both lockout and mail limits, and the passphrase change refused while locked. Not verified in Firefox (a running instance blocks the headless one), nor the upsert's concurrency on PostgreSQL. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt --- .../meshbay-hub/src/meshbay_hub/login_throttle.py | 143 +++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/login_throttle.py (limited to 'packages/meshbay-hub/src/meshbay_hub/login_throttle.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py new file mode 100644 index 0000000..3281088 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py @@ -0,0 +1,143 @@ +"""Per-account sign-in lockout: after N wrong passphrases, refuse for a while. + +The per-IP rate limit on `login` bounds one address, and IPv6 hands every +subscriber a /64 of them. What an online guess actually targets is an account, +so that is what this counts. + +Three properties, each for a reason: + +- **Keyed by the username as typed, whether or not the account exists.** An + unknown name locks exactly like a real one, so a 429 says nothing a 401 did + not — `login` stays uniform (M1). The key is a hash: people type passphrases + into the username field, and this table must not keep them. +- **The attempt is counted before the passphrase is checked, in one statement.** + Read-then-write would let a burst of concurrent requests all read "three + failures" and all be checked; an `INSERT … ON CONFLICT DO UPDATE … WHERE` + either takes one attempt or reports that none is left, atomically on SQLite + and PostgreSQL alike. +- **A locked account is refused without verifying anything**, so a guess made + during the lockout learns nothing — not even whether it was right. + +What a lockout does not touch: sessions already open, token renewal, and device +sign-in, none of which take a passphrase. That is what keeps a stranger who +locks somebody else's name from signing them out (§13.5b, AV26). +""" + +import hashlib +from datetime import datetime, timedelta, timezone + +from sqlalchemy import case, delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub import hub_settings +from meshbay_hub.db.models import LoginThrottle + + +def _key(username: str) -> str: + return hashlib.sha256(f"meshbay:login:{username}".encode()).hexdigest() + + +def _aware(dt: datetime) -> datetime: + # SQLite hands back naive datetimes for a timezone-aware column. + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) + + +def _insert_for(db: AsyncSession): + dialect = db.bind.dialect.name + if dialect == "postgresql": + from sqlalchemy.dialects.postgresql import insert + elif dialect == "sqlite": + from sqlalchemy.dialects.sqlite import insert + else: + raise RuntimeError(f"login throttle has no upsert for dialect {dialect!r}") + return insert + + +async def reserve(db: AsyncSession, username: str) -> tuple[bool, int]: + """Take one attempt for `username`, and commit it before anything is checked. + + Returns `(allowed, retry_after_seconds)`. When allowed, the second value is + 0; when not, it is how long the lockout has left. + """ + limits = await hub_settings.login_limits(db) + max_failures = limits["max_failures"] + if max_failures == 0: + return True, 0 + + now = datetime.now(timezone.utc) + window = timedelta(minutes=limits["lockout_minutes"]) + window_start = now - window + key = _key(username) + + table = LoginThrottle.__table__ + stale = table.c.last_failure_at < window_start + insert = _insert_for(db) + stmt = ( + insert(table) + .values(key=key, failures=1, last_failure_at=now) + .on_conflict_do_update( + index_elements=[table.c.key], + # Failures older than the window have aged out: start again at one + # rather than carrying three typos from last week into today. + set_={"failures": case((stale, 1), else_=table.c.failures + 1), + "last_failure_at": now}, + where=(table.c.failures < max_failures) | stale, + ) + .returning(table.c.failures) + ) + taken = (await db.execute(stmt)).first() + await db.commit() + if taken is not None: + return True, 0 + return False, max(1, await locked_for(db, username)) + + +async def locked_for(db: AsyncSession, username: str) -> int: + """Seconds left on a lockout, 0 if none — without spending an attempt.""" + limits = await hub_settings.login_limits(db) + if limits["max_failures"] == 0: + return 0 + row = await db.get(LoginThrottle, _key(username)) + if row is None or row.failures < limits["max_failures"]: + return 0 + remaining = (_aware(row.last_failure_at) + + timedelta(minutes=limits["lockout_minutes"]) + - datetime.now(timezone.utc)).total_seconds() + return max(0, int(remaining + 0.999)) + + +async def is_now_locked(db: AsyncSession, username: str) -> bool: + """After a failure: did that one spend the last attempt?""" + limits = await hub_settings.login_limits(db) + if limits["max_failures"] == 0: + return False + failures = await db.scalar( + select(LoginThrottle.failures).where(LoginThrottle.key == _key(username))) + return (failures or 0) >= limits["max_failures"] + + +async def release(db: AsyncSession, username: str) -> None: + """Give back an attempt that checked no passphrase. The caller owns the commit. + + Only ever undoes the caller's own reservation, so it cannot be used to earn + attempts: the net effect of reserve-then-release is nothing. + """ + await db.execute( + update(LoginThrottle) + .where(LoginThrottle.key == _key(username), LoginThrottle.failures > 0) + .values(failures=LoginThrottle.failures - 1)) + + +async def clear(db: AsyncSession, username: str) -> None: + """The right passphrase, or a reset proved by e-mail. The caller owns the commit.""" + await db.execute(delete(LoginThrottle).where(LoginThrottle.key == _key(username))) + + +async def purge_expired(db: AsyncSession) -> int: + """Rows whose failures have aged out. Every unknown name typed creates one.""" + limits = await hub_settings.login_limits(db) + cutoff = datetime.now(timezone.utc) - timedelta(minutes=limits["lockout_minutes"]) + result = await db.execute( + delete(LoginThrottle).where(LoginThrottle.last_failure_at < cutoff)) + await db.commit() + return result.rowcount -- cgit v1.2.3