Throttle login and registration with exponential backoff (B-33)

POST /auth/login had no rate limiting, no lockout, no delay — a patient
distributed attack could brute-force a password against an enumerable
username list on a custodial wallet, where a guessed password means
withdrawing someone's funds.

Add per-username and per-IP throttling with exponential backoff
(app/auth/rate_limit.py), keyed on app.state like UserLocks rather than
a module global so each app instance gets isolated throttle state.
Unknown-user and wrong-password already shared one response path, so no
enumeration oracle there. Registration is throttled per-IP too, which
also bounds how many accounts one IP can spin up (B-31).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 11:51:26 +02:00
co-authored by Claude Sonnet 5
parent 17c557b8a3
commit 16802cafb6
5 changed files with 278 additions and 22 deletions
+6 -21
View File
@@ -1,11 +1,11 @@
# Known bugs # Known bugs
A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high, A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high,
7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-32 are fixed (see "Previously 7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-33 are fixed (see "Previously
fixed" below) — no Critical-severity finding remains open; the other 17 are High/Medium/Low. fixed" below) — no Critical-severity finding remains open; the other 16 are High/Medium/Low.
The 139-test suite was green at the time of the audit, so none of these were caught by existing The 139-test suite was green at the time of the audit, so none of these were caught by existing
coverage — every fix lands with a regression test (the eight fixes so far brought the suite coverage — every fix lands with a regression test (the nine fixes so far brought the suite
from 139 to 187). from 139 to 192).
The recurring pattern across the open findings is worth stating once: the code is rigorous The recurring pattern across the open findings is worth stating once: the code is rigorous
about the failure modes that have actually been hit, and silent about the ones that have not. about the failure modes that have actually been hit, and silent about the ones that have not.
@@ -20,22 +20,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD
## High ## High
### B-33 — No brute-force protection on a custodial wallet
`POST /auth/login` (`auth/routes.py:83`) has no rate limiting, no lockout, no delay and no
CAPTCHA, and the password minimum is 8 characters. Argon2 slows a single attempt but not a
patient distributed attack against an enumerable username list — and `409 username_taken` on
registration is a perfect enumeration oracle.
"No rate limiting" is listed as a generic known gap; on a system where guessing a password
means **withdrawing someone's funds**, it deserves to be tracked separately and treated as a
blocker.
**Proposed fix.** Per-username *and* per-IP throttling with exponential backoff on failed
logins (`slowapi`, or a small DB-backed counter — but note the in-process caveat if workers
are ever scaled). Return an identical response for unknown-user and wrong-password. Rate-limit
registration too, which also bounds B-31's attacker-controlled user count.
### B-34 — Password change and admin reset do not invalidate existing sessions ### B-34 — Password change and admin reset do not invalidate existing sessions
Neither `/users/me/change-password` nor `/admin/users/{id}/reset-password` invalidates Neither `/users/me/change-password` nor `/admin/users/{id}/reset-password` invalidates
@@ -227,9 +211,10 @@ already does.
- **B-30** — a lost scripthash subscription meant a user's deposits were never credited, with no periodic safety net - **B-30** — a lost scripthash subscription meant a user's deposits were never credited, with no periodic safety net
- **B-31** — resubscribing on reconnect ran serially before anything else started, freezing the chain tip (and so an in-flight draw) for the whole sweep - **B-31** — resubscribing on reconnect ran serially before anything else started, freezing the chain tip (and so an in-flight draw) for the whole sweep
- **B-32** — an RBF bump's fee delta could fall below BIP125's relay-mandated minimum, so the node rejected it and the same tick retried identically forever; also had no ceiling on how high the fee rate could climb - **B-32** — an RBF bump's fee delta could fall below BIP125's relay-mandated minimum, so the node rejected it and the same tick retried identically forever; also had no ceiling on how high the fee rate could climb
- **B-33** — `POST /auth/login` had no rate limiting on a custodial wallet, so a patient distributed attack could brute-force a password against an enumerable username list; fixed with per-username *and* per-IP exponential backoff (`app/auth/rate_limit.py`), registration throttled per-IP too (also bounds B-31's attacker-controlled user count)
See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the
B-28/B-29/B-30/B-31/B-32 fixes). Suite grew from 139 to 187 tests over the eight. B-28/B-29/B-30/B-31/B-32/B-33 fixes). Suite grew from 139 to 192 tests over the nine.
A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python
module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical, module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical,
+75
View File
@@ -0,0 +1,75 @@
import time
from dataclasses import dataclass
@dataclass
class _Bucket:
failures: int = 0
locked_until: float = 0.0
last_failure_at: float = 0.0
class RateLimiter:
"""In-process failed-attempt throttle with exponential backoff, keyed by an
arbitrary string (username, IP...). Single-process-only, like UserLocks
(app/tx/locks.py) — an accepted MVP constraint; a multi-worker deployment
would need a shared store (Redis) instead (B-33).
Brute-forcing a login here isn't a spammy client to be capped at N req/s —
it's an attempt to withdraw someone else's funds — so failures are
penalized with a delay that doubles each time past `threshold` free
attempts, rather than a flat rate cap. `decay_seconds` ages a bucket back
to zero once failures stop, so a shared/NAT IP isn't punished forever for
someone else's earlier mistakes.
"""
def __init__(
self,
threshold: int = 5,
base_delay: float = 2.0,
max_delay: float = 300.0,
decay_seconds: float = 900.0,
) -> None:
self._threshold = threshold
self._base_delay = base_delay
self._max_delay = max_delay
self._decay_seconds = decay_seconds
self._buckets: dict[str, _Bucket] = {}
def retry_after(self, key: str) -> float:
bucket = self._buckets.get(key)
if bucket is None:
return 0.0
remaining = bucket.locked_until - time.monotonic()
return remaining if remaining > 0 else 0.0
def record_failure(self, key: str) -> None:
now = time.monotonic()
bucket = self._buckets.setdefault(key, _Bucket())
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
bucket.failures = 0
bucket.failures += 1
bucket.last_failure_at = now
if bucket.failures >= self._threshold:
delay = min(self._max_delay, self._base_delay * 2 ** (bucket.failures - self._threshold))
bucket.locked_until = now + delay
def record_success(self, key: str) -> None:
self._buckets.pop(key, None)
class AuthRateLimiters:
"""The three throttles B-33 needs, bundled so they can live on `app.state`
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
A module global would persist for the lifetime of the process — fine in
production (one app instance), but wrong in the test suite, where every
test builds its own FastAPI app against a fresh in-memory DB and expects a
clean slate; a shared global would leak failure counts between unrelated
tests. Per-`app.state` state gets a fresh instance per app automatically.
"""
def __init__(self) -> None:
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0)
+64 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error from app.api.errors import http_error
from app.auth.rate_limit import AuthRateLimiters
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
from app.db.models import User from app.db.models import User
from app.db.session import get_session from app.db.session import get_session
@@ -15,6 +16,43 @@ router = APIRouter(prefix="/auth", tags=["auth"])
_MAX_REGISTER_RETRIES = 5 _MAX_REGISTER_RETRIES = 5
def _rate_limiters(request: Request) -> AuthRateLimiters:
# B-33: no rate limiting on login was a brute-forceable path to withdrawing
# someone else's funds. Keyed per-username *and* per-IP so an attacker can't
# dodge the throttle by spraying one password across many accounts, nor by
# routing one account's guesses through many IPs alone (the username key
# still catches that). The IP limiter's threshold is deliberately higher
# than the username one: a single account should lock out fast, but a
# shared/NAT IP hosting several genuine users shouldn't be punished for one
# of them mistyping a password a few times. Registration gets its own,
# coarser limiter, IP-only — no username exists yet to key on — mainly to
# bound how many accounts one IP can spin up (B-31), not to protect a
# secret. Lives on app.state (see AuthRateLimiters) rather than a module
# global so each app instance gets its own, isolated throttle state.
if not hasattr(request.app.state, "auth_rate_limiters"):
request.app.state.auth_rate_limiters = AuthRateLimiters()
return request.app.state.auth_rate_limiters
def _client_ip(request: Request) -> str:
# Caddy (see Caddyfile) reverse-proxies every request, so request.client.host
# is the proxy's address, not the caller's — fall back to it only if the
# header is somehow missing (e.g. hitting the app container directly).
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _rate_limited_error(retry_after: float):
return http_error(
status.HTTP_429_TOO_MANY_REQUESTS,
"rate_limited",
"too many attempts, try again later",
retry_after_seconds=int(retry_after) + 1,
)
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
"""Registration used to accept an empty username and a one-character password, """Registration used to accept an empty username and a one-character password,
while /users/me/change-password demanded 8 characters — an odd place to be while /users/me/change-password demanded 8 characters — an odd place to be
@@ -34,6 +72,13 @@ class TokenResponse(BaseModel):
async def register( async def register(
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session) body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
) -> TokenResponse: ) -> TokenResponse:
limiters = _rate_limiters(request)
ip_key = f"ip:{_client_ip(request)}"
retry_after = limiters.register_ip.retry_after(ip_key)
if retry_after > 0:
raise _rate_limited_error(retry_after)
limiters.register_ip.record_failure(ip_key)
existing = await session.scalar(select(User).where(User.username == body.username)) existing = await session.scalar(select(User).where(User.username == body.username))
if existing is not None: if existing is not None:
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken") raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
@@ -81,8 +126,26 @@ class LoginRequest(BaseModel):
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse: async def login(
body: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
) -> TokenResponse:
limiters = _rate_limiters(request)
username_key = f"user:{body.username.lower()}"
ip_key = f"ip:{_client_ip(request)}"
retry_after = max(limiters.login.retry_after(username_key), limiters.login_ip.retry_after(ip_key))
if retry_after > 0:
raise _rate_limited_error(retry_after)
user = await session.scalar(select(User).where(User.username == body.username)) user = await session.scalar(select(User).where(User.username == body.username))
if user is None or not verify_password(body.password, user.password_hash): if user is None or not verify_password(body.password, user.password_hash):
# Same code path (and therefore the same response) whether the username
# doesn't exist or the password is wrong — no enumeration oracle here.
limiters.login.record_failure(username_key)
limiters.login_ip.record_failure(ip_key)
raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials") raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials")
# Only the username bucket resets on success — the IP bucket is left to decay
# on its own, so one correct login can't be used to wipe out an IP's failure
# count while it's mid-attack against other accounts.
limiters.login.record_success(username_key)
return TokenResponse(access_token=create_access_token(user.id), address=user.address) return TokenResponse(access_token=create_access_token(user.id), address=user.address)
+7
View File
@@ -137,6 +137,7 @@ const TRANSLATIONS = {
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.', 'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
'error.internal_error': 'Unexpected server error. Please try again shortly.', 'error.internal_error': 'Unexpected server error. Please try again shortly.',
'error.guide_unavailable': 'The guide is not available right now.', 'error.guide_unavailable': 'The guide is not available right now.',
'error.rate_limited': 'Too many attempts, please try again in {retry_after_seconds} seconds.',
'loading.creating': 'Creating…', 'loading.creating': 'Creating…',
'loading.loggingIn': 'Logging in…', 'loading.loggingIn': 'Logging in…',
@@ -274,6 +275,7 @@ const TRANSLATIONS = {
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.', 'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
'error.internal_error': 'Errore inatteso del server. Riprova tra poco.', 'error.internal_error': 'Errore inatteso del server. Riprova tra poco.',
'error.guide_unavailable': 'La guida non è disponibile in questo momento.', 'error.guide_unavailable': 'La guida non è disponibile in questo momento.',
'error.rate_limited': 'Troppi tentativi, riprova tra {retry_after_seconds} secondi.',
'loading.creating': 'Creazione…', 'loading.creating': 'Creazione…',
'loading.loggingIn': 'Accesso…', 'loading.loggingIn': 'Accesso…',
@@ -411,6 +413,7 @@ const TRANSLATIONS = {
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.', 'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.', 'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.',
'error.guide_unavailable': 'La guía no está disponible en este momento.', 'error.guide_unavailable': 'La guía no está disponible en este momento.',
'error.rate_limited': 'Demasiados intentos, inténtalo de nuevo en {retry_after_seconds} segundos.',
'loading.creating': 'Creando…', 'loading.creating': 'Creando…',
'loading.loggingIn': 'Entrando…', 'loading.loggingIn': 'Entrando…',
@@ -548,6 +551,7 @@ const TRANSLATIONS = {
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.", 'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.', 'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.',
'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.", 'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.",
'error.rate_limited': 'Trop de tentatives, réessayez dans {retry_after_seconds} secondes.',
'loading.creating': 'Création…', 'loading.creating': 'Création…',
'loading.loggingIn': 'Connexion…', 'loading.loggingIn': 'Connexion…',
@@ -685,6 +689,7 @@ const TRANSLATIONS = {
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.', 'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.', 'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.',
'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.', 'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.',
'error.rate_limited': 'Zu viele Versuche, bitte versuche es in {retry_after_seconds} Sekunden erneut.',
'loading.creating': 'Wird erstellt…', 'loading.creating': 'Wird erstellt…',
'loading.loggingIn': 'Anmeldung…', 'loading.loggingIn': 'Anmeldung…',
@@ -822,6 +827,7 @@ const TRANSLATIONS = {
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.', 'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.',
'error.guide_unavailable': 'Руководство сейчас недоступно.', 'error.guide_unavailable': 'Руководство сейчас недоступно.',
'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.',
'loading.creating': 'Создание…', 'loading.creating': 'Создание…',
'loading.loggingIn': 'Вход…', 'loading.loggingIn': 'Вход…',
@@ -959,6 +965,7 @@ const TRANSLATIONS = {
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
'error.internal_error': '服务器发生意外错误,请稍后重试。', 'error.internal_error': '服务器发生意外错误,请稍后重试。',
'error.guide_unavailable': '指南当前不可用。', 'error.guide_unavailable': '指南当前不可用。',
'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。',
'loading.creating': '正在创建…', 'loading.creating': '正在创建…',
'loading.loggingIn': '正在登录…', 'loading.loggingIn': '正在登录…',
+126
View File
@@ -0,0 +1,126 @@
import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from fastapi import FastAPI
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def _register(client, username="alice", password="original-password"):
resp = await client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 201
return resp.json()["access_token"]
async def test_login_locks_out_after_repeated_failures(client):
await _register(client)
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"
# Even the *correct* password is refused while locked out — the throttle
# protects against a lucky guess landing inside the backoff window too.
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 429
async def test_unknown_username_and_wrong_password_share_a_bucket_and_response(client):
await _register(client, username="bob")
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "invalid_credentials"
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 429
async def test_login_failures_against_one_account_do_not_lock_out_another(client):
await _register(client, username="alice")
await _register(client, username="carol", password="carols-password")
for _ in range(6):
await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
# Different username, but same IP (the test client always looks the same) —
# only the per-username bucket should be exhausted, not the whole IP, since
# the per-username threshold (5) is hit well before the shared IP bucket's.
resp = await client.post("/auth/login", json={"username": "carol", "password": "carols-password"})
assert resp.status_code == 200
async def test_successful_login_resets_the_username_bucket(client):
await _register(client)
for _ in range(4):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
async def test_registration_is_rate_limited_per_ip(client):
for i in range(5):
resp = await client.post(
"/auth/register", json={"username": f"user{i}", "password": "a-strong-password"}
)
assert resp.status_code == 201
resp = await client.post(
"/auth/register", json={"username": "user5", "password": "a-strong-password"}
)
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"