security: rate-limit admin login and TOTP verification

Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.

DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).

10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.

Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-12 16:42:20 +05:00
parent 11c75c13d1
commit 52f5c551cb
3 changed files with 54 additions and 2 deletions

37
db.py
View file

@ -68,6 +68,14 @@ CREATE TABLE IF NOT EXISTS pending_totp (
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_lookup ON login_attempts (ip, kind, created_at);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
tg_id INTEGER NOT NULL,
@ -544,6 +552,35 @@ def delete_expired_pending_totp():
conn.execute("DELETE FROM pending_totp WHERE expires_at<=?", (now_iso(),))
def record_login_attempt(ip: str, kind: str):
with get_conn() as conn:
conn.execute(
"INSERT INTO login_attempts (ip, kind, created_at) VALUES (?,?,?)",
(ip, kind, now_iso()),
)
def count_recent_login_attempts(ip: str, kind: str, minutes: int) -> int:
since = (datetime.datetime.utcnow() - datetime.timedelta(minutes=minutes)).isoformat()
with get_conn() as conn:
row = conn.execute(
"SELECT COUNT(*) c FROM login_attempts WHERE ip=? AND kind=? AND created_at>?",
(ip, kind, since),
).fetchone()
return row["c"]
def clear_login_attempts(ip: str, kind: str):
with get_conn() as conn:
conn.execute("DELETE FROM login_attempts WHERE ip=? AND kind=?", (ip, kind))
def delete_old_login_attempts(hours: int = 1):
cutoff = (datetime.datetime.utcnow() - datetime.timedelta(hours=hours)).isoformat()
with get_conn() as conn:
conn.execute("DELETE FROM login_attempts WHERE created_at<=?", (cutoff,))
def list_all_subscriptions(limit: int = 200):
with get_conn() as conn:
rows = conn.execute(