login: sync def so the 0.5s brute-force sleep runs on the threadpool, not the event loop (+latency regression test)
This commit is contained in:
1 parent
9a4b8fb299
commit
673aca827f
2 files changed
+29
-1
No files matched your search
+5
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user