Skip to content

fix(web): harden web dashboard auth with Origin/Host validation and tokens - #424

Merged
Jason Robert (jrob5756) merged 2 commits into
mainfrom
feature/397-harden-web-dashboard-auth
Aug 13, 2026
Merged

fix(web): harden web dashboard auth with Origin/Host validation and tokens#424
Jason Robert (jrob5756) merged 2 commits into
mainfrom
feature/397-harden-web-dashboard-auth

Conversation

@jrob5756

Copy link
Copy Markdown
Collaborator

Summary

Hardens the web dashboard against cross-origin and unauthenticated access:

  • Adds conductor/web/auth.py: a pure-ASGI OriginHostGuard middleware enforcing Origin/Host validation on every HTTP and WebSocket request, plus per-run token auth on mutating routes and the WebSocket handshake.
  • A per-run token is minted automatically (protected by default), overridable via CONDUCTOR_GATE_TOKEN.
  • A token file at ~/.conductor/runs/dashboard-<port>.token (mode 0600) lets CLI commands (guide, gate respond) discover the token without an explicit --token.
  • Read-only routes (/api/state, /api/info, /api/logs, /api/gate-status, /api/files/*, replay app) remain Origin/Host-protected only, no token required.
  • Frontend (lib/auth.ts) presents the server-injected token on mutating requests and the WebSocket handshake.

Closes#397

Testing

  • tests/test_web/test_request_guard.py (new)
  • Updated test_gate_respond_api.py, test_guidance_api.py, test_replay.py, test_server.py, test_gate.py, test_guide.py, test_stop_ladder.py

Jason Robertand others added 2 commits August 12, 2026 19:38
…okens
Add conductor/web/auth.py: a pure-ASGI OriginHostGuard middleware that
enforces Origin/Host validation on every HTTP and WebSocket request, plus
per-run token auth on mutating routes and the WebSocket handshake. The
token is minted automatically per run, overridable via
CONDUCTOR_GATE_TOKEN, and discoverable via a 0600 token file under
~/.conductor/runs/ for CLI commands (guide, gate respond) that need to
authenticate without an explicit --token. Read-only routes remain
Origin/Host-protected only. Wires the guard into web/server.py and
web/replay.py, threads token resolution through cli/app.py, cli/gate.py,
and cli/guide.py, and adds frontend support (lib/auth.ts) for presenting
the injected token on mutating requests and the WebSocket handshake.
Fixes all 9 blocking findings from the code review:
1. constant_time_match now compares UTF-8 bytes (surrogatepass) instead of
raw str, so a non-ASCII presented token (latin-1-decoded Authorization
header, or a %FF query token already mangled by errors="replace") 403s
instead of crashing the guard with an unhandled TypeError -> 500.
Applied the identical fix to server.py's duplicate _gate_token_ok (now
delegates to auth.constant_time_match, dropping the dead `import hmac`).
2. The token file is now written with resolve_expected_token(self._token)
(the value the guard actually validates against) instead of the raw
minted token, so CONDUCTOR_GATE_TOKEN no longer breaks CLI
auto-discovery of the dashboard token.
3. remove_token_file now takes an `expected` token and only unlinks when
the file's contents match, closing the port-reuse race where a
draining stop() could delete a newer run's token file (the same hazard
cli/pid.py::remove_pid_file_at already guards). Moved the call above
the WebSocket drain and wrapped it in try/except so a failure doesn't
skip emitter.unsubscribe().
4. Frontend: wsDisconnectedSince now starts on the very first failed
connection attempt (not just a drop from 'connected'), so the issue
#330 stuck-reconnecting banner fires even for a WebSocket that's never
once connected. A handshake that never reaches onopen after a
successful /api/state fetch sets a new wsAuthFailed flag, which stops
the hot exponential-backoff retry and switches the banner's wording to
cover an auth rejection. sendGateResponse/sendDialogMessage/
sendDialogDecline/sendIterationLimitResponse now log + set a new
wsSendFailed flag instead of silently discarding the send when
disconnected; a new SendFailedBanner surfaces it to the user.
5. Added mutation-proof tests: two tests assert every mutating HTTP route
and every WebSocket route in the live FastAPI app is actually
registered with OriginHostGuard's protected_paths/websocket_paths
(catches the "drop a route from the hardcoded test list" mutation),
plus wrong-token WS handshake tests (query param and Authorization
header) asserting close code 1008 (catches weakening
constant_time_match). Verified both mutations described in the review
are now caught.
6. Promoted the four copy-pasted _isolated_runs_dir fixtures into one
autouse fixture in tests/conftest.py, closing the gap where
test_markup_injection.py read the developer's real
~/.conductor/runs. Added a meta-test asserting rundir.runs_dir() is
never the real home during the suite.
7. Added anti-clickjacking/anti-sniffing headers (X-Frame-Options: DENY,
Content-Security-Policy: frame-ancestors 'none', X-Content-Type-Options,
Referrer-Policy) via OriginHostGuard's HTTP pass-through, applied to
every response (including rejections), not just GET /.
8. Restored the "### Debugging `--web-bg` failures" heading in AGENTS.md,
fixing the dead anchor from docs/cli-reference.md:318.
9. Added a CHANGELOG.md ### Security entry under Unreleased documenting
the token-by-default requirement, CONDUCTOR_WEB_ALLOW_ORIGINS, the
token file, and the breaking Content-Type requirement.
Recommendations adopted: none beyond what the blocking fixes already
required (see PR description / task summary for the recommendations
evaluated and deliberately skipped).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrob5756
Jason Robert (jrob5756) marked this pull request as ready for review August 13, 2026 00:21
@jrob5756
Jason Robert (jrob5756) merged commit 3de3457 into mainAug 13, 2026
10 of 11 checks passed
@jrob5756
Jason Robert (jrob5756) deleted the feature/397-harden-web-dashboard-auth branch August 13, 2026 00:28
Jason Robert (jrob5756) pushed a commit that referenced this pull request Aug 14, 2026
Resolves the overlap between the Fleet Manager's run-record launch gate
(D2) and the two-stage readiness contract main added in #410/#417. Both
survive; they are complementary rather than alternatives:
- Stage one (`_wait_for_server`) is unchanged.
- The parent-side `write_pid_file` main wrote between the stages is
replaced by this branch's poll for the *child's* run record. That is a
strictly stronger signal -- the child only writes the record once it is
executing -- and preserves D2's single-writer invariant, which a
reinstated parent-side write would break.
- Stage two (`_wait_for_workflow_start`, `/api/info`) is retained, so
`workflow_started` / `still_running` still distinguish "listening" from
"actually started" and from "already exited".
- The stage-two failure cleanup moves from `remove_pid_file_at` to a new
`_remove_dead_child_record`, identity-checked on `pid` for the same
reason (issue #344): a resumed launch can carry a checkpoint's original
`run_id`, so the record under that key may belong to a live process.
Test resolution: both sides' new test classes are kept. Main's stage-two
tests are re-pointed from the PID file to the run record. Nineteen
with-blocks that predate stage two now skip it explicitly -- without that
they ran the real 30s `/api/info` wait, taking `test_bg_runner.py` from
2s to 4m33s.
Docs: AGENTS.md's `bg_runner.py` bullet describes the merged three-stage
sequence; the env-var table keeps main's rewritten `CONDUCTOR_GATE_TOKEN`
row (#424) and `CONDUCTOR_WEB_ALLOW_ORIGINS` alongside this branch's
`CONDUCTOR_HOME` and `CONDUCTOR_FLEET_NO_ANIM`; CHANGELOG keeps both
sides' entries.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

Harden the web dashboard request surface: origin/host validation and consistent auth across HTTP + WebSocket

1 participant

@jrob5756