fix(dashboard/auth): a WebAuthn expectation needs a Host header that arrived - #3594
Conversation
…arrived Both halves of a passkey ceremony's expectation are derived from the Host header, and each door stood in a value when the header was absent -- the two most permissive readings available. _derive_rp_id read "localhost", which rp_id_verdict answers "loopback" for: the one verdict checked before both STRANDS_DASH_AUTH_RP_ID and the enrolled set, because a browser on this machine is the operator. Measured on a deployment with one enrolled credential: Host: evil.example is refused, and no Host at all returns rp_id 'localhost' -- so a wrong host was refused while a missing host was trusted absolutely, and a pinned rp_id was overridden by it. _served_origin read "localhost:8090", an authority and a port nobody configured. origin_verdict then compares the caller's own Origin against that invention, which the caller passes by offering the invented value -- the tautology that function exists to refuse, reached through a default spelled in the source. rp_id_verdict now refuses an empty host, placed after the pin so a proxy that rewrites Host is still served by STRANDS_DASH_AUTH_RP_ID; _derive_rp_id passes what arrived; and _served_origin raises the same 400 _connection_scheme raises for a transport with no scheme. The refusal names the reading that was missing, so an operator behind such a proxy can tell it from an unenrolled host.
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
Closes a real auth-relevant hole in strands_robots/dashboard/auth.py: a request carrying no Host header had both halves of its WebAuthn expectation stood in from source-spelled defaults — _derive_rp_id read "localhost", which rp_id_verdict answers loopback for (the one verdict that outranks both STRANDS_DASH_AUTH_RP_ID and the enrolled set), and _served_origin invented localhost:8090 as the origin origin_verdict compares the caller's own claim against. The fix refuses the missing reading at both doors instead of guessing: rp_id_verdict returns (None, reason) for an empty host, placed after the pin so a proxy that rewrites Host is still served by the env var, and _served_origin raises the same shaped 400 _connection_scheme already raises, with a hint naming STRANDS_DASH_AUTH_ORIGIN. The refusal is explicit and attributable (400 + logger.warning), not a silent behaviour change, and browsers always send Host, so legitimate ceremonies are unaffected. All uses of the changed functions are confined to auth.py; the one non-ceremony caller (status()'s advisory block) already degrades gracefully behind its documented broad catch.
What's good
- Ordering is right: loopback > pin > empty-host refusal > enrolled set, so pinned deployments behind a Host-rewriting proxy keep working — and the test suite pins exactly that (
no host, but the operator pinned one). - 12 new pins with 5 controls, including the exploit-in-one-call test (
test_a_caller_cannot_choose_the_expectation_by_dropping_the_host); verified all 12 pass at the head, plus the full 310-test dashboard-auth selection and the repo'sRaises:-block, test-naming, xref, and ASCII graders. - AGENTS.md compliance: changelog fragment named for the PR number, refusal dicts returned in the module's established shape with actionable hints,
monkeypatch.setenv/delenvthroughout, ASCII-only user-facing strings, no host paths.
Both halves of a passkey ceremony's expectation come from the
Hostheader, and each door stood in a value when the header was absent — the two most permissive readings available._derive_rp_idread"localhost", whichrp_id_verdictanswersloopbackfor: the verdict checked before bothSTRANDS_DASH_AUTH_RP_IDand the enrolled set, because a browser on this machine is the operator._served_originread"localhost:8090"— an authority and port nobody configured — andorigin_verdictthen compares the caller's ownOriginagainst that invention, which the caller passes by offering it.Measured on a deployment with one enrolled credential (
dash.example.com):A host nobody enrolled is refused; no host at all was trusted absolutely, and overrode a pinned rp_id.
Change
rp_id_verdictrefuses an empty host — after the pin, so a proxy that rewritesHostis still served bySTRANDS_DASH_AUTH_RP_ID._derive_rp_idpasses what arrived._served_originraises the same 400_connection_schemeraises for a transport with no scheme, and the refusal names the missing reading. Docstrings updated (no docs page covers this module).Tests
tests/test_dashboard_auth_absent_host_is_not_loopback.py: 12 pins, 7 fail pre-fix / 5 controls pass, 12 pass after. Fulltests/*.py11,390 passed / 60 skipped; ruff + mypy clean. +215/-4.