Repository navigation
fix(serve): work behind HTTPS reverse proxies and under a path prefix, apps included - #404
Merged
Merged
Conversation
`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.
`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.
This was referenced Oct 17, 2026
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.
Fixes #402. Also restores path-prefix support from #9, lost when
biorouter-headlesswas 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,
Hostis preserved, andX-Forwarded-Protoarrives ashttps,http.Sec-Fetch-Mode: navigate/Sec-Fetch-Dest: document.auth::reached_over_a_secure_context(new in 1.93.0) also requiredX-Forwarded-Proto: https, and it reads the last value. JupyterHub's configurable-http-proxy appendshttpafter the TLS terminator'shttps, and JupyterHub's documented nginx block sends noX-Forwarded-Protoat all./assets/...at the host root, so the page stays on "Loading Biorouter…"./apps/{id}/from the root.What changed
Hostpasses on Fetch Metadata (sent only to https or loopback URLs, and unforgeable by script). A WebSocket handshake, which carries none, passes when itsOriginishttps.X-Forwarded-Protois no longer consulted here. Plain-HTTP LAN is still refused, for pages and for handshakes fromhttppages.<base href>.build:webpasses--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/headlessbases fromdocument.baseURI. The token bounce goes to./.biorouter serve --public-url <url>(orBIOROUTER_PUBLIC_URL):Hostor appendshttpotherwise refuses every live view);Pathto the prefix;httpsunless loopback;<base href="./">and the launch bounce goes to./;Path, so the browser scopes it to the redeeming address (at the root that is the same/apps/{id}as before);sdk.tsreads the prefix in front of/apps/<id>/from its own address for the socket, REST and figure-frame URLs (export pages unchanged).?api=/?headless=from the address bar, which with--no-tokenlet any site redirect the secret;--no-tokenwhere users share one machine;curlintogrep -q.headless-linux.md,serve-architecture.mdandCLAUDE.md.Verification (real Chrome through the JupyterHub-like proxy)
/user/minrk/proxy/9999/--public-url)Path=/user/minrk/proxy/9999/apps/<id>?api=https://evil.examplelinkserveon loopback at/cargo test -p biorouter-server --lib(1004 passed) andcargo test -p biorouter-cli --lib(791 passed)-p biorouter-mcp --lib agent_drafter::(386 passed),--test ui_example_apps,--test serve_lifecycle-D warningson all three crateslint:checkandformat:checkrust.ymlserve contract script, run locallyNotes for review
auth.rs(secure-address check, app cookie) androutes/mod.rs(WebSocket origin gate). Reasoning is in SD-15.sdk.tschanges its content hash, so existing apps rebuild on their next load, which is the designed path.🤖 Generated with Claude Code