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:
ai-ag2026andClaude Opus 4.8 authored and t committed 2026-07-09 01:05:28 +00:00
1 parent 9dacbe60de
commit b10116bffc
2 files changed
+96 -1

No files matched your search

+3 -1
View File
@@ -15336,13 +15336,15 @@ def handle_post(handler, parsed) -> bool:
_record_login_attempt(client_ip)
return bad(handler, str(e), status=401)
cookie_val = create_session()
body = json.dumps({"ok": True}).encode()
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
_security_headers(handler)
set_auth_cookie(handler, cookie_val)
handler.end_headers()
handler.wfile.write(json.dumps({"ok": True}).encode())
handler.wfile.write(body)
return True
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