feat: make the web UI directly reachable (spec Part 1) - #10
Merged
Conversation
`replicant web` bound a random loopback port and minted a per-session token into
the URL, so the address changed on every restart and reaching it from another
machine meant an SSH tunnel. Part 1 of tasks/webui-access-and-nav-spec.md.
- fixed port 9787 (--port); a busy port is an error naming the port and the flag,
not a reason to silently pick another. 8787 was the first candidate and was
dropped on evidence: RStudio Server defaults to it, and it was already held on
the author's own machine, which is exactly the collision a fixed port avoids
- --host accepts any local address; the Host allowlist now follows the bind
address plus loopback plus repeatable --allowed-host. On a wildcard bind any IP
literal is accepted, since DNS rebinding needs a hostname
- the token persists to ~/.config/replicant/web-token, 0600 in a 0700 dir, and is
accepted as Bearer, X-Replicant-Token, ?token=, or an httpOnly SameSite=Strict
cookie set on first load. --rotate-token replaces it. A blank file is treated as
absent: compare_digest("", "") is true
- --no-auth is refused on a non-loopback bind without an explicit acknowledgement
- the terminal tab is off by default off-loopback, restored by --enable-terminal
This relaxes the loopback enforcement added in 0.1.0, so two compensating controls
are the point of the change:
1. The cookie is the first ambient credential here. A browser attaches it to a
cross-site request on its own, which a header or query token never was, so a
cookie-authenticated write must carry a matching Origin and a missing Origin is
refused. Header and query auth stay exempt so curl and the CLI still work.
2. /ws/terminal now performs its own Host, Origin, token and enabled checks. A
websocket scope never traverses HTTP middleware, so the guard protecting every
/api route did not protect the PTY endpoint. The RED tests for a foreign Host
and a foreign Origin both printed DID NOT RAISE before the fix. Latent while the
bind was loopback-only; recorded at docs/end-to-end-debug-audit-2026-07-21.md.
Also: no webbrowser.open when there is no display, which printed a gio "Operation
not supported" error over the banner on every headless start.
Docs corrected rather than extended. The README safety table claimed "loopback
only" and "per-session token", both now false. CLAUDE.md, AGENTS.md and CHANGELOG
each claimed a test asserts the web UI's scenario surface is absent; there is no
such test, only a manual UAT row (CHAIN-16).
496 python tests (443 before), 23 frontend (17 before), black/ruff/mypy clean.
Verified against a real server, not just the TestClient: 12/12 access checks on a
loopback bind and 4/4 on 0.0.0.0 including reachability from the host's LAN
address with the terminal off.
scripts/replicant-web.service is [Unverified]: no real systemd has started it.
This was referenced Jul 29, 2026
404SecNotFound
added a commit
that referenced
this pull request
Sep 1, 2026
Execution of the 2026-09 five-persona roadmap: 13 buildable survivors across ten PRs (#89-#98). Two changes alter emitted output (marker default-on for non-loopback sends; per-flow packet counts), hence the minor bump. The three lab-gated items (#4 pilot, #10/#11 refining the unbuilt F2/F5) stay behind the launch gate; every timing and delivery claim remains loopback-only until the first observed rule fire. - version 0.9.0 -> 0.10.0 (pyproject + __init__) - CHANGELOG [0.10.0] entry (Added/Changed/Fixed) - README: roadmap-execution bullet + Status badge -> v0.10.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 1 of
tasks/webui-access-and-nav-spec.md. Part 2 (navigation: tactic grouping, filters, Docs tab,--anchorcontrol) will stack on this branch.The problem
replicant webbound a random loopback port and minted a per-session token into the URL. The address changed on every restart, so a bookmark, a systemd unit, or a firewall rule could not be written against it, and reaching it from another machine meant an SSH tunnel.What changed
--portto change; a busy port is an error, not a silent reassignment0.0.0.0--allowed-host0600in a0700dir; Bearer /X-Replicant-Token/ query / httpOnly cookie--enable-terminalrestores itOn a wildcard bind, any IP-literal
Hostis accepted: DNS rebinding needs a hostname whose resolution the attacker controls, and a literal has none. Hostnames still have to be named with--allowed-host.The compensating controls are the point
This deliberately relaxes the loopback enforcement added in 0.1.0. Two things replace it:
1. The cookie is the first ambient credential in this codebase. A browser attaches it to a cross-site request on its own, which a header or query token never was. So a state-changing request authenticated by the cookie must carry an
Originthe allowlist accepts, and a missingOriginis refused. Header- and query-authenticated requests stay exempt, because nothing spends those on a user's behalf and requiring anOriginthere would breakcurlfor no gain.2.
/ws/terminalnow performs its own Host, Origin, token, and enabled checks. A websocket scope never traverses HTTP middleware, so the guard protecting every/apiroute did not protect the PTY endpoint. This was latent while the bind was loopback-only and was already recorded atdocs/end-to-end-debug-audit-2026-07-21.md:296-298. The RED tests for a foreignHostand a foreignOriginboth printedDID NOT RAISEbefore the fix, which is the proof it was real.--no-authis refused outright on a non-loopback bind unless--i-understand-this-is-unauthenticatedis also passed.Still plain HTTP: the token and the traffic are readable on the wire. Documented in the README rather than glossed.
Also fixed
No
webbrowser.openattempt when there is no display. It shelled out togioand printedOperation not supportedover the startup banner on every headless start.Docs corrected, not just extended
CLAUDE.md,AGENTS.md, andCHANGELOG.mdeach claimed a test asserts the web UI's scenario surface is absent. There is no such test; it is a manual UAT row (tasks/uat-plan.md, CHAIN-16). Corrected in all three.Verification
496 Python tests (443 before), 23 frontend (17 before).
black,ruff,mypyclean.npm run buildclean with xterm still code-split.Beyond the TestClient, against a real server:
Set-CookiewithHttpOnlyandSameSite=Stricton both the API and the SPA document, cookie-only GET 200, cookie POST with a foreignOrigin403, cookie POST with noOrigin403, foreignHost403, terminal enabled.--host 0.0.0.0— terminal reported disabled, reachable from the machine's LAN address with an IP-literalHost, hostnameHoststill 403.--no-auth --host 10.20.0.50refused, exit 1, message on stderr.EADDRINUSEpath verified itself unplanned: the original candidate port 8787 was already held on the author's machine by an unrelated tool. That evidence is why the default is 9787.Known gap
scripts/replicant-web.serviceis [Unverified] — no real systemd has started it. The Docker daemon was unavailable this session. Verify withsystemd-analyze verifyplus one realsystemctl enable --nowin a container before release. Authoring it already surfaced one defect of exactly the kind only a real start finds:ProtectHome=read-onlywould have blocked the first token write.