diff --git a/src/porchlight/authn/routes.py b/src/porchlight/authn/routes.py index 47f58d4..465ebde 100644 --- a/src/porchlight/authn/routes.py +++ b/src/porchlight/authn/routes.py @@ -157,7 +157,7 @@ async def register_magic_link(request: Request, token: str) -> Response: return RedirectResponse("/manage/credentials?setup=1", status_code=303) -@router.get("/login/webauthn/begin") +@router.post("/login/webauthn/begin") async def login_webauthn_begin(request: Request) -> Response: webauthn_service = request.app.state.webauthn_service diff --git a/src/porchlight/static/webauthn.js b/src/porchlight/static/webauthn.js index 0ed2a50..9052d0f 100644 --- a/src/porchlight/static/webauthn.js +++ b/src/porchlight/static/webauthn.js @@ -19,6 +19,16 @@ function getCsrfToken() { return meta ? meta.getAttribute('content') : ''; } +// Render an alert with the message as text (never as HTML) to avoid XSS. +function showAlert(el, message) { + if (!el) return; + el.replaceChildren(); + const div = document.createElement('div'); + div.setAttribute('role', 'alert'); + div.textContent = message; + el.appendChild(div); +} + async function beginRegistration() { const statusEl = document.getElementById('webauthn-status'); @@ -29,7 +39,7 @@ async function beginRegistration() { headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() }, }); if (!beginRes.ok) { - if (statusEl) statusEl.innerHTML = '
Failed to start registration
'; + showAlert(statusEl, 'Failed to start registration'); return; } const options = await beginRes.json(); @@ -74,7 +84,7 @@ async function beginRegistration() { if (statusEl) statusEl.innerHTML = text; } } catch (err) { - if (statusEl) statusEl.innerHTML = '
Registration failed: ' + err.message + '
'; + showAlert(statusEl, 'Registration failed: ' + err.message); } } @@ -83,10 +93,13 @@ async function beginAuthentication() { try { // Step 1: Get options from server (no username needed) - const beginRes = await fetch('/login/webauthn/begin'); + const beginRes = await fetch('/login/webauthn/begin', { + method: 'POST', + headers: { 'X-CSRF-Token': getCsrfToken() }, + }); if (!beginRes.ok) { const data = await beginRes.json(); - if (statusEl) statusEl.innerHTML = '
' + (data.error || 'Failed to start authentication') + '
'; + showAlert(statusEl, data.error || 'Failed to start authentication'); return; } const options = await beginRes.json(); @@ -129,10 +142,10 @@ async function beginAuthentication() { window.location.href = data.redirect || '/manage/credentials'; } else { const data = await completeRes.json().catch(function () { return {}; }); - if (statusEl) statusEl.innerHTML = '
' + (data.error || 'Authentication failed') + '
'; + showAlert(statusEl, data.error || 'Authentication failed'); } } catch (err) { - if (statusEl) statusEl.innerHTML = '
Authentication failed: ' + err.message + '
'; + showAlert(statusEl, 'Authentication failed: ' + err.message); } } diff --git a/tests/test_auth_routes/test_webauthn_login.py b/tests/test_auth_routes/test_webauthn_login.py index 5952633..4959045 100644 --- a/tests/test_auth_routes/test_webauthn_login.py +++ b/tests/test_auth_routes/test_webauthn_login.py @@ -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(