From 673aca827f38488990d6912c323851966202aab4 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 16 Sep 2026 00:49:22 +0000 Subject: [PATCH] login: sync def so the 0.5s brute-force sleep runs on the threadpool, not the event loop (+latency regression test) --- server/app.py | 6 +++++- tests/test_api.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/server/app.py b/server/app.py index f767579..b059965 100644 --- a/server/app.py +++ b/server/app.py @@ -125,6 +125,7 @@ _SESSION_AGE = 30 * 86400 _PUBLIC = {("/login", "GET"), ("/login", "POST"), ("/api/cdn", "GET")} _SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS +# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min # ponytail: in-memory lockout per username, single process — 5 fails -> 15 min _lockout: dict = {} @@ -193,7 +194,10 @@ def login_page(): @app.post("/login") -async def login(payload: dict = Body(...)): +def login(payload: dict = Body(...)): + # SYNC def on purpose: _lock's 0.5s anti-brute-force sleep must run on + # uvicorn's threadpool — as async def it ran ON the event loop and every + # wrong password stalled every in-flight request (streams included). u = payload.get("username", "").lower() if not u: return JSONResponse({"detail": "username required"}, status_code=400) diff --git a/tests/test_api.py b/tests/test_api.py index e66778a..6dfb8d6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -71,6 +71,30 @@ def test_wrong_password(base=BASE): assert req("POST", "/login", {"username": "admin", "password": "nope"}, base=base) == 401 +def test_wrong_password_does_not_stall_event_loop(base=BASE): + # regression: login was async def, so the 0.5s anti-brute-force sleep ran + # ON the event loop and stalled every in-flight request. With the sync + # handler the sleep runs on the threadpool and /api/cdn answers instantly + # while a wrong-password login is still sleeping. + import threading + import time as _time + done = threading.Event() + + def slow_fail(): + req("POST", "/login", {"username": "loop-probe", "password": "wrong12345"}, base=base) + done.set() + + t = threading.Thread(target=slow_fail, daemon=True) + t.start() + _time.sleep(0.05) # let the login reach its sleep + t0 = _time.time() + assert req("GET", "/api/cdn", base=base) == 200 + dt = _time.time() - t0 + done.wait(timeout=5) + t.join(timeout=5) + assert dt < 0.3, f"/api/cdn took {dt:.2f}s — login's sleep blocked the loop" + + def test_login_and_access(base=BASE): jar = login("admin", "adminpw12345", base) assert req("GET", "/api/search?q=x", jar=jar, base=base) == 200