Skip to content

fix(serve): work behind HTTPS reverse proxies and under a path prefix, apps included - #404

Merged
Broccolito merged 5 commits into
mainfrom
claude/biorouter-serve-headless-proxy-576fc1
Oct 17, 2026
Merged

Broccolito merged 5 commits into
mainfrom
claude/biorouter-serve-headless-proxy-576fc1

Conversation

@Broccolito

@Broccolito Broccolito commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #402. Also restores path-prefix support from #9, lost when biorouter-headless was retired.

What was broken

All reproduced in Chrome (headless, via Playwright) through a TLS proxy that behaves like a JupyterHub chain: the prefix is stripped, Host is preserved, and X-Forwarded-Proto arrives as https,http.

  1. Proxied HTTPS was refused with "Open Biorouter through localhost or HTTPS…", although Chrome had sent Sec-Fetch-Mode: navigate / Sec-Fetch-Dest: document. auth::reached_over_a_secure_context (new in 1.93.0) also required X-Forwarded-Proto: https, and it reads the last value. JupyterHub's configurable-http-proxy appends http after the TLS terminator's https, and JupyterHub's documented nginx block sends no X-Forwarded-Proto at all.
  2. Path prefixes 404'd. The root-base bundle asks for /assets/... at the host root, so the page stays on "Loading Biorouter…".
  3. Built apps never connected behind any HTTPS proxy. Chrome sends no Fetch Metadata on WebSocket handshakes, so each app's agent socket failed the same secure-address check ("No Biorouter backend"). Under a prefix, the app page, launch bounce, app cookie and SDK also all named /apps/{id}/ from the root.

What changed

  • Secure address = the browser's word. A non-loopback Host passes on Fetch Metadata (sent only to https or loopback URLs, and unforgeable by script). A WebSocket handshake, which carries none, passes when its Origin is https. X-Forwarded-Proto is no longer consulted here. Plain-HTTP LAN is still refused, for pages and for handshakes from http pages.
  • Relative bundle plus <base href>. build:web passes --base ./, and the daemon writes <base href> (./, ../, …, derived from the request path depth; only dot segments) first in the shell's head. The renderer derives its API and /headless bases from document.baseURI. The token bounce goes to ./.
  • biorouter serve --public-url <url> (or BIOROUTER_PUBLIC_URL):
    • prints the public address with the token;
    • adds its origin to the WebSocket gates (Chrome sends no Fetch Metadata on handshakes, so a chain that rewrites Host or appends http otherwise refuses every live view);
    • scopes the session cookie Path to the prefix;
    • must be https unless loopback;
    • is read only by a daemon that serves the UI;
    • is validated identically in the CLI and the daemon.
  • Apps under a prefix:
    • the page gets <base href="./"> and the launch bounce goes to ./;
    • the app cookie carries no Path, so the browser scopes it to the redeeming address (at the root that is the same /apps/{id} as before);
    • sdk.ts reads the prefix in front of /apps/<id>/ from its own address for the socket, REST and figure-frame URLs (export pages unchanged).
  • Hardening from an independent security review:
    • a daemon-served page ignores ?api= / ?headless= from the address bar, which with --no-token let any site redirect the secret;
    • the JupyterHub recipe no longer recommends --no-token where users share one machine;
    • the CI deep-path check no longer pipes curl into grep -q.
  • Docs: decision SD-15 (supersedes SD-4, amends SD-14); "Behind a reverse proxy" and JupyterHub sections; updated headless-linux.md, serve-architecture.md and CLAUDE.md.

Verification (real Chrome through the JupyterHub-like proxy)

Scenario Before After
Interface at /user/minrk/proxy/9999/ 403 mounts; all requests under the prefix
Clean https proxy with prefix assets 404 mounts
Interface live-view socket (with --public-url) n/a connects
Built app: page, bundle, agent socket socket 403 all work; cookie Path=/user/minrk/proxy/9999/apps/<id>
Token flow and deep paths under the prefix n/a work
?api=https://evil.example link n/a no request leaves for that host
Plain serve on loopback at / works works
Plain-HTTP LAN address 403 403
  • cargo test -p biorouter-server --lib (1004 passed) and cargo test -p biorouter-cli --lib (791 passed)
  • -p biorouter-mcp --lib agent_drafter:: (386 passed), --test ui_example_apps, --test serve_lifecycle
  • clippy -D warnings on all three crates
  • the app SDK jsdom harness, including new prefix checks that fail on the old SDK
  • lint:check and format:check
  • the rust.yml serve contract script, run locally

Notes for review

  • Security-sensitive: auth.rs (secure-address check, app cookie) and routes/mod.rs (WebSocket origin gate). Reasoning is in SD-15.
  • The proxy must strip the prefix, and the address is opened with its trailing slash.
  • Changing sdk.ts changes its content hash, so existing apps rebuild on their next load, which is the designed path.

🤖 Generated with Claude Code

`biorouter serve` 1.93.0 could not be served behind a proxy (#402):

- A proxied HTTPS request was refused with "Open Biorouter through
  localhost or HTTPS" even when the browser sent Fetch Metadata. The
  secure-address check also required `X-Forwarded-Proto: https`, and a
  JupyterHub chain reports `https,http` (configurable-http-proxy appends
  its own scheme), whose last value is `http`. The browser attaches
  Sec-Fetch-* only to an https or loopback URL and script cannot forge
  them, so the metadata alone is the evidence; the proxy header is no
  longer consulted. A plain-HTTP LAN address is still refused.
- Path prefixes (#9) were lost with biorouter-headless. The web bundle
  is now built with a relative base, and the daemon writes a
  `<base href>` naming the interface's mount relative to the path each
  request landed on, so the same bundle works at a hostname's root,
  under a prefix the proxy strips, and on a path that falls through to
  the shell. The renderer derives its API and /headless bases from it,
  and the token exchange moves the browser to `./` rather than `/`.
- `--public-url` is back. `serve` prints that address with the token,
  the WebSocket gates admit its origin (Chrome sends no Fetch Metadata
  on a WebSocket handshake, so a proxy chain that rewrites Host or
  appends `http` otherwise refuses every live view), and the session
  cookie's Path is scoped to its path.

Reproduced and verified in Chrome through a TLS proxy that mimics the
JupyterHub chain (prefix stripped, Host preserved, X-Forwarded-Proto
"https,http"): before, 403 and then 404 assets; after, the app mounts,
every request stays under the prefix and the workspace socket connects.
SD-15 records the three changes for #402 and supersedes SD-4 (root
only); SD-14's secure-address paragraph is amended. browser-access.md
gains a "Behind a reverse proxy" section with a jupyter-server-proxy
example and troubleshooting entries; headless-linux.md and
serve-architecture.md drop the root-only and root-base statements, and
CLAUDE.md's serve traps now describe the relative bundle and its
<base href>.
Found while verifying #402 end to end: an Agent Drafter app opened through
an HTTPS reverse proxy loaded and then said "No Biorouter backend".

- Every app surface asks `reached_over_a_secure_context`, and an app's
  agent socket is a WebSocket. Chrome sends no Fetch Metadata on a
  WebSocket handshake anywhere (measured), so behind any HTTPS proxy the
  socket was refused as if the page were at a plain-HTTP address. A
  handshake now counts when its Origin is https: the page that opened it
  is at a secure address, and script can set neither Origin nor Upgrade.
  An http page's handshake is still refused.
- Under a prefix the page, the launch bounce and the app cookie all named
  `/apps/{id}/` from the root. The page now gets `<base href="./">` (it is
  only served at `/apps/{id}/`), the bounce goes to `./`, and the cookie
  carries no Path, so the browser scopes it to the address it redeemed
  the link at (`/apps/{id}` at the root, as before). Re-assembly strips
  the relative base as it did the old one.
- sdk.ts reads the proxy prefix off its own address, in front of
  `/apps/<id>/`, for the agent socket, REST calls and figure frames; a
  page without that segment (an export's own) is unchanged. The jsdom
  harness checks all three cases and fails on the old SDK.

Also from an independent security review of this PR:

- renderer.tsx no longer takes `?api=` / `?headless=` from the address bar
  on a page the daemon served; with --no-token any site could have sent
  the browser to a link that handed the secret to another host.
- The daemon reads BIOROUTER_PUBLIC_URL only when it serves the web
  interface (the desktop app passes its daemon its whole environment),
  and refuses a plain-http public URL unless it names this machine. The
  CLI applies the same host and scheme rules as the daemon.
- The CI deep-path check writes to a file instead of piping curl into
  `grep -q`, which can kill curl under pipefail.

Verified in Chrome through the JupyterHub-like proxy: the app's page,
bundle and agent socket all work under /user/minrk/proxy/9999/, the
interface's live view connects, and a plain-HTTP LAN address is still
refused.
The JupyterHub recipe recommended --no-token, which is unsafe where users'
servers share one machine (LocalProcessSpawner, TLJH): every other account
can reach the loopback port. It now keeps the token there and limits
--no-token to one container per server. SD-15 records the WebSocket
origin rule, the app changes and the stricter --public-url rules, and
CLAUDE.md notes them beside the serve traps.
@Broccolito Broccolito changed the title fix(serve): work behind HTTPS reverse proxies and under a path prefix fix(serve): work behind HTTPS reverse proxies and under a path prefix, apps included Oct 17, 2026
`GET /apps/{id}` answered `Location: /apps/{id}/`, which behind a proxy
that strips a prefix sends the browser to the proxy's root. It now
answers `./{id}/`, which resolves to `/apps/{id}/` at the root and to
`/<prefix>/apps/{id}/` behind the proxy (verified through the
JupyterHub-like proxy). App ids are letters, digits, `-` and `_`, so the
relative reference cannot become anything else. Found by the second
security review of #404.
@Broccolito
Broccolito merged commit 3ddd6b2 into main Oct 17, 2026
24 checks passed
@Broccolito
Broccolito deleted the claude/biorouter-serve-headless-proxy-576fc1 branch October 17, 2026 18:12
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.

headless server can no longer be served behind proxy

1 participant