From 52f5c551cbe84379b660db94f88efb0d7810c5f7 Mon Sep 17 00:00:00 2001 From: savsis Date: Sat, 12 Sep 2026 16:42:20 +0500 Subject: [PATCH] security: rate-limit admin login and TOTP verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api.py | 18 ++++++++++++++++-- bot.py | 1 + db.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index c0ef6c8..147ab2a 100644 --- a/api.py +++ b/api.py @@ -436,13 +436,22 @@ async def register_node(token: str, request: Request): +def _client_ip(request: Request) -> str: + return request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown") + + @app.post("/admin/api/login") -def admin_login(response: Response, body: dict = Body(...)): +def admin_login(request: Request, response: Response, body: dict = Body(...)): + ip = _client_ip(request) + if db.count_recent_login_attempts(ip, "password", minutes=15) >= 10: + raise HTTPException(429, "too many attempts, try again later") username = (body.get("username") or "").strip() password = body.get("password") or "" admin = db.verify_admin_login(username, password) if not admin: + db.record_login_attempt(ip, "password") raise HTTPException(401, "wrong username or password") + db.clear_login_attempts(ip, "password") if admin.get("totp_secret"): pending_token = db.create_pending_totp(admin["id"]) return {"ok": True, "needs_totp": True, "pending_token": pending_token} @@ -452,7 +461,10 @@ def admin_login(response: Response, body: dict = Body(...)): @app.post("/admin/api/login/totp") -def admin_login_totp(response: Response, body: dict = Body(...)): +def admin_login_totp(request: Request, response: Response, body: dict = Body(...)): + ip = _client_ip(request) + if db.count_recent_login_attempts(ip, "totp", minutes=5) >= 10: + raise HTTPException(429, "too many attempts, try again later") pending_token = body.get("pending_token") or "" code = (body.get("code") or "").strip() pending = db.resolve_pending_totp(pending_token) @@ -460,7 +472,9 @@ def admin_login_totp(response: Response, body: dict = Body(...)): raise HTTPException(401, "login session expired, log in again") admin = db.get_admin_by_id(pending["admin_id"]) if not admin or not admin.get("totp_secret") or not totp.verify(admin["totp_secret"], code): + db.record_login_attempt(ip, "totp") raise HTTPException(401, "wrong code") + db.clear_login_attempts(ip, "totp") db.delete_pending_totp(pending_token) token = db.create_admin_session(admin["id"]) response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600) diff --git a/bot.py b/bot.py index c01e695..ac49087 100644 --- a/bot.py +++ b/bot.py @@ -370,6 +370,7 @@ async def periodic_sync(): try: db.delete_expired_admin_sessions() db.delete_expired_pending_totp() + db.delete_old_login_attempts() except Exception: log.exception("expired admin session cleanup failed") await asyncio.sleep(90) diff --git a/db.py b/db.py index 91914c7..e36f054 100644 --- a/db.py +++ b/db.py @@ -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(