Frame passkey-login 200 response with Content-Length
The passkey-login success handler wrote its 200 JSON body without a Content-Length header, unlike the password-login and logout handlers. Under HTTP/1.1 keep-alive that response is unframed, so the browser's fetch().json() waits for connection close and appears to hang until it times out. Encode the body once and send Content-Length before end_headers(), mirroring the /api/auth/login block. Header order is unchanged so set_auth_cookie's Set-Cookie still precedes end_headers(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9dacbe60de
commit
b10116bffc
2 files changed
+96
-1
No files matched your search
+3
-1
@@ -15336,13 +15336,15 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
_record_login_attempt(client_ip)
|
_record_login_attempt(client_ip)
|
||||||
return bad(handler, str(e), status=401)
|
return bad(handler, str(e), status=401)
|
||||||
cookie_val = create_session()
|
cookie_val = create_session()
|
||||||
|
body = json.dumps({"ok": True}).encode()
|
||||||
handler.send_response(200)
|
handler.send_response(200)
|
||||||
handler.send_header("Content-Type", "application/json")
|
handler.send_header("Content-Type", "application/json")
|
||||||
|
handler.send_header("Content-Length", str(len(body)))
|
||||||
handler.send_header("Cache-Control", "no-store")
|
handler.send_header("Cache-Control", "no-store")
|
||||||
_security_headers(handler)
|
_security_headers(handler)
|
||||||
set_auth_cookie(handler, cookie_val)
|
set_auth_cookie(handler, cookie_val)
|
||||||
handler.end_headers()
|
handler.end_headers()
|
||||||
handler.wfile.write(json.dumps({"ok": True}).encode())
|
handler.wfile.write(body)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if parsed.path == "/api/auth/passkey/register/options":
|
if parsed.path == "/api/auth/passkey/register/options":
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Regression test — passkey login must frame its 200 response with Content-Length.
|
||||||
|
|
||||||
|
The password-login and logout handlers set a `Content-Length` header on their
|
||||||
|
success response, but the passkey-login success block wrote the JSON body with
|
||||||
|
no `Content-Length`. Under HTTP/1.1 keep-alive that response is unframed, so the
|
||||||
|
browser's `fetch().json()` hangs until it times out. This test pins that the
|
||||||
|
passkey-login 200 now carries a `Content-Length` matching the body it writes,
|
||||||
|
mirroring the password-login block.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHeaders(dict):
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return dict.get(self, key, default)
|
||||||
|
|
||||||
|
|
||||||
|
class RouteFakeHandler:
|
||||||
|
def __init__(self):
|
||||||
|
self.headers = FakeHeaders({"Host": "localhost:8787", "Content-Length": "0"})
|
||||||
|
self.rfile = io.BytesIO(b"")
|
||||||
|
self.wfile = io.BytesIO()
|
||||||
|
self.status = None
|
||||||
|
self.sent_headers = []
|
||||||
|
self.client_address = ("127.0.0.1", 12345)
|
||||||
|
|
||||||
|
def send_response(self, status):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def send_header(self, key, value):
|
||||||
|
self.sent_headers.append((key, value))
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_passkey_login_success_sets_content_length(monkeypatch):
|
||||||
|
import api.auth as auth
|
||||||
|
import api.passkeys as passkeys
|
||||||
|
import api.routes as routes
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||||
|
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
|
||||||
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
||||||
|
monkeypatch.setattr(auth, "_check_login_rate", lambda ip: True)
|
||||||
|
# WebAuthn verification is out of scope here — make it succeed.
|
||||||
|
monkeypatch.setattr(passkeys, "finish_login", lambda body, handler: None)
|
||||||
|
monkeypatch.setattr(auth, "create_session", lambda: "sess-cookie")
|
||||||
|
monkeypatch.setattr(auth, "set_auth_cookie", lambda handler, cookie: None)
|
||||||
|
|
||||||
|
handler = RouteFakeHandler()
|
||||||
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/login"))
|
||||||
|
|
||||||
|
assert handler.status == 200
|
||||||
|
body = handler.wfile.getvalue()
|
||||||
|
assert json.loads(body) == {"ok": True}
|
||||||
|
|
||||||
|
header_map = {k.lower(): v for k, v in handler.sent_headers}
|
||||||
|
assert "content-length" in header_map, "passkey-login 200 must be framed"
|
||||||
|
assert header_map["content-length"] == str(len(body))
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_length_precedes_end_headers(monkeypatch):
|
||||||
|
"""`set_auth_cookie` emits Set-Cookie via send_header, so every header —
|
||||||
|
including Content-Length — must be sent before end_headers()."""
|
||||||
|
import api.auth as auth
|
||||||
|
import api.passkeys as passkeys
|
||||||
|
import api.routes as routes
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||||
|
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
|
||||||
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
||||||
|
monkeypatch.setattr(auth, "_check_login_rate", lambda ip: True)
|
||||||
|
monkeypatch.setattr(passkeys, "finish_login", lambda body, handler: None)
|
||||||
|
monkeypatch.setattr(auth, "create_session", lambda: "sess-cookie")
|
||||||
|
monkeypatch.setattr(auth, "set_auth_cookie",
|
||||||
|
lambda handler, cookie: handler.send_header("Set-Cookie", "s=1"))
|
||||||
|
|
||||||
|
order = []
|
||||||
|
handler = RouteFakeHandler()
|
||||||
|
orig_send = handler.send_header
|
||||||
|
orig_end = handler.end_headers
|
||||||
|
handler.send_header = lambda k, v: (order.append(("hdr", k.lower())), orig_send(k, v))[1]
|
||||||
|
handler.end_headers = lambda: (order.append(("end", None)), orig_end())[1]
|
||||||
|
|
||||||
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/login"))
|
||||||
|
|
||||||
|
end_idx = order.index(("end", None))
|
||||||
|
header_names = [name for kind, name in order[:end_idx] if kind == "hdr"]
|
||||||
|
assert "content-length" in header_names
|
||||||
|
assert "set-cookie" in header_names
|
||||||
Reference in new issue
Block a user