fix(security): POST WebAuthn login-begin; render JS errors as text

Two WebAuthn client/route hardening fixes:

- GET /login/webauthn/begin wrote a challenge to the session on a safe method
  (login-flow DoS / CSRF surface). Make it POST with CSRF, matching the
  registration-begin endpoint; update webauthn.js and tests accordingly.

- webauthn.js rendered dynamic error text (err.message, server error fields)
  via innerHTML — an XSS-prone sink. Add showAlert() that sets textContent;
  route all dynamic error messages through it. The trusted server-rendered
  credentials partial is still injected as markup.

Refs: porchlight-cog, porchlight-t7y

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-05 14:04:24 +02:00
parent 1571706d21
commit c175633980
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
3 changed files with 28 additions and 13 deletions

View file

@ -46,10 +46,12 @@ async def _setup_user_with_webauthn(
async def test_webauthn_login_begin_returns_options(client: AsyncClient) -> None:
"""Begin is now GET with no username — returns options with empty allowCredentials."""
"""Begin is a POST (mutates session) with no username — returns options
with empty allowCredentials."""
await _setup_user_with_webauthn(client)
res = await client.get("/login/webauthn/begin")
token = await get_csrf_token(client)
res = await client.post("/login/webauthn/begin", headers={"X-CSRF-Token": token})
assert res.status_code == 200
data = res.json()
assert "publicKey" in data
@ -59,7 +61,8 @@ async def test_webauthn_login_begin_returns_options(client: AsyncClient) -> None
async def test_webauthn_login_begin_has_user_verification_preferred(client: AsyncClient) -> None:
res = await client.get("/login/webauthn/begin")
token = await get_csrf_token(client)
res = await client.post("/login/webauthn/begin", headers={"X-CSRF-Token": token})
assert res.status_code == 200
data = res.json()
assert data["publicKey"]["userVerification"] == "preferred"
@ -81,10 +84,9 @@ async def test_webauthn_login_complete_returns_json_redirect(client: AsyncClient
_userid, _pk, _credential_id, _att = await _setup_user_with_webauthn(client)
# Begin to get state into session
res1 = await client.get("/login/webauthn/begin")
assert res1.status_code == 200
token = await get_csrf_token(client)
res1 = await client.post("/login/webauthn/begin", headers={"X-CSRF-Token": token})
assert res1.status_code == 200
# We can't easily complete the full assertion without browser interaction,
# but we verify the endpoint returns 400 JSON (not HTML) for bad assertions
res2 = await client.post(