Skip to content

fix(dashboard/auth): a WebAuthn expectation needs a Host header that arrived - #3594

Merged
cagataycali merged 1 commit into
strands-labs:mainfrom
cagataycali:fix-host-header-is-not-loopback
Sep 13, 2026
Merged

cagataycali merged 1 commit into
strands-labs:mainfrom
cagataycali:fix-host-header-is-not-loopback

Conversation

@cagataycali

Copy link
Copy Markdown
Member

Both halves of a passkey ceremony's expectation come 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 verdict checked before both STRANDS_DASH_AUTH_RP_ID and the enrolled set, because a browser on this machine is the operator. _served_origin read "localhost:8090" — an authority and port nobody configured — and origin_verdict then compares the caller's own Origin against that invention, which the caller passes by offering it.

Measured on a deployment with one enrolled credential (dash.example.com):

before/after

A host nobody enrolled is refused; no host at all was trusted absolutely, and overrode a pinned rp_id.

Change

rp_id_verdict refuses an empty host — after the pin, so a proxy that rewrites Host is still served by STRANDS_DASH_AUTH_RP_ID. _derive_rp_id passes what arrived. _served_origin raises the same 400 _connection_scheme raises 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. Full tests/*.py 11,390 passed / 60 skipped; ruff + mypy clean. +215/-4.

…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.
@cagataycali
cagataycali enabled auto-merge (squash) September 13, 2026 13:21

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's Raises:-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/delenv throughout, ASCII-only user-facing strings, no host paths.

@cagataycali
cagataycali merged commit d398a17 into strands-labs:main Sep 13, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants