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

@ -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

View file

@ -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 = '<div role="alert">Failed to start registration</div>';
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 = '<div role="alert">Registration failed: ' + err.message + '</div>';
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 = '<div role="alert">' + (data.error || 'Failed to start authentication') + '</div>';
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 = '<div role="alert">' + (data.error || 'Authentication failed') + '</div>';
showAlert(statusEl, data.error || 'Authentication failed');
}
} catch (err) {
if (statusEl) statusEl.innerHTML = '<div role="alert">Authentication failed: ' + err.message + '</div>';
showAlert(statusEl, 'Authentication failed: ' + err.message);
}
}