Skip to content

feat: make the web UI directly reachable (spec Part 1) - #10

Merged
404SecNotFound merged 1 commit into
mainfrom
feat/webui-access
Jul 29, 2026
Merged

feat: make the web UI directly reachable (spec Part 1)#10
404SecNotFound merged 1 commit into
mainfrom
feat/webui-access

Conversation

@404SecNotFound

Copy link
Copy Markdown
Owner

Part 1 of tasks/webui-access-and-nav-spec.md. Part 2 (navigation: tactic grouping, filters, Docs tab, --anchor control) will stack on this branch.

The problem

replicant web bound 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

Before After
Port random fixed 9787, --port to change; a busy port is an error, not a silent reassignment
Bind loopback enforced any local address or 0.0.0.0
Host guard hardcoded localhost follows the bind address, plus loopback, plus repeatable --allowed-host
Token per-session, URL only persisted 0600 in a 0700 dir; Bearer / X-Replicant-Token / query / httpOnly cookie
Terminal tab always on off by default on a non-loopback bind, --enable-terminal restores it

On a wildcard bind, any IP-literal Host is 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 Origin the allowlist accepts, and a missing Origin is refused. Header- and query-authenticated requests stay exempt, because nothing spends those on a user's behalf and requiring an Origin there would break curl for no gain.

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. This was latent while the bind was loopback-only and was already recorded at docs/end-to-end-debug-audit-2026-07-21.md:296-298. The RED tests for a foreign Host and a foreign Origin both printed DID NOT RAISE before the fix, which is the proof it was real.

--no-auth is refused outright on a non-loopback bind unless --i-understand-this-is-unauthenticated is 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.open attempt when there is no display. It shelled out to gio and printed Operation not supported over the startup banner on every headless start.

Docs corrected, not just extended

  • The README safety-model table claimed the server "binds to loopback only" and "requires a per-session token". Both were true and are not any more.
  • CLAUDE.md, AGENTS.md, and CHANGELOG.md each 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, mypy clean. npm run build clean with xterm still code-split.

Beyond the TestClient, against a real server:

  • 12/12 on a loopback bind — 401 with no credential, 200 on Bearer, Set-Cookie with HttpOnly and SameSite=Strict on both the API and the SPA document, cookie-only GET 200, cookie POST with a foreign Origin 403, cookie POST with no Origin 403, foreign Host 403, terminal enabled.
  • 4/4 on --host 0.0.0.0 — terminal reported disabled, reachable from the machine's LAN address with an IP-literal Host, hostname Host still 403.
  • --no-auth --host 10.20.0.50 refused, exit 1, message on stderr.
  • The EADDRINUSE path 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.service is [Unverified] — no real systemd has started it. The Docker daemon was unavailable this session. Verify with systemd-analyze verify plus one real systemctl enable --now in a container before release. Authoring it already surfaced one defect of exactly the kind only a real start finds: ProtectHome=read-only would have blocked the first token write.

`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.
@404SecNotFound
404SecNotFound merged commit a6084d1 into main Jul 29, 2026
8 checks passed
@404SecNotFound
404SecNotFound deleted the feat/webui-access branch July 29, 2026 05:30
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
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.

1 participant