From dd1411d44e982a0bafdfb8d1fb33a45a29b06bf0 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 16 Oct 2026 21:31:45 -0700 Subject: [PATCH 1/5] fix(serve): work behind HTTPS reverse proxies and under a path prefix `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 `` 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. --- .github/workflows/rust.yml | 33 ++- Justfile | 6 +- crates/biorouter-cli/src/cli.rs | 12 + crates/biorouter-cli/src/commands/serve.rs | 165 ++++++++++- crates/biorouter-server/src/auth.rs | 105 ++++--- crates/biorouter-server/src/commands/agent.rs | 39 ++- crates/biorouter-server/src/routes/apps.rs | 1 + crates/biorouter-server/src/routes/mod.rs | 207 +++++++++++++- crates/biorouter-server/src/routes/web_ui.rs | 257 +++++++++++++++--- .../biorouter-server/src/routes/workspace.rs | 1 + scripts/smoke-test-release-artifacts.sh | 13 +- ui/desktop/forge.config.ts | 2 +- ui/desktop/package.json | 2 +- ui/desktop/src/renderer.tsx | 18 +- 14 files changed, 756 insertions(+), 105 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c2e32a8fc..adf2b8d22 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -610,10 +610,13 @@ jobs: run: | npm ci npm run build:web - # Root base, not Forge's relative base: a relative-base bundle served - # at the root resolves its assets against the current path. - grep -q 'src="/assets/' src/web/index.html - ! grep -q 'src="./assets/' src/web/index.html + # A RELATIVE base (issue #402): the daemon names the interface's mount + # in a it writes into the shell, so one bundle works at the + # root of a hostname and under a reverse proxy's path prefix. A + # root-base bundle asks for /assets/... at the proxy's root and never + # mounts there. + grep -q 'src="./assets/' src/web/index.html + ! grep -q 'src="/assets/' src/web/index.html - name: Build the command and the daemon run: cargo build -p biorouter-cli -p biorouter-server @@ -654,7 +657,7 @@ jobs: grep -qi 'SameSite=Strict' "$RUNNER_TEMP/h" || fail 'the session cookie is sent cross-site' grep -qi 'cache-control: no-store' "$RUNNER_TEMP/h" || fail 'the exchange page is cacheable' grep -qi 'referrer-policy: no-referrer' "$RUNNER_TEMP/h" || fail 'the exchange page leaks its address' - grep -q 'http-equiv="refresh" content="0;url=/"' "$RUNNER_TEMP/bounce.html" \ + grep -q 'http-equiv="refresh" content="0;url=./"' "$RUNNER_TEMP/bounce.html" \ || fail 'the exchange page does not move on to the interface' ! grep -q citoken "$RUNNER_TEMP/bounce.html" || fail 'the exchange page carries the token' @@ -668,6 +671,26 @@ jobs: || fail 'apiBaseUrl is present; the renderer will not use its own origin' grep -q 'no-store' <(curl -s -D - -o /dev/null -b 'biorouter_session=citoken' http://127.0.0.1:18777/) \ || fail 'the shell is cacheable and it carries the secret' + # The shell names its mount, relative to where the request landed, so + # the relative bundle resolves under a proxy's path prefix and on a + # path that fell through to the shell alike. + grep -q '' "$RUNNER_TEMP/shell.html" || fail 'the shell names no mount' + curl -fsS -b 'biorouter_session=citoken' http://127.0.0.1:18777/a/b/c \ + | grep -q '' || fail 'a deep path does not lead back to the mount' + + # Behind an HTTPS reverse proxy (issue #402). The browser's Fetch + # Metadata is what says the page is at a secure address; the scheme a + # proxy chain reports is not asked, because JupyterHub's chain reports + # `https,http`. Without the metadata, a network address is refused. + nav=(-H 'Sec-Fetch-Mode: navigate' -H 'Sec-Fetch-Dest: document') + for proto in https 'https,http' http; do + [ "$(curl -s -o /dev/null -w '%{http_code}' -b 'biorouter_session=citoken' "${nav[@]}" \ + -H 'Host: hub.example.org' -H "X-Forwarded-Proto: $proto" http://127.0.0.1:18777/)" = 200 ] \ + || fail "a proxied navigation with X-Forwarded-Proto: $proto was refused" + done + [ "$(curl -s -o /dev/null -w '%{http_code}' -b 'biorouter_session=citoken' \ + -H 'Host: 192.168.1.5:18777' -H 'X-Forwarded-Proto: https' http://127.0.0.1:18777/)" = 403 ] \ + || fail 'a network address without Fetch Metadata was served the shell' # The bundle is really served. asset=$(grep -o '/assets/index-[A-Za-z0-9_-]*\.js' "$RUNNER_TEMP/shell.html" | head -1) diff --git a/Justfile b/Justfile index 4c96f3b8b..027d41e64 100644 --- a/Justfile +++ b/Justfile @@ -404,8 +404,10 @@ lint-ui: cd ui/desktop && npm run lint:check # Build the browser interface bundle biorouterd serves (BIOROUTER_SERVE_UI). -# Writes a ROOT-BASE build of the renderer to ui/desktop/src/web/ (~8.8 MB), -# which every packaging path ships beside the binaries — see extraResource in +# Writes a RELATIVE-BASE build of the renderer to ui/desktop/src/web/ (~8.8 MB), +# which biorouterd anchors with a it writes into the shell, so the +# same bundle works at a hostname's root and under a reverse proxy's path prefix. +# Every packaging path ships it beside the binaries — see extraResource in # ui/desktop/forge.config.ts, and /usr/share/biorouter/web in the CLI-only # packages. Output is gitignored. # diff --git a/crates/biorouter-cli/src/cli.rs b/crates/biorouter-cli/src/cli.rs index 7d6f9e0b0..2ea0d4295 100644 --- a/crates/biorouter-cli/src/cli.rs +++ b/crates/biorouter-cli/src/cli.rs @@ -1475,6 +1475,17 @@ struct ServeOptions { )] web_dir: Option, + /// The address a reverse proxy serves the interface at + #[arg( + long, + value_name = "URL", + help = "The https:// address a reverse proxy serves the interface at, path prefix \ + included (for example https://hub.example.org/user/me/proxy/8765/). The proxy \ + must strip the prefix. Printed with the access token, and admitted by the \ + live views' origin check. Takes precedence over BIOROUTER_PUBLIC_URL." + )] + public_url: Option, + /// Configure a separate approval key for Biorouter Copilot tasks in the browser #[arg( long, @@ -1495,6 +1506,7 @@ impl ServeOptions { self.token, self.no_token, self.web_dir, + self.public_url, self.open, self.computer_use_approval, ) diff --git a/crates/biorouter-cli/src/commands/serve.rs b/crates/biorouter-cli/src/commands/serve.rs index 4fe4f1225..daa2968a0 100644 --- a/crates/biorouter-cli/src/commands/serve.rs +++ b/crates/biorouter-cli/src/commands/serve.rs @@ -92,11 +92,15 @@ pub async fn handle_serve( token: Option, no_token: bool, web_dir: Option, + public_url: Option, open_browser: bool, computer_use_approval: bool, ) -> Result<()> { let computer_use_digest = configure_computer_use_approval(computer_use_approval)?; let bind_is_loopback = host_is_loopback(&host); + // Before anything starts, so a typo is a message here rather than a daemon + // that refuses to start. + let public_url = choose_public_url(public_url, std::env::var(PUBLIC_URL_ENV).ok())?; // A credential that is optional on an exposed port is not a credential; it // is a setting nobody changed. So the token is mandatory the moment the bind @@ -152,6 +156,17 @@ pub async fn handle_serve( command.env_remove("BIOROUTER_BROWSER_TOKEN"); } } + // Set from the choice above, or removed: the daemon reads this variable on + // its own, so one this shell exports but that was not chosen here would + // reach it without the banner saying so. + match public_url.as_deref() { + Some(url) => { + command.env(PUBLIC_URL_ENV, url); + } + None => { + command.env_remove(PUBLIC_URL_ENV); + } + } let mut child = command .env("BIOROUTER_HOST", &host) .env("BIOROUTER_PORT", port.to_string()) @@ -204,8 +219,20 @@ pub async fn handle_serve( } } - let url = browser_url(&host, port, browser_token.value()); - print_banner(&url, &host, port, &browser_token, bind_is_loopback); + let local_url = browser_url(&host, port, browser_token.value()); + let url = match public_url.as_deref() { + Some(public) => with_token(public, browser_token.value()), + None => local_url.clone(), + }; + let proxied_to = public_url.as_ref().map(|_| browser_url(&host, port, None)); + print_banner( + &url, + &host, + port, + &browser_token, + bind_is_loopback, + proxied_to.as_deref(), + ); if computer_use_approval { eprintln!(" Biorouter Copilot approval is configured. Enter your approval key in the browser when approving a task."); eprintln!(" Biorouter Copilot controls this backend host's desktop, not a remote browser's device."); @@ -298,14 +325,24 @@ fn token_flag_warning(token: &BrowserToken) -> Option<&'static str> { } /// What the operator reads once the daemon is answering. +/// +/// `proxied_to` is this daemon's own address when `url` is a reverse proxy's +/// (`--public-url`), so the operator can check the proxy forwards there. fn print_banner( url: &str, host: &str, port: u16, browser_token: &BrowserToken, bind_is_loopback: bool, + proxied_to: Option<&str>, ) { println!("\n Biorouter is serving at\n\n {url}\n"); + if let Some(local) = proxied_to { + println!( + " through a reverse proxy that forwards it to {local},\n removing any path \ + prefix on the way and passing WebSocket upgrades through.\n" + ); + } if !bind_is_loopback { match reachable_address(host) { Some(addr) => println!( @@ -554,6 +591,69 @@ fn browser_url(host: &str, port: u16, token: Option<&str>) -> String { } } +/// The variable `biorouter serve` hands the daemon the public address in +/// (`--public-url`). The daemon's own name for it is +/// `biorouter_server::routes::PUBLIC_URL_ENV`; this crate does not depend on +/// that one, so the literal is repeated, as the user-action variable is. +const PUBLIC_URL_ENV: &str = "BIOROUTER_PUBLIC_URL"; + +/// The address a reverse proxy serves the interface at: `--public-url`, else +/// `BIOROUTER_PUBLIC_URL` from this shell, else none. The same order as the +/// token's ([`choose_browser_token`]), and a blank variable reads as unset. +/// +/// Returned normalised, with the trailing slash a relative bundle needs to +/// resolve its assets under the prefix (issue #402). Refused, with the reason, +/// when it is not an `http` or `https` URL with a host and nothing but a path +/// after it; the daemon applies the same rule (`routes::PublicUrl::parse`) and +/// would refuse to start on it. +pub(crate) fn choose_public_url( + flag: Option, + variable: Option, +) -> Result> { + let Some(raw) = flag.or_else(|| variable.filter(|value| !value.trim().is_empty())) else { + return Ok(None); + }; + let raw = raw.trim(); + let mut url = url::Url::parse(raw).with_context(|| { + format!("--public-url {raw:?} is not a URL; give the whole address, https:// and all") + })?; + if !matches!(url.scheme(), "http" | "https") { + bail!("--public-url {raw:?} must be an http or https address"); + } + if url.host_str().is_none() { + bail!("--public-url {raw:?} names no host"); + } + if !url.username().is_empty() || url.password().is_some() { + bail!("--public-url {raw:?} must not carry a user name or password"); + } + if url.query().is_some() || url.fragment().is_some() { + bail!( + "--public-url {raw:?} must not carry a query or a fragment; the access token is \ + added to the address this command prints" + ); + } + let allowed = |b: u8| b.is_ascii_alphanumeric() || b"-._~!$&'()*+,=:@%/".contains(&b); + if !url.path().bytes().all(allowed) { + bail!( + "the path of --public-url {raw:?} may hold only letters, digits, percent escapes and \ + - . _ ~ ! $ & ' ( ) * + , = : @ /" + ); + } + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + Ok(Some(url.to_string())) +} + +/// `url` with the browser token as its query, or unchanged without one. +fn with_token(url: &str, token: Option<&str>) -> String { + match token { + Some(t) => format!("{url}?t={t}"), + None => url.to_string(), + } +} + fn random_hex(bytes: usize) -> String { use rand::Rng as _; let mut rng = rand::thread_rng(); @@ -812,6 +912,67 @@ mod tests { assert_ne!(DEFAULT_PORT, 3000); } + /// Issue #402: `--public-url` is back, for a reverse proxy that serves the + /// interface under a path (JupyterHub's `/user//proxy//`). The + /// trailing slash is added, because the relative bundle resolves its assets + /// against it, and the token goes after it. + #[test] + fn a_public_url_is_normalised_and_carries_the_token() { + let chosen = |flag: Option<&str>, variable: Option<&str>| { + choose_public_url(flag.map(str::to_string), variable.map(str::to_string)).unwrap() + }; + assert_eq!(chosen(None, None), None); + assert_eq!(chosen(None, Some(" ")), None); + assert_eq!( + chosen(Some("https://hub.example.org/user/a/proxy/8765"), None).as_deref(), + Some("https://hub.example.org/user/a/proxy/8765/") + ); + assert_eq!( + chosen(Some("https://hub.example.org"), None).as_deref(), + Some("https://hub.example.org/") + ); + // The flag outranks the variable, which is read when there is no flag. + assert_eq!( + chosen( + Some("https://a.example.org/x/"), + Some("https://b.example.org/") + ) + .as_deref(), + Some("https://a.example.org/x/") + ); + assert_eq!( + chosen(None, Some("https://b.example.org/y")).as_deref(), + Some("https://b.example.org/y/") + ); + assert_eq!( + with_token("https://hub.example.org/p/", Some("abc")), + "https://hub.example.org/p/?t=abc" + ); + assert_eq!( + with_token("https://hub.example.org/p/", None), + "https://hub.example.org/p/" + ); + } + + /// The daemon refuses to start on the same values + /// (`routes::PublicUrl::parse`); saying so here names the flag. + #[test] + fn a_public_url_that_is_not_a_plain_web_address_is_refused() { + for refused in [ + "hub.example.org/prefix/", + "ftp://hub.example.org/", + "https://user:pw@hub.example.org/", + "https://hub.example.org/?t=abc", + "https://hub.example.org/#top", + "https://hub.example.org/a;Domain=evil.net/", + ] { + let error = choose_public_url(Some(refused.to_string()), None) + .expect_err(refused) + .to_string(); + assert!(error.contains("--public-url"), "{refused}: {error}"); + } + } + #[test] fn a_wildcard_bind_is_not_treated_as_loopback() { // It is reachable from other machines by definition, so it must take diff --git a/crates/biorouter-server/src/auth.rs b/crates/biorouter-server/src/auth.rs index 27f2a83f6..0401b7a6b 100644 --- a/crates/biorouter-server/src/auth.rs +++ b/crates/biorouter-server/src/auth.rs @@ -624,9 +624,10 @@ fn app_access_granted_by( /// Whether the address a request was sent to is one a browser treats as a /// secure context: a loopback address, `localhost` or a name under -/// `.localhost`, or HTTPS forwarded by a TLS proxy with Fetch Metadata from -/// the browser (`routes::request_scheme`). A request with no `Host` (nor authority) was not -/// sent by a browser, and counts as one. +/// `.localhost`, or any address the browser itself attached Fetch Metadata to +/// (`Sec-Fetch-Mode` and `Sec-Fetch-Dest`), which is how an HTTPS reverse proxy +/// in front of the daemon is recognised. A request with no `Host` (nor +/// authority) was not sent by a browser, and counts as one. /// /// It is what decides whether [`is_document_navigation`] and the opener /// policies mean anything. A browser sends Fetch Metadata only to such an @@ -638,10 +639,24 @@ fn app_access_granted_by( /// arrived with no `Sec-Fetch-*` at all, where on 127.0.0.1 the same script was /// told apart and severed. /// -/// `X-Forwarded-Proto` alone proves nothing: a page left open from an older -/// daemon can set it on an HTTP LAN fetch. Such a browser sends no Fetch -/// Metadata and cannot forge those headers, so forwarded HTTPS also requires -/// both metadata headers. Secret-header callers do not depend on this gate. +/// That is also why the metadata alone is the evidence, and nothing a proxy +/// writes is consulted. The Fetch Metadata specification has a browser attach +/// these headers only to a potentially trustworthy URL (https, or loopback), and +/// script cannot set a `Sec-` header, so their presence is the browser saying +/// the very thing this gate needs to know: it is applying the protections above +/// to this address. A proxy that forwards the request does not change that, and +/// a page at a plain-HTTP address cannot fake it. +/// +/// ⚠ Until issue #402 the forwarded case also required `X-Forwarded-Proto: +/// https`, read through `routes::request_scheme`. That added no protection, since +/// the metadata was already required beside it, and it refused real deployments: +/// a chain whose inner hop appends its own scheme (JupyterHub's +/// configurable-http-proxy after a TLS terminator reaches the daemon as +/// `"https,http"`, whose last value is `http`), and a proxy that names the scheme +/// some other way or not at all (JupyterHub's documented nginx block sends +/// `X-Scheme`). Do not put it back: a header a proxy may or may not write is a +/// worse witness to what the browser did than the browser's own headers. +/// Secret-header callers do not depend on this gate. pub fn reached_over_a_secure_context( headers: &axum::http::HeaderMap, uri: &axum::http::Uri, @@ -657,14 +672,19 @@ pub fn reached_over_a_secure_context( None => return true, }, }; - host_is_loopback(host) - || (crate::routes::request_scheme(headers) == "https" - && ["sec-fetch-mode", "sec-fetch-dest"].iter().all(|name| { - headers - .get(*name) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| !value.trim().is_empty()) - })) + host_is_loopback(host) || carries_fetch_metadata(headers) +} + +/// Whether a request carries the Fetch Metadata a browser attaches to a +/// potentially trustworthy URL: a readable, non-empty `Sec-Fetch-Mode` and +/// `Sec-Fetch-Dest`. See [`reached_over_a_secure_context`]. +fn carries_fetch_metadata(headers: &axum::http::HeaderMap) -> bool { + ["sec-fetch-mode", "sec-fetch-dest"].iter().all(|name| { + headers + .get(*name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| !value.trim().is_empty()) + }) } /// Whether a `Host` value names this machine the way a browser counts as @@ -1941,25 +1961,38 @@ mod tests { // An HTTP LAN page already open during an update can forge this proxy // header on a fetch, but cannot make the browser emit Fetch Metadata. - for uri in surfaces { - let (status, body) = app_request( - &app, - peer, - Method::GET, - uri, - &[ - ("cookie", cookie.as_str()), - ("host", "192.168.1.5:8765"), - ("x-forwarded-proto", "https"), - ], - ) - .await; - assert_eq!(status, StatusCode::FORBIDDEN, "forged HTTPS: {uri}"); - assert!(!body.contains("reached the app route")); + // Nor does metadata that is present but blank, or only half of it. + for forged in [ + vec![("x-forwarded-proto", "https")], + vec![("sec-fetch-mode", "navigate")], + vec![("sec-fetch-mode", " "), ("sec-fetch-dest", "document")], + ] { + for uri in surfaces { + let mut headers = forged.clone(); + headers.push(("cookie", cookie.as_str())); + headers.push(("host", "192.168.1.5:8765")); + let (status, body) = app_request(&app, peer, Method::GET, uri, &headers).await; + assert_eq!(status, StatusCode::FORBIDDEN, "forged {forged:?}: {uri}"); + assert!(!body.contains("reached the app route")); + } } // Loopback in every spelling a browser counts as secure, and anything a - // TLS proxy forwarded as https. + // browser attached Fetch Metadata to, which it does only at an https or + // loopback address. What a proxy in front says about the scheme is not + // consulted (issue #402): JupyterHub's configurable-http-proxy appends + // its own `http` after the TLS terminator's `https`, and its documented + // nginx block sends `X-Scheme` and no `X-Forwarded-Proto` at all. + let navigation = [ + ("sec-fetch-mode", "navigate"), + ("sec-fetch-dest", "document"), + ]; + let behind_a_proxy = |proto: Option<&'static str>| { + let mut headers = vec![("host", "biorouter.example.org")]; + headers.extend(proto.map(|proto| ("x-forwarded-proto", proto))); + headers.extend(navigation); + headers + }; for headers in [ vec![("host", "127.0.0.1:8765")], vec![("host", "127.4.5.6")], @@ -1967,12 +2000,10 @@ mod tests { vec![("host", "LocalHost.:8765")], vec![("host", "[::1]:8765")], vec![("host", "present-app.localhost:8765")], - vec![ - ("host", "biorouter.example.org"), - ("x-forwarded-proto", "https"), - ("sec-fetch-mode", "navigate"), - ("sec-fetch-dest", "document"), - ], + behind_a_proxy(Some("https")), + behind_a_proxy(Some("https,http")), + behind_a_proxy(Some("http")), + behind_a_proxy(None), // No `Host` at all is not a browser. vec![], ] { diff --git a/crates/biorouter-server/src/commands/agent.rs b/crates/biorouter-server/src/commands/agent.rs index 3fcb60601..8c1a945ff 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -455,6 +455,26 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { info!("the WebSocket gates admit the declared renderer origin {renderer}"); } + // The address a reverse proxy serves this daemon's interface at, which + // `biorouter serve --public-url` declares (issue #402). `serve` checks it + // before it starts the daemon; a launcher that did not is refused here + // rather than having its declaration quietly ignored, which would leave the + // live views refusing their own page with nothing on screen to say why. + let public_url = match std::env::var(crate::routes::PUBLIC_URL_ENV) { + Ok(value) if !value.trim().is_empty() => Some( + crate::routes::PublicUrl::parse(&value) + .map_err(|e| anyhow::anyhow!("{}: {e}", crate::routes::PUBLIC_URL_ENV))?, + ), + _ => None, + }; + if let Some(public) = public_url.as_ref() { + info!( + "the interface is reached through {}{}; the WebSocket gates admit that origin", + public.origin(), + public.path() + ); + } + let app = crate::routes::configure(app_state.clone(), secret_key.clone()) .layer(middleware::from_fn_with_state( secret_key.clone(), @@ -492,13 +512,18 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { ); biorouter_server::auth::install_served_operator(token.to_string(), capability); } - let ui = crate::routes::web_ui::WebUi::new(&web_dir, &secret_key, browser_token) - .map_err(|e| { - anyhow::anyhow!( - "could not read the web interface at {}: {e}", - web_dir.display() - ) - })?; + let ui = crate::routes::web_ui::WebUi::new( + &web_dir, + &secret_key, + browser_token, + public_url.as_ref().map(crate::routes::PublicUrl::path), + ) + .map_err(|e| { + anyhow::anyhow!( + "could not read the web interface at {}: {e}", + web_dir.display() + ) + })?; info!("serving the web interface from {}", web_dir.display()); crate::routes::web_ui::attach(app, web_dir, ui) } diff --git a/crates/biorouter-server/src/routes/apps.rs b/crates/biorouter-server/src/routes/apps.rs index 26c8ff900..6ffc1554f 100644 --- a/crates/biorouter-server/src/routes/apps.rs +++ b/crates/biorouter-server/src/routes/apps.rs @@ -10231,6 +10231,7 @@ mod tests { host, scheme: "http", renderer: None, + public: None, } } diff --git a/crates/biorouter-server/src/routes/mod.rs b/crates/biorouter-server/src/routes/mod.rs index 05fc01b24..1034fac6e 100644 --- a/crates/biorouter-server/src/routes/mod.rs +++ b/crates/biorouter-server/src/routes/mod.rs @@ -297,6 +297,109 @@ pub(crate) fn declared_renderer_origin() -> Option<&'static DeclaredRenderer> { DECLARED.as_ref() } +/// The environment variable `biorouter serve --public-url` sets to tell the +/// daemon the address a browser reaches its interface at, when that is a +/// reverse proxy rather than the daemon's own listener (issue #402). +/// +/// The daemon learns two things from it and nothing else: +/// +/// - the **origin**, which the WebSocket gates admit beside the daemon's own +/// ([`UpgradeOrigin::is_this_daemons`]). Behind a chain of proxies, the `Host` +/// and `X-Forwarded-Proto` that reach the daemon need not describe the +/// address the browser used (JupyterHub's configurable-http-proxy appends its +/// own `http` after the TLS terminator's `https`), and an operator who wrote +/// the address down should not have to make every hop agree about it; +/// - the **path**, which scopes the browser session cookie (`routes::web_ui`), +/// so a token is not sent to every other server under the same hostname, as +/// each JupyterHub user's is. +/// +/// It widens nothing a browser page could use. An `Origin` is set by the +/// browser and cannot be written by script, so admitting the declared one +/// admits pages served at the address the operator says is this interface. +pub const PUBLIC_URL_ENV: &str = "BIOROUTER_PUBLIC_URL"; + +/// The address a browser reaches this daemon's interface at, as `biorouter +/// serve --public-url` declared it. See [`PUBLIC_URL_ENV`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicUrl { + origin: WebOrigin, + /// `/`, or the prefix with no trailing slash: `/user/a/proxy/8765`. + path: String, +} + +impl PublicUrl { + /// An `http` or `https` URL with a host and nothing a browser would not + /// send back: no credentials, query or fragment. The path may carry percent + /// escapes; every other character in it must be one a URL path allows + /// unescaped, except `;`, which would end the cookie's `Path` attribute. + /// + /// `biorouter serve` applies the same rule before it starts the daemon, so + /// an operator hears about a typo there; this is the daemon refusing to start + /// on a value nobody checked, for a launcher that is not `serve`. + pub fn parse(value: &str) -> Result { + let value = value.trim(); + let url = url::Url::parse(value).map_err(|e| format!("{value:?} is not a URL ({e})"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(format!("{value:?} is not an http or https address")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(format!("{value:?} carries a user name or password")); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(format!("{value:?} carries a query or a fragment")); + } + let host = url + .host_str() + .ok_or_else(|| format!("{value:?} names no host"))?; + // `Url` drops a scheme's default port, as a browser's `Origin` does. + let authority = match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let origin = WebOrigin::with_authority(url.scheme(), &authority) + .ok_or_else(|| format!("{value:?} names a host no browser origin can carry"))?; + let allowed = |b: u8| b.is_ascii_alphanumeric() || b"-._~!$&'()*+,=:@%/".contains(&b); + if !url.path().bytes().all(allowed) { + return Err(format!( + "the path of {value:?} may hold only letters, digits, percent escapes and \ + - . _ ~ ! $ & ' ( ) * + , = : @ /" + )); + } + let path = url.path().trim_end_matches('/'); + Ok(Self { + origin, + path: if path.is_empty() { + "/".to_string() + } else { + path.to_string() + }, + }) + } + + /// The origin of a page served at this address. + pub fn origin(&self) -> &WebOrigin { + &self.origin + } + + /// `/`, or the path prefix with no trailing slash. + pub fn path(&self) -> &str { + &self.path + } +} + +/// This process's declared public address, read once. A value that does not +/// parse is `None` here; `commands::agent` refuses to start on one, so a +/// running daemon never reaches this with it. +pub(crate) fn declared_public_url() -> Option<&'static PublicUrl> { + static DECLARED: LazyLock> = LazyLock::new(|| { + std::env::var(PUBLIC_URL_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .and_then(|value| PublicUrl::parse(&value).ok()) + }); + DECLARED.as_ref() +} + /// What a WebSocket upgrade says about where it came from and where it was /// sent, read off the request once so that both socket gates — the workspace /// socket and the per-app agent socket — ask the same question of it. @@ -323,6 +426,9 @@ pub(crate) struct UpgradeOrigin<'a> { /// The renderer this daemon's launcher declared, if any; see /// [`RENDERER_ORIGIN_ENV`]. pub renderer: Option<&'a DeclaredRenderer>, + /// The public address `biorouter serve --public-url` declared, if any; see + /// [`PUBLIC_URL_ENV`]. + pub public: Option<&'a PublicUrl>, } impl<'a> UpgradeOrigin<'a> { @@ -336,11 +442,13 @@ impl<'a> UpgradeOrigin<'a> { .and_then(|value| value.to_str().ok()), scheme: request_scheme(headers), renderer: declared_renderer_origin(), + public: declared_public_url(), } } /// Is the page behind this upgrade one of this daemon's own: served from - /// its origin, or the renderer its launcher declared? + /// its origin, from the public address its launcher declared (a reverse + /// proxy in front of it), or the renderer its launcher declared? /// /// `false` when the upgrade carries no `Origin`. Whether that is admitted /// is each gate's own decision, and both admit it, because their token is @@ -350,6 +458,9 @@ impl<'a> UpgradeOrigin<'a> { return false; }; origin_matches_host(origin, self.host, self.scheme) + || self + .public + .is_some_and(|public| WebOrigin::parse(origin).as_ref() == Some(public.origin())) || matches!( self.renderer, Some(DeclaredRenderer::LoopbackHttp(renderer)) @@ -434,7 +545,7 @@ pub(crate) fn body_of<'a>(src: &'a str, signature: &str) -> &'a str { mod origin_tests { use super::{ declared_renderer, is_local_origin, origin_matches_host, request_scheme, DeclaredRenderer, - UpgradeOrigin, WebOrigin, + PublicUrl, UpgradeOrigin, WebOrigin, }; use axum::http::HeaderMap; @@ -682,6 +793,7 @@ mod origin_tests { host: Some("127.0.0.1:9380"), scheme: "http", renderer, + public: None, }; assert!(upgrade(Some("http://127.0.0.1:9380"), None).is_this_daemons()); assert!(upgrade(Some("http://localhost:5173"), Some(&vite)).is_this_daemons()); @@ -693,6 +805,96 @@ mod origin_tests { assert!(!upgrade(None, Some(&vite)).is_this_daemons()); } + /// Issue #402: behind JupyterHub, the browser's page is + /// `https://hub.example.org/user/a/proxy/8765/`, the `Host` is preserved, + /// and `X-Forwarded-Proto` arrives as `https,http` because + /// configurable-http-proxy appends its own scheme after the TLS + /// terminator's. The same-origin test reads the last value and refuses the + /// interface's own socket. The address the operator declared with + /// `--public-url` is admitted whatever the hops in between said, and admits + /// nothing beside itself. + #[test] + fn the_declared_public_address_is_this_daemons_whatever_the_proxy_chain_said() { + let public = PublicUrl::parse("https://hub.example.org/user/a/proxy/8765/").unwrap(); + let upgrade = |origin, host, scheme, public| UpgradeOrigin { + origin, + origin_unreadable: false, + host, + scheme, + renderer: None, + public, + }; + let page = Some("https://hub.example.org"); + let hub = Some("hub.example.org"); + // Without the declaration, the appended `http` refuses the real page. + assert!(!upgrade(page, hub, "http", None).is_this_daemons()); + // With it, the page is admitted, and so it is when a hop rewrote `Host`. + assert!(upgrade(page, hub, "http", Some(&public)).is_this_daemons()); + assert!(upgrade(page, Some("127.0.0.1:8765"), "http", Some(&public)).is_this_daemons()); + // Exactly that origin: not its plain-http twin, a neighbour, or a port. + for other in [ + "http://hub.example.org", + "https://evil.example.org", + "https://hub.example.org.evil.net", + "https://hub.example.org:8443", + "null", + ] { + assert!( + !upgrade(Some(other), Some("127.0.0.1:8765"), "http", Some(&public)) + .is_this_daemons(), + "{other}" + ); + } + // No Origin is still each gate's own question. + assert!(!upgrade(None, hub, "https", Some(&public)).is_this_daemons()); + } + + #[test] + fn a_public_url_keeps_its_origin_and_its_path_without_the_trailing_slash() { + let parsed = |value: &str| { + let url = PublicUrl::parse(value).unwrap_or_else(|e| panic!("{value}: {e}")); + (url.origin().to_string(), url.path().to_string()) + }; + let expect = |origin: &str, path: &str| (origin.to_string(), path.to_string()); + assert_eq!( + parsed("https://proxy.example.org/prefix/"), + expect("https://proxy.example.org", "/prefix") + ); + assert_eq!( + parsed("https://Proxy.Example.org:443/a/b"), + expect("https://proxy.example.org", "/a/b") + ); + assert_eq!( + parsed("http://10.0.0.5:8080"), + expect("http://10.0.0.5:8080", "/") + ); + assert_eq!( + parsed("https://[::1]:9443/"), + expect("https://[::1]:9443", "/") + ); + // A JupyterHub user name is percent-encoded in its prefix, and stays so. + assert_eq!( + parsed("https://hub.example.org/user/foo%40bar.org/proxy/8765/"), + expect("https://hub.example.org", "/user/foo%40bar.org/proxy/8765") + ); + for refused in [ + "", + "hub.example.org/prefix/", + "ftp://hub.example.org/", + "file:///tmp/x", + "https://user:pw@hub.example.org/", + "https://hub.example.org/?t=token", + "https://hub.example.org/#top", + // `;` would end the cookie's Path attribute and start another. + "https://hub.example.org/a;Domain=evil.net/", + ] { + assert!( + PublicUrl::parse(refused).is_err(), + "{refused:?} must be refused" + ); + } + } + /// The packaged renderer's `file:` page is a DIFFERENT question from /// `is_this_daemons`, asked only by the workspace gate, and answered only on /// a daemon whose launcher declared such a renderer. @@ -712,6 +914,7 @@ mod origin_tests { host: Some("127.0.0.1:9380"), scheme: "http", renderer, + public: None, }; assert!(upgrade(Some("file://"), Some(&electron)).is_declared_electron_renderer()); // Nothing declared — `biorouter serve`, or a hand-run `biorouterd`. diff --git a/crates/biorouter-server/src/routes/web_ui.rs b/crates/biorouter-server/src/routes/web_ui.rs index 1aaf51707..98f95fc67 100644 --- a/crates/biorouter-server/src/routes/web_ui.rs +++ b/crates/biorouter-server/src/routes/web_ui.rs @@ -28,8 +28,8 @@ //! 1. `biorouter serve` mints a browser token for the launch and prints it in //! the URL it shows the user. //! 2. `GET /?t=` validates it, sets a session cookie, and answers a -//! short page of this origin that moves the browser on to `/`, so the token -//! leaves the address bar ([`exchange_bounce`]). +//! short page of this origin that moves the browser on to the interface's +//! mount, so the token leaves the address bar ([`exchange_bounce`]). //! 3. `GET /` with that cookie returns the shell, with the daemon's secret //! injected into it. //! 4. From then on the application presents `X-Secret-Key` exactly as the @@ -69,8 +69,9 @@ //! //! # The shell shares its origin with code an agent wrote //! -//! Those apps are served from this same origin, and the cookie is `Path=/`, so -//! it rides along on any request an app's script makes here. An app could +//! Those apps are served from this same origin, and the cookie's `Path` is the +//! interface's own (`/`, or the prefix `--public-url` declared), so it rides +//! along on any request an app's script makes here. An app could //! `fetch('/')` or frame `/` and read the secret out of the shell. So the shell //! is served only to a document navigation (`auth::is_document_navigation`), //! cannot be framed, and its opener policy (`SHELL_CSP`) leaves an app that @@ -122,6 +123,11 @@ pub struct WebUi { /// The browser token, hex-encoded. `None` disables the gate entirely, which /// is only correct for a loopback bind the launcher chose not to token. browser_token: Option, + /// The `Path` of the session cookie: `/`, or the path prefix a reverse proxy + /// serves the interface under, from `biorouter serve --public-url` + /// (`routes::PublicUrl`). Behind JupyterHub every user's server shares one + /// hostname, and a cookie at `/` would carry this token to all of them. + cookie_path: String, } #[derive(Debug, Deserialize)] @@ -138,15 +144,20 @@ impl WebUi { /// which is why the document is gated: anyone who can read the shell can /// drive the API, and that is the intended equivalence — a browser that has /// authenticated is as capable as the desktop application. + /// + /// `public_path` is the path of the address `biorouter serve --public-url` + /// declared, `/` or a prefix with no trailing slash, and scopes the cookie. pub fn new( web_dir: &Path, secret_key: &str, browser_token: Option, + public_path: Option<&str>, ) -> std::io::Result { let raw = std::fs::read_to_string(web_dir.join("index.html"))?; Ok(Self { index_html: inject_runtime_config(&raw, secret_key), browser_token, + cookie_path: public_path.unwrap_or("/").to_string(), }) } @@ -164,8 +175,10 @@ impl WebUi { /// Splice the runtime configuration the renderer reads into the shell. /// -/// `ui/desktop/src/renderer.tsx` looks for `window.__BIOROUTER_HEADLESS_CONFIG__` -/// and derives its API base, its interface-endpoint base and its secret from it. +/// `ui/desktop/src/renderer.tsx` looks for `window.__BIOROUTER_HEADLESS_CONFIG__`, +/// takes its secret from it, and reads its presence as "the daemon served me", +/// which sends it to the shell's `` ([`with_base`]) for its API base +/// and its interface-endpoint base. /// Everything downstream of that global — the shim that stands in for /// `window.electron`, the API client, the secret handling — already exists and /// is untouched by this change. @@ -178,13 +191,18 @@ impl WebUi { fn inject_runtime_config(raw: &str, secret_key: &str) -> String { // `apiBaseUrl` is deliberately ABSENT rather than empty. The renderer treats // the mere presence of this global as "the daemon served me", and then uses - // its own origin -- which is the only correct answer, and the only one the - // daemon could not supply: it does not know the address the browser used. - // An empty string here would be falsy and fall through to the renderer's - // `http://127.0.0.1:3000` guess, which names a port the daemon need not be - // on and is meaningless from another machine. + // the address it is mounted at -- which is the only correct answer, and the + // only one the daemon could not supply: it does not know the address the + // browser used. An empty string here would be falsy and fall through to the + // renderer's `http://127.0.0.1:3000` guess, which names a port the daemon + // need not be on and is meaningless from another machine. + // + // `headlessBaseUrl` is absent for the same reason. It used to be the literal + // `/headless`, which is right only at the root of a hostname; behind a + // proxy that serves the interface under a path (issue #402) it sent every + // interface request to the proxy's root. The renderer derives it from the + // mount, the `` [`index`] writes into the shell. let config = serde_json::json!({ - "headlessBaseUrl": "/headless", "secretKey": secret_key, }); // `` inside a JSON string would close the tag early. The daemon @@ -275,14 +293,22 @@ async fn index( return not_a_navigation(); } + // Where the interface is mounted, relative to where this request landed. + // See [`mount_href`]. + let mount = mount_href(uri.path()); + // The token in the URL is exchanged for a cookie and the browser is moved // on, so it does not linger in the address bar, in the tab's history, or in // the `Referer` of anything the page later loads. if let Some(token) = query.t.as_deref() { if ui.token_matches(Some(token)) { - return exchange_bounce(&format!( - "{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict" - )); + return exchange_bounce( + &format!( + "{SESSION_COOKIE}={token}; Path={}; HttpOnly; SameSite=Strict", + ui.cookie_path + ), + &mount, + ); } return unauthorized(); } @@ -300,10 +326,63 @@ async fn index( .header(header::CONTENT_SECURITY_POLICY, SHELL_CSP) .header(header::X_FRAME_OPTIONS, "DENY") .header("cross-origin-opener-policy", "same-origin") - .body(Body::from(ui.index_html.clone())) + .body(Body::from(with_base(&ui.index_html, &mount))) .expect("valid html response") } +/// The interface's mount, as a URL relative to the path a request was sent to: +/// `./` for `/` (or any path one segment deep), `../` one directory further +/// down, and so on. +/// +/// The bundle is built with a relative base (`npm run build:web` passes `--base +/// ./`), so every URL it holds -- the entry script and stylesheet in the shell, +/// the code-split chunks, the fonts its stylesheets name -- resolves against +/// the document, and the renderer reads its API base off the same document +/// (`renderer.tsx`). Naming the mount in a `` is what lets one +/// bundle work wherever it is opened: +/// +/// - at the root of a hostname, which is what it always did; +/// - under a path prefix a reverse proxy strips before forwarding (issue #402: +/// JupyterHub's `/user//proxy//`, nginx's `location /p/ { +/// proxy_pass http://127.0.0.1:8765/; }`). The daemon never sees the prefix, +/// and does not need to: the part of the path below the mount is the same on +/// both sides of the proxy, so the distance back up to the mount is too; +/// - on a path that is not the mount at all, which falls through to the shell. +/// A relative bundle there would ask for `/x/assets/...` and be answered +/// with this page as a script, which is why the bundle used to have to be +/// built with an absolute base, and why this cannot be left to the document's +/// own URL. +/// +/// It names only `.` and `..` segments, so nothing from the request reaches the +/// page, and the shell's own policy (`base-uri 'self'`) accepts it. +fn mount_href(path: &str) -> String { + match path.matches('/').count().saturating_sub(1) { + 0 => "./".to_string(), + depth => "../".repeat(depth), + } +} + +/// The shell with `` as the first element of its head, so +/// it precedes every URL the head holds. +fn with_base(shell: &str, mount: &str) -> String { + let base = format!(""); + match shell.find("") { + Some(at) => { + // `split_at`, as in `inject_runtime_config`, because this workspace + // denies `string_slice`; `find` gives a character boundary. + let (before, after) = shell.split_at(at + "".len()); + let mut out = String::with_capacity(shell.len() + base.len()); + out.push_str(before); + out.push_str(&base); + out.push_str(after); + out + } + // Not something Vite produces; `inject_runtime_config` already put its + // own snippet first in that case, and this goes before it. + None => format!("{base}{shell}"), + } +} + /// The policy the shell is served under. Only `frame-ancestors`: the shell's /// own scripts and styles are whatever the build emitted, and this header is /// not the place to restate them. @@ -325,7 +404,7 @@ fn insecure_address() -> Response { (header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "no-store"), ], - "Open Biorouter through localhost or HTTPS. Use an SSH tunnel or a TLS proxy and a current browser that sends Fetch Metadata.", + "Open Biorouter through localhost or HTTPS. Use an SSH tunnel, or an HTTPS reverse proxy that passes the browser's Sec-Fetch-* headers through, and a current browser that sends them.", ) .into_response() } @@ -359,8 +438,9 @@ fn not_a_navigation() -> Response { /// cookie goes with it. The same rule, for the same reason, as an app's launch /// link (`routes::apps::launch_bounce`). A meta refresh rather than a script, /// because the page's policy allows no script, and no token in the page: the -/// cookie carries it, and the address the page moves to is `/`. -fn exchange_bounce(cookie: &str) -> Response { +/// cookie carries it, and the address the page moves to is the mount +/// ([`mount_href`]), relative, so the browser stays under a proxy's path prefix. +fn exchange_bounce(cookie: &str, mount: &str) -> Response { Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/html; charset=utf-8") @@ -372,15 +452,21 @@ fn exchange_bounce(cookie: &str) -> Response { form-action 'none'; frame-ancestors 'none'", ) .header(header::SET_COOKIE, cookie) - .body(Body::from(EXCHANGE_BOUNCE_HTML)) + .body(Body::from(exchange_bounce_html(mount))) .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } -const EXCHANGE_BOUNCE_HTML: &str = "\ - \ - Opening Biorouter\ - \ -

Opening Biorouter. If nothing happens, continue here.

"; +/// `mount` is a [`mount_href`], only `.` and `..` segments, so it needs no +/// escaping in an attribute. +fn exchange_bounce_html(mount: &str) -> String { + format!( + "\ + \ + Opening Biorouter\ + \ +

Opening Biorouter. If nothing happens, continue here.

" + ) +} /// Refuse without saying anything a caller does not already know. fn unauthorized() -> Response { @@ -426,6 +512,7 @@ mod tests { WebUi { index_html: "shell".to_string(), browser_token: token.map(str::to_string), + cookie_path: "/".to_string(), } } @@ -439,6 +526,9 @@ mod tests { !out.contains("apiBaseUrl"), "apiBaseUrl must be absent so the renderer uses its own origin" ); + // So must the interface endpoints' base: the literal `/headless` it + // used to be is wrong under a proxy's path prefix (issue #402). + assert!(!out.contains("headlessBaseUrl"), "{out}"); assert!(out.contains("\"secretKey\":\"deadbeef\"")); // Injected inside , so it runs before the module bundle that // reads it. @@ -569,8 +659,9 @@ mod tests { .await .unwrap(); let body = String::from_utf8(body.to_vec()).unwrap(); + // Relative, so a browser under a proxy's path prefix stays under it. assert!( - body.contains(""), + body.contains(""), "{body}" ); assert!( @@ -679,6 +770,7 @@ mod tests { "lan-secret", ), browser_token: Some("tok".into()), + cookie_path: "/".to_string(), }; for forwarded in [None, Some("https")] { for token in [None, Some("tok")] { @@ -709,30 +801,118 @@ mod tests { } } + /// Issue #402. A browser sends Fetch Metadata only to an https or loopback + /// address, so a navigation carrying it through a proxy is the browser + /// saying it applies the shell's protections there. What the proxy chain + /// says about the scheme is not asked: JupyterHub's configurable-http-proxy + /// appends `http` after the TLS terminator's `https`, and its documented + /// nginx block sends `X-Scheme` and no `X-Forwarded-Proto` at all. Each of + /// those was refused with "Open Biorouter through localhost or HTTPS". #[tokio::test] async fn modern_tls_proxy_navigations_can_open_the_shell_and_exchange_tokens() { let ui = ui_with(Some("tok")); - for token in [None, Some("tok")] { - let mut headers = cookie_request(Some("navigate"), Some("document")); - headers.insert(header::HOST, "biorouter.example.org".parse().unwrap()); - headers.insert("x-forwarded-proto", "https".parse().unwrap()); + for proto in [Some("https"), Some("https,http"), Some("http"), None] { + for token in [None, Some("tok")] { + let mut headers = cookie_request(Some("navigate"), Some("document")); + headers.insert(header::HOST, "biorouter.example.org".parse().unwrap()); + if let Some(proto) = proto { + headers.insert("x-forwarded-proto", proto.parse().unwrap()); + } + let res = index( + State(ui.clone()), + headers, + Query(IndexQuery { + t: token.map(str::to_owned), + }), + Uri::default(), + ) + .await; + assert_eq!(res.status(), StatusCode::OK, "{proto:?} {token:?}"); + assert_eq!( + res.headers().get(header::SET_COOKIE).is_some(), + token.is_some() + ); + } + } + } + + #[test] + fn the_mount_is_named_relative_to_where_the_request_landed() { + for (path, mount) in [ + ("/", "./"), + ("/sessions", "./"), + ("/sessions/", "../"), + ("/a/b", "../"), + ("/a/b/c", "../../"), + ("/a//b", "../../"), + ("", "./"), + ] { + assert_eq!(mount_href(path), mount, "{path:?}"); + } + } + + /// The bundle is relative, so the shell names its mount before anything in + /// its head asks for a URL. At `/` that is `./`, which is what the document's + /// own address already said; on a path that fell through to the shell it is + /// the way back up, so `./assets/...` does not become `/x/assets/...`. + #[tokio::test] + async fn the_shell_names_its_mount_first_in_its_head() { + let ui = WebUi { + index_html: inject_runtime_config( + "\ + ", + "s3cr3t", + ), + browser_token: None, + cookie_path: "/".to_string(), + }; + for (uri, mount) in [("/", "./"), ("/x/y/z", "../../")] { let res = index( State(ui.clone()), - headers, - Query(IndexQuery { - t: token.map(str::to_owned), - }), - Uri::default(), + cookie_request(Some("navigate"), Some("document")), + Query(IndexQuery { t: None }), + uri.parse().unwrap(), ) .await; assert_eq!(res.status(), StatusCode::OK); - assert_eq!( - res.headers().get(header::SET_COOKIE).is_some(), - token.is_some() + let body = axum::body::to_bytes(res.into_body(), 64 * 1024) + .await + .unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body.starts_with(&format!( + "" + )), + "{uri}: {body}" ); + assert_eq!(body.matches("", "s3cr3t"), browser_token: Some("tok".into()), + cookie_path: "/".to_string(), }; for (mode, dest) in [ (Some("cors"), Some("empty")), diff --git a/crates/biorouter-server/src/routes/workspace.rs b/crates/biorouter-server/src/routes/workspace.rs index 30ef3b950..2480d1144 100644 --- a/crates/biorouter-server/src/routes/workspace.rs +++ b/crates/biorouter-server/src/routes/workspace.rs @@ -317,6 +317,7 @@ mod tests { host, scheme: "http", renderer: None, + public: None, } } diff --git a/scripts/smoke-test-release-artifacts.sh b/scripts/smoke-test-release-artifacts.sh index 6d2f6259b..036683579 100755 --- a/scripts/smoke-test-release-artifacts.sh +++ b/scripts/smoke-test-release-artifacts.sh @@ -248,7 +248,7 @@ smoke_serve() { # The token is exchanged for a session cookie. It is not consumed # (SD-9 in docs/deployment/serve-decisions.md): the readiness loop above # has already redeemed it. The answer is a page served by the daemon - # itself that moves on to /, not a 303, so the SameSite=Strict cookie + # itself that moves on to ./, not a 303, so the SameSite=Strict cookie # survives a navigation started by the file: page that serve --open writes. curl -s -o /tmp/bounce.html -D /tmp/h "http://127.0.0.1:18080/?t=smoketoken" grep -qi "^HTTP/1.1 200" /tmp/h @@ -256,7 +256,7 @@ smoke_serve() { grep -qi "HttpOnly" /tmp/h grep -qi "SameSite=Strict" /tmp/h grep -qi "cache-control: no-store" /tmp/h - grep -q "http-equiv=\"refresh\" content=\"0;url=/\"" /tmp/bounce.html + grep -q "http-equiv=\"refresh\" content=\"0;url=./\"" /tmp/bounce.html # `! grep` cannot fail a `set -e` script: bash exempts a negated # pipeline from errexit, so each absence is an explicit exit. if grep -q smoketoken /tmp/bounce.html; then exit 1; fi @@ -273,8 +273,11 @@ smoke_serve() { grep -q "\"secretKey\":\"[0-9a-f]\{64\}\"" /tmp/shell.html if grep -q "apiBaseUrl" /tmp/shell.html; then exit 1; fi - # The bundle really is served, and it is the ROOT-BASE build. - grep -q "src=\"/assets/" /tmp/shell.html + # The bundle really is served, and it is the RELATIVE build, with the + # mount named in the shell, so it also works under a reverse proxy path + # prefix (issue #402). + grep -q "src=\"./assets/" /tmp/shell.html + grep -q "" /tmp/shell.html asset=$(grep -o "/assets/index-[A-Za-z0-9_-]*\.js" /tmp/shell.html | head -1) curl -fsS -o /dev/null "http://127.0.0.1:18080$asset" @@ -296,7 +299,7 @@ smoke_serve() { exit 1 fi ' - log "biorouter serve: token exchange, gated shell, root-base bundle, authenticated endpoints, and stopping with its daemon passed" + log "biorouter serve: token exchange, gated shell, relative bundle and mount, authenticated endpoints, and stopping with its daemon passed" } case "$TARGET" in diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index 4e4ec4236..70b8ca666 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -81,7 +81,7 @@ let cfg = { // plugin composes with this value rather than replacing it. asar: { unpack: '**/node_modules/node-pty/**' }, ignore: (file) => !keepInPackage(file), - // `src/web` is the ROOT-BASE build of the same renderer (`npm run build:web`), + // `src/web` is the relative-base build of the same renderer (`npm run build:web`), // which `biorouterd` serves to a browser when pointed at it with // BIOROUTER_SERVE_UI. It has to be an extraResource rather than a bundled // asset for one reason: the daemon is a separate process that reads it off diff --git a/ui/desktop/package.json b/ui/desktop/package.json index f6c7e10c7..78a0f5293 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -10,7 +10,7 @@ "scripts": { "typecheck": "tsc --noEmit", "generate-api": "openapi-ts", - "build:web": "vite build --config vite.renderer.config.mts --outDir src/web --emptyOutDir", + "build:web": "vite build --config vite.renderer.config.mts --base ./ --outDir src/web --emptyOutDir", "start-gui": "npm run generate-api && electron-forge start", "start-gui-debug": "npm run generate-api && electron-forge start -- --inspect=9229", "start": "cd ../.. && just run-ui", diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index 1b23cba4e..176016645 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -66,20 +66,28 @@ function getHeadlessConfig(): HeadlessConfig { const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname); // A page the daemon served itself carries this global (see // `crates/biorouter-server/src/routes/web_ui.rs`). That daemon serves the API - // on the very same origin, so the origin IS the base. The two fallbacks below - // it are for a page served by something else: the loopback guess names a port - // the daemon need not be on, and would be meaningless from another machine. + // beside the page, so the address the page is mounted at IS the base: the + // origin, plus the path a reverse proxy serves it under (issue #402, e.g. + // JupyterHub's `/user//proxy//`). The daemon cannot know that + // path, since a proxy strips it, but it does know how deep below the mount + // each request was, and names the mount in a ``, so the base URI + // is it. The two fallbacks below are for a page served by something else: + // the loopback guess names a port the daemon need not be on, and would be + // meaningless from another machine. const servedByDaemon = Boolean(window.__BIOROUTER_HEADLESS_CONFIG__); + const mountUrl = servedByDaemon + ? new URL('./', document.baseURI).href.replace(/\/+$/, '') + : window.location.origin; const apiBaseUrl = globalConfig.apiBaseUrl || params.get('api') || (servedByDaemon - ? window.location.origin + ? mountUrl : isLocalhost ? 'http://127.0.0.1:3000' : `${window.location.origin}/api`); const headlessBaseUrl = - globalConfig.headlessBaseUrl || params.get('headless') || `${window.location.origin}/headless`; + globalConfig.headlessBaseUrl || params.get('headless') || `${mountUrl}/headless`; if (querySecret) { window.sessionStorage?.setItem('biorouter-headless-secret', querySecret); From 725cdf0019cccf4ea782c564270c83ba25d3c08b Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 16 Oct 2026 21:31:45 -0700 Subject: [PATCH 2/5] docs(serve): reverse proxies, path prefixes and --public-url (SD-15) 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 . --- CLAUDE.md | 28 +++++++-- docs/deployment/browser-access.md | 90 ++++++++++++++++++++++----- docs/deployment/headless-linux.md | 27 +++++--- docs/deployment/serve-architecture.md | 45 +++++++++----- docs/deployment/serve-decisions.md | 85 ++++++++++++++++++++++++- 5 files changed, 228 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 990d9444f..b67183366 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2039,7 +2039,12 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de `routes::UpgradeOrigin::is_this_daemons` — the `Origin` must match the request's own `Host` in **scheme, host and port** (`origin_matches_host`), which is what lets a browser reach the daemon at a LAN address nobody enumerated. The scheme is `http` unless a proxy in front says - `X-Forwarded-Proto: https`, so a TLS proxy must forward both `Host` and that header. The one + `X-Forwarded-Proto: https`, so a TLS proxy must forward both `Host` and that header. Beside it, + the gates admit the origin of the address `biorouter serve --public-url` declares + (`BIOROUTER_PUBLIC_URL`, `routes::PublicUrl`), which is how a proxy chain that rewrites `Host` + or appends `http` to `X-Forwarded-Proto` (JupyterHub's configurable-http-proxy, issue #402) + still reaches the live views. Chrome sends no Fetch Metadata on a WebSocket handshake (measured + 2026-10-16), so the browser cannot vouch for a socket the way it does for a page. The one other origin admitted is a renderer the launcher declares in `BIOROUTER_RENDERER_ORIGIN`: `main.ts` declares the dev renderer's vite origin (loopback `http`) or, packaged, the literal `file://`, and `just debug-server` declares vite's default. ⚠ **`file://` is admitted by NAME @@ -2066,11 +2071,22 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de pass it from the desktop. - ⚠ **Three traps.** The app uses a **HashRouter**, so its routes live in the fragment and never reach the daemon — that is the only reason `/sessions/{id}` (a real API route) does - not collide with the app's own; a history router would break pages silently. The bundle must - be **root-base** (`npm run build:web` → `ui/desktop/src/web`), because Forge forces a - *relative* base and a relative bundle served at `/` breaks deep links while the landing page - looks fine. And `/../web` resolves for the packaged app and Windows zip but **not** for - deb/rpm (`/usr/bin/../web` = `/usr/web`), hence `/usr/share/biorouter/web`. + not collide with the app's own; a history router would break pages silently. The bundle is + **relative** (`npm run build:web` passes `--base ./` → `ui/desktop/src/web`), and that is safe + only because `routes::web_ui` writes `` (`mount_href`: `./`, `../`, …) as the first + element of the shell's head: without it a path that falls through to the shell asks for + `/a/assets/...` and gets the shell back. It used to be **root-base** for exactly that reason, + which made every reverse proxy path prefix 404 its assets (issue #402, SD-15 supersedes SD-4); + do not go back, and do not drop the ``. The renderer derives its API base from the same + base URI, so nothing in the served page may assume it lives at `/`. And `/../web` resolves + for the packaged app and Windows zip but **not** for deb/rpm (`/usr/bin/../web` = `/usr/web`), + hence `/usr/share/biorouter/web`. +- **A proxied address counts as secure on the browser's word** (SD-15). + `auth::reached_over_a_secure_context` admits a non-loopback `Host` when the request carries + `Sec-Fetch-Mode` and `Sec-Fetch-Dest`, which a browser sends only to an https or loopback URL + and script cannot forge. It no longer asks `X-Forwarded-Proto`: requiring it beside the metadata + added nothing and refused JupyterHub, whose chain reports `https,http`. A plain-HTTP LAN + address is still refused, because Chrome sends no Fetch Metadata there. - **Tests:** `cargo test -p biorouter-server --lib -- routes::web_ui routes::shell` (the `--` is required: cargo takes one filter before it and rejects a second with a usage error), `cargo test -p biorouter-cli --lib commands::serve`, the `serve` job in diff --git a/docs/deployment/browser-access.md b/docs/deployment/browser-access.md index 1f253368a..a7e86b5cc 100644 --- a/docs/deployment/browser-access.md +++ b/docs/deployment/browser-access.md @@ -57,7 +57,7 @@ place. ## The command and its options ```bash -biorouter serve [--host ] [--port ] [--token ] [--no-token] [--web-dir ] [--open] +biorouter serve [--host ] [--port ] [--token ] [--no-token] [--web-dir ] [--public-url ] [--open] ``` | Option | What it does | Default | @@ -67,6 +67,7 @@ biorouter serve [--host ] [--port ] [--token ] [--no-token] [--web-d | `--token ` | Use this access token instead of generating a fresh one. The flag stays on the command line, where other accounts can read it, and `serve` prints a note saying so; `BIOROUTER_BROWSER_TOKEN` does not. | A new random token each launch | | `--no-token` | Serve with no access token. Refused for a non-loopback bind, and cannot be combined with `--token`. | Off | | `--web-dir ` | Directory holding the built interface. It must contain an `index.html`, or `serve` refuses to start. Takes precedence over `BIOROUTER_SERVE_UI`. | `BIOROUTER_SERVE_UI` if set, otherwise found automatically — see [When the interface cannot be found](#when-the-interface-cannot-be-found) | +| `--public-url ` | The `https://` address a reverse proxy serves the interface at, path prefix included. `serve` prints it with the token, and the live views accept connections from it. Takes precedence over `BIOROUTER_PUBLIC_URL`. See [Behind a reverse proxy](#behind-a-reverse-proxy). | None | | `--open` | Open a browser once the server is ready, through a page only your account can read. | Off | The default port is `8765` rather than `3000` deliberately: `3000` is `biorouterd`'s own default, so @@ -173,9 +174,9 @@ started `serve`. - **There is no transport encryption.** `serve` speaks plain HTTP. Over an untrusted network the token and everything else are readable in transit. Browser access from another machine requires - a TLS-terminating reverse proxy or an SSH tunnel. The proxy must forward the original `Host`, - set `X-Forwarded-Proto: https`, and preserve the browser's `Sec-Fetch-*` request headers; the - [headless Linux guide](headless-linux.md#decide-who-can-reach-the-port) has the configuration. + a TLS-terminating reverse proxy or an SSH tunnel. The proxy must pass the browser's + `Sec-Fetch-*` request headers through; [Behind a reverse proxy](#behind-a-reverse-proxy) and the + [headless Linux guide](headless-linux.md#decide-who-can-reach-the-port) have the configuration. - **There is one credential and no user accounts.** Everyone who opens the address is the same user, with the same files and the same history. Biorouter has no notion of separate accounts here. @@ -198,6 +199,57 @@ ssh -N -L 8765:127.0.0.1:8765 user@remote-host Then open the URL `serve` printed on the remote host, unchanged, in a local browser. For a shared host meant to stay up, see [Headless Linux deployment](headless-linux.md). +### Behind a reverse proxy + +Put an HTTPS reverse proxy in front of a loopback bind, and tell `serve` the address people open: + +```bash +biorouter serve --port 8765 --public-url https://biorouter.example.org/ +``` + +The interface also works under a path on a shared hostname. The proxy must remove the path prefix +before it forwards the request, and pass WebSocket upgrades through: + +```bash +biorouter serve --port 8765 --public-url https://hub.example.org/biorouter/ +``` + +`--public-url` does three things: + +1. `serve` prints that address, with the access token, as the one to open. +2. The live views accept a connection from a page at that address. Without it, they accept only a + page whose address matches the `Host` and `X-Forwarded-Proto` headers the daemon receives, + which a chain of proxies may not preserve. +3. The access cookie is limited to that path, so it is not sent to other servers on the same + hostname. + +The proxy must pass the browser's `Sec-Fetch-*` request headers through, which proxies do unless +configured to remove them. The browser sends them only to an `https` or loopback address, and the +daemon serves the interface to another address only when they arrive. Open the address with its +trailing slash: the interface loads its files relative to it. + +#### JupyterHub + +With [jupyter-server-proxy](https://jupyter-server-proxy.readthedocs.io/), each person's server can +start Biorouter and serve it at `https:///user//biorouter/`. The hub's own login protects +the address, so the access token can be turned off: + +```python +c.ServerProxy.servers = { + "biorouter": { + "command": [ + "biorouter", "serve", "--port", "{port}", "--no-token", + "--public-url", "https://hub.example.org{base_url}biorouter/", + ], + "timeout": 60, + }, +} +``` + +`{base_url}` is the server's own prefix, for example `/user/alice/`. Replace `hub.example.org` with +the hub's hostname. `--no-token` is accepted because `serve` binds the loopback address, which only +the proxy on the same machine can reach. + ## The model is fixed before you start **A browser session cannot change its model or provider.** The picker is inert, and this is @@ -282,8 +334,9 @@ these checks read and ignores the opener policy, so an app's script there could and every other app. Neither the interface nor apps are served on browser credentials at such an address; open them through an SSH tunnel or an HTTPS proxy ([What exposing the port means](#what-exposing-the-port-means)). -The proxy must preserve Fetch Metadata. `X-Forwarded-Proto: https` alone is refused: an old HTTP -app tab can forge that header on a fetch, but cannot forge the browser's `Sec-Fetch-*` headers. +The proxy must preserve Fetch Metadata. What a proxy says about the scheme is not consulted: an +old HTTP app tab can forge `X-Forwarded-Proto: https` on a fetch, but cannot forge the browser's +`Sec-Fetch-*` headers, and the browser sends those only to an `https` or loopback address. Gaps remain even at a secure address: a browser that does not support the `noopener-allow-popups` opener policy (Chromium-based browsers do) lets an app read another app's page through a popup, a @@ -332,14 +385,23 @@ chat client shortening the link, or a bookmark saved after the address changed t printed. If the launch has since restarted, the token has changed; read the new one from the terminal. -**The page loads but nothing connects.** Check that you are on the address `serve` printed. A -browser reaching the daemon on a different origin is not the supported configuration — the -interface is served at the root of the daemon's own origin and nowhere else. Behind a TLS proxy, -check that it forwards the original `Host`, sets `X-Forwarded-Proto: https`, and preserves -`Sec-Fetch-*` headers: the live views are -WebSockets, and the daemon admits one only from the scheme, host and port the browser used. The -daemon logs each refusal as `rejected workspace WS: cross-origin connect rejected`, with the -origin it was given. +**The page loads but nothing connects.** Check that you are on the address `serve` printed. The +live views are WebSockets, and the daemon admits one only from the scheme, host and port the +browser used. Behind a reverse proxy, pass that address with `--public-url` (see +[Behind a reverse proxy](#behind-a-reverse-proxy)). Without it, the daemon works the address out +from the `Host` and `X-Forwarded-Proto` headers, so the proxy must forward the original `Host` and +set `X-Forwarded-Proto: https`, and a chain of proxies that appends `http` to that header refuses +every live view. The daemon logs each refusal as +`rejected workspace WS: cross-origin connect rejected`, with the origin it was given. + +**Behind a proxy, the page says to open Biorouter through localhost or HTTPS.** The browser's +`Sec-Fetch-*` request headers did not arrive. Check that the address in the browser is `https`, +and that no proxy in the chain removes those headers. + +**Under a path prefix, the page stays on "Loading Biorouter".** The interface's files did not +load. Check that the proxy removes the prefix before forwarding, and that the address ends in `/`. +A bundle built by an earlier release asks for its files at the hostname's root; rebuild it with +`npm run build:web`. **A file path the agent uses does not exist.** Paths are resolved on the serving machine. When the browser is on a different computer, its local files are not visible to the agent; copy them to the diff --git a/docs/deployment/headless-linux.md b/docs/deployment/headless-linux.md index a11c39551..01946a53d 100644 --- a/docs/deployment/headless-linux.md +++ b/docs/deployment/headless-linux.md @@ -193,15 +193,17 @@ reaching the port as equivalent to a shell account. - **There is no transport encryption.** `biorouter serve` speaks plain HTTP. On anything but a trusted local network, terminate TLS in front of it — bind loopback (`--host 127.0.0.1`) and put - nginx, Caddy or another reverse proxy on the public port. Serve it at the **root of a hostname**, - not under a path prefix; the interface is served at `/` and a subpath is not supported - ([decision SD-4](serve-decisions.md)). - - The proxy must also pass WebSocket upgrades through, forward the browser's original `Host`, - and set `X-Forwarded-Proto`. The daemon's socket gates admit a page only from the origin the - browser reached it at, in scheme, host and port, and the scheme is `http` unless that header - says `https` — so without it, the interface loads over `https` and its live views never - connect. Caddy does all three by default. For nginx: + nginx, Caddy or another reverse proxy on the public port, and pass the address people open to + `serve` with `--public-url`. The interface works at the root of a hostname or under a path + prefix that the proxy removes before forwarding + ([decision SD-15](serve-decisions.md#sd-15-behind-a-reverse-proxy-at-the-root-or-under-a-path)). + + The proxy must also pass WebSocket upgrades through and leave the browser's `Sec-Fetch-*` + request headers in place. The daemon's socket gates admit a page only from the origin the + browser reached it at, in scheme, host and port. `--public-url` names that origin. Without it, + the daemon reads it from the original `Host` and `X-Forwarded-Proto`, so the proxy must forward + both, or the interface loads over `https` and its live views never connect. Caddy does all of + this by default. For nginx: ```nginx location / { @@ -214,11 +216,16 @@ reaching the port as equivalent to a shell account. } ``` + For a path prefix, end both the `location` and the `proxy_pass` address with a slash, which + makes nginx remove the prefix: `location /biorouter/ { proxy_pass http://127.0.0.1:8765/; ... }`, + with `--public-url https://example.org/biorouter/`. + `$http_host` rather than `$host`, which drops a non-default port and so no longer matches the page's origin. `proxy_set_header` **replaces** the header, which is what you want: where a proxy chain appends instead, the daemon reads the last value — the one the proxy nearest it wrote — so an inner proxy that overwrites a correct `https` with its own `http` will refuse every WebSocket - upgrade. Have inner proxies pass the value through rather than re-derive it. + upgrade. Have inner proxies pass the value through rather than re-derive it, or pass + `--public-url`, which the socket gates accept whatever the chain reports. - **Restrict the source addresses.** Scope a cloud security group or a host firewall rule to the addresses that need it, rather than relying on the token alone: diff --git a/docs/deployment/serve-architecture.md b/docs/deployment/serve-architecture.md index 7560fe1b8..0974a007f 100644 --- a/docs/deployment/serve-architecture.md +++ b/docs/deployment/serve-architecture.md @@ -71,8 +71,10 @@ When it is set, the daemon mounts two things: | Hashed static assets | `/assets/*` and other built files | None — they carry no secrets | | The interface's own endpoints | `/headless/*` | The ordinary secret-key header | -The shell is served at the root and nowhere else (SD-4), so the bundle's baked-in -root-absolute asset URLs are correct as built and nothing rewrites them. +The bundle is relative and the shell names its mount in a ``, so it works at the root +of a hostname and under a path prefix a reverse proxy removes, and nothing rewrites it +([SD-15](serve-decisions.md#sd-15-behind-a-reverse-proxy-at-the-root-or-under-a-path), which +supersedes SD-4; see [Where the bundle comes from](#where-the-bundle-comes-from)). ### The interface's routes never reach the daemon @@ -95,7 +97,7 @@ Two consequences worth knowing before changing anything here: > above real, and it would surface as a handful of pages that 401 while the rest work. If that > change is ever made, the API needs a path prefix of its own first. -### The one thing injected into the shell +### The two things injected into the shell The renderer already knows how to run outside Electron. `ui/desktop/src/renderer.tsx` reads a global the server places in the document: @@ -104,9 +106,11 @@ global the server places in the document: const globalConfig = window.__BIOROUTER_HEADLESS_CONFIG__ ?? {}; ``` -and derives its API base, its interface-endpoint base and its secret from it, falling back to -query parameters and `sessionStorage`. The daemon's only job is to populate that global before -handing over the document. Everything downstream — the ninety-six-method shim that stands in for +and takes its secret from it, falling back to query parameters and `sessionStorage`. Its API base +and interface endpoint base come from the other injection, the `` that names where the +interface is mounted (`new URL('./', document.baseURI)`), because the daemon does not know the +address the browser used and a proxy may have removed a path prefix from it. The daemon's only job +is to write those two things into the document before handing it over. Everything downstream — the ninety-six-method shim that stands in for `window.electron`, the API client, the secret handling — already exists and is unchanged. > **Why this matters for scope.** There is no second frontend and there never was. One Vite @@ -121,7 +125,8 @@ cannot gate the initial document. The exchange is therefore: 1. `biorouter serve` mints a random browser token for the launch and prints it in the URL. 2. `GET /?t=` validates it, sets an `httpOnly`, `SameSite=Strict` session cookie, and - answers a short page of the daemon's own origin that moves the browser on to `/`. It is a page + answers a short page of the daemon's own origin that moves the browser on to the interface's + mount (`./`, so it stays under a proxy's path prefix). It is a page and not a redirect because a redirect keeps a `SameSite=Strict` cookie off a navigation another site started, and `serve --open` starts one from a `file:` page (below). 3. `GET /` with that cookie returns the shell, with the daemon's secret injected into it. @@ -286,15 +291,23 @@ daemon, and the resolution of where the web directory lives. ## Where the bundle comes from -The bundle the daemon serves is built by Vite with the default root base. That build already -exists — `scripts/build-headless-linux.sh` runs it inside a container — and becomes an ordinary -package script so every platform's packaging can call it. - -> **Warning.** Electron Forge forces a *relative* base for the bundle it packages. A relative-base -> bundle served at the root resolves its assets against the current path, so a deep link breaks -> while the landing page appears to work. The bundle the daemon serves must be built with the -> root base, from `vite.renderer.config.mts` directly. Reusing the packaged Electron bundle is not -> a shortcut; it is a different artifact. +The bundle the daemon serves is built by Vite from `vite.renderer.config.mts` with a relative base +(`npm run build:web`, which passes `--base ./`), and every platform's packaging calls that script. + +A relative bundle holds no address of its own. The shell names `./assets/...`, the code split +chunks resolve against `import.meta.url`, and the stylesheets name their fonts relative to +themselves. So the same bundle works at the root of a hostname and under a path prefix that a +reverse proxy removes before forwarding (issue #402). + +> **Warning.** A relative bundle resolves its assets against the document's address, and every +> path the daemon does not route falls through to the shell. Opened at `/a/b`, it would ask for +> `/a/assets/...` and receive the shell again. The daemon closes this by writing +> `` as the first element of the shell's head, naming the mount relative to the path +> the request landed on (`routes::web_ui::mount_href`): `./` at the mount, `../` one directory +> below it. The bundle used to be built with the root base for this reason, which broke every +> path prefix; see [SD-15](serve-decisions.md#sd-15-behind-a-reverse-proxy-at-the-root-or-under-a-path). +> The renderer reads its API address from the same base (`renderer.tsx`), so nothing in the +> served page assumes it is at `/`. A directory named explicitly — `--web-dir`, or else `BIOROUTER_SERVE_UI` — is used as given or refused with the same error, never skipped. The variable used to be only the first candidate of diff --git a/docs/deployment/serve-decisions.md b/docs/deployment/serve-decisions.md index b68bab744..2499bf4db 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -145,6 +145,10 @@ readiness poll and both header allowlists all go. ## SD-4 — One serving shape: the interface is served at the root +> **Superseded by [SD-15](#sd-15-behind-a-reverse-proxy-at-the-root-or-under-a-path) (2026-10-16).** +> The interface works under a path prefix again, without the rewriting described below: the +> bundle is relative and the daemon names its mount in a ``. + **Ruling.** The interface is served at `/`. Serving it under a path prefix is not supported. **Why.** The prefix mode existed to sit behind a reverse proxy at a subpath, and it cost a @@ -1176,13 +1180,92 @@ shell. Both the cookie-bearing shell load and its token exchange now require loo `localhost` or a name under `.localhost`, or HTTPS forwarded by a TLS proxy with the browser's Fetch Metadata preserved (`auth::reached_over_a_secure_context`). `X-Forwarded-Proto: https` alone is refused because old HTTP page code can forge that header; it cannot forge `Sec-Fetch-Mode` or -`Sec-Fetch-Dest`. Refusals give SSH tunnel or TLS guidance and carry no secret. Explicit secret-header +`Sec-Fetch-Dest`. (Amended 2026-10-16 by [SD-15](#sd-15-behind-a-reverse-proxy-at-the-root-or-under-a-path): +the forwarded case no longer asks for `X-Forwarded-Proto` at all. The metadata was always the +evidence, and requiring the header beside it refused real proxy chains.) Refusals give SSH tunnel or TLS guidance and carry no secret. Explicit secret-header API callers remain supported. Missing headers remain accepted on loopback for non-browser clients, so an engine that sends no Fetch Metadata at all (Safari before 16.4) is still not isolated there; such engines are refused through a non-loopback HTTPS proxy. Per-app origins remain the complete fix. --- +## SD-15: Behind a reverse proxy, at the root or under a path + +**Ruling (2026-10-16, issue #402).** Three changes, taken together because one deployment needed +all three: JupyterHub, where each person's server is reached at +`https://hub.example.org/user//proxy//` through a TLS terminator, +configurable-http-proxy and jupyter-server-proxy. + +1. **The browser says whether an address is secure.** A request whose `Host` is not loopback + counts as reaching a secure address when the browser attached Fetch Metadata to it + (`Sec-Fetch-Mode` and `Sec-Fetch-Dest`). `X-Forwarded-Proto` is no longer part of that decision + (`auth::reached_over_a_secure_context`). +2. **The interface works under a path prefix that a proxy strips.** `npm run build:web` builds + the bundle with a relative base (`--base ./`), and the daemon writes `` into the + shell, naming the mount relative to the path each request landed on + (`routes::web_ui::mount_href`): `./` at the mount, `../` one directory below it. The renderer + takes its API and interface endpoint addresses from the same base (`renderer.tsx`). The token + exchange moves the browser to that relative address rather than to `/`. SD-4 is superseded. +3. **`biorouter serve --public-url
` names the address a browser uses.** `serve` prints + that address with the access token. The daemon admits its origin on the WebSocket gates + (`routes::PublicUrl`, `UpgradeOrigin::is_this_daemons`) and scopes the session cookie's `Path` + to its path. `BIOROUTER_PUBLIC_URL` in the shell is read when the flag is absent. + +**Why.** Version 1.93.0 refused every JupyterHub deployment of `serve`, and lost the path prefix +support that 1.89 had (issue #9). + +- The secure address check of SD-14 required `X-Forwarded-Proto: https` beside the metadata. In + JupyterHub's chain, configurable-http-proxy appends its own scheme after the TLS terminator's, + so the daemon reads `https,http`, whose last value is `http` (`routes::request_scheme` reads the + last value). JupyterHub's documented nginx configuration sends `X-Scheme` and no + `X-Forwarded-Proto` at all. In both cases the browser had sent Fetch Metadata and was told to + use "a TLS proxy and a current browser that sends Fetch Metadata", which it was doing. The + header added no protection. The Fetch Metadata specification has a browser attach these + headers only to a potentially trustworthy URL (https, or loopback), and script cannot write a + `Sec-` header, so the metadata alone answers the question the check asks. The header only made + the answer depend on how each hop of a proxy chain was configured. +- SD-4 dropped the prefix because the root base bundle bakes `/assets/...` into its HTML, + JavaScript and stylesheets, and supporting a prefix meant rewriting all three. A relative bundle + holds no such URL: Vite resolves its chunks against `import.meta.url` and its fonts against the + stylesheet. What a relative bundle could not do alone was load on a path that is not the mount, + since every unmatched path falls through to the shell; that is why the bundle was root base. + The `` closes that. It needs no configuration, because a proxy that strips a prefix + leaves the part of the path below the mount unchanged, so the distance back up is the same on + both sides of the proxy. +- The live views connect over a WebSocket whose gate compares the page's `Origin` with the `Host` + and the scheme from `X-Forwarded-Proto`. Through the same chain that comparison refuses the + interface's own socket, so the page loads and every live view stays empty. Chrome sends no Fetch + Metadata on a WebSocket handshake (measured 2026-10-16), so the browser cannot vouch for the + socket the way it does for the page. An address the operator declares can. + +**Displaced alternatives.** + +- *Read the first `X-Forwarded-Proto` value, or accept `https` anywhere in the list.* Not needed + for the page check, which asks no proxy header now. For the socket check it would reverse the + reading recorded on `routes::request_scheme` on the strength of one proxy's behaviour, where the + declared address depends on none. +- *Read `X-Forwarded-Prefix`.* Some proxies send it and most do not, and the relative bundle does + not need the prefix. The cookie path, the one thing that does, comes from the declared address. +- *Restore the rewriting the retired `biorouter-headless` did.* Rejected for the reasons SD-4 + gives. + +**Consequences to accept.** + +- The proxy must strip the prefix before forwarding. A proxy that forwards `/prefix/...` unchanged + reaches the shell fallback, and the assets it asks for miss. +- The address is opened with its trailing slash, because the relative bundle resolves against it. + `serve` prints it that way. Opened as `https://hub.example.org/prefix` with no slash, the browser + asks for the assets one directory up, unless the proxy redirects to the slash first. +- Without `--public-url`, a chain whose `X-Forwarded-Proto` does not end in `https`, or a proxy + that rewrites `Host`, still loads the page and still refuses the live views' socket. The + troubleshooting entry in [browser access](browser-access.md#troubleshooting) says to pass the + flag. +- The session cookie is scoped to the declared path only. Without `--public-url`, a token cookie + is set at `/` on the proxy's hostname, as it always was. With `--no-token`, as JupyterHub + deployments use it behind the hub's own login, there is no cookie at all. + +--- + ## Related documentation - [Architecture of the serving path](serve-architecture.md) — how the decisions above are built. From e440a1c21b8a089fbab5244b35b015990b97a202 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 17 Oct 2026 10:27:58 -0700 Subject: [PATCH 3/5] fix(serve): built apps behind an HTTPS proxy and under a path prefix 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 `` (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//`, 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. --- .github/workflows/rust.yml | 7 +- crates/biorouter-cli/src/commands/serve.rs | 33 ++++++- .../biorouter-mcp/src/agent_drafter/render.rs | 17 +++- .../src/agent_drafter/templates/sdk.ts | 44 +++++++-- crates/biorouter-server/src/auth.rs | 71 +++++++++++++-- crates/biorouter-server/src/commands/agent.rs | 26 ++++-- crates/biorouter-server/src/routes/apps.rs | 42 +++++---- crates/biorouter-server/src/routes/mod.rs | 91 +++++++++++++++++-- crates/biorouter-server/src/routes/web_ui.rs | 11 ++- scripts/agent-drafter/ui-control-harness.mjs | 32 +++++++ ui/desktop/src/renderer.tsx | 10 +- 11 files changed, 326 insertions(+), 58 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index adf2b8d22..3babae220 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -675,8 +675,11 @@ jobs: # the relative bundle resolves under a proxy's path prefix and on a # path that fell through to the shell alike. grep -q '' "$RUNNER_TEMP/shell.html" || fail 'the shell names no mount' - curl -fsS -b 'biorouter_session=citoken' http://127.0.0.1:18777/a/b/c \ - | grep -q '' || fail 'a deep path does not lead back to the mount' + # To a file first: under pipefail, `grep -q` exiting early can kill + # curl mid-write and fail the step at random. + curl -fsS -b 'biorouter_session=citoken' -o "$RUNNER_TEMP/deep.html" http://127.0.0.1:18777/a/b/c \ + || fail 'a deep path did not answer' + grep -q '' "$RUNNER_TEMP/deep.html" || fail 'a deep path does not lead back to the mount' # Behind an HTTPS reverse proxy (issue #402). The browser's Fetch # Metadata is what says the page is at a secure address; the scheme a diff --git a/crates/biorouter-cli/src/commands/serve.rs b/crates/biorouter-cli/src/commands/serve.rs index daa2968a0..3cd4fab51 100644 --- a/crates/biorouter-cli/src/commands/serve.rs +++ b/crates/biorouter-cli/src/commands/serve.rs @@ -620,8 +620,29 @@ pub(crate) fn choose_public_url( if !matches!(url.scheme(), "http" | "https") { bail!("--public-url {raw:?} must be an http or https address"); } - if url.host_str().is_none() { - bail!("--public-url {raw:?} names no host"); + // The daemon's rule (`routes::PublicUrl::parse`): a host a browser origin + // can carry, and https unless it is this machine. + let loopback = match url.host() { + None => bail!("--public-url {raw:?} names no host"), + Some(url::Host::Domain(name)) => { + if !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) + { + bail!("--public-url {raw:?} names a host no browser address can carry"); + } + let name = name.trim_end_matches('.').to_ascii_lowercase(); + name == "localhost" || name.ends_with(".localhost") + } + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + }; + if url.scheme() == "http" && !loopback { + bail!( + "--public-url {raw:?} is plain http at a network address. A browser applies the \ + interface's protections only over https or on this machine, and serve refuses the \ + interface at such an address; give the https address of the proxy in front of it" + ); } if !url.username().is_empty() || url.password().is_some() { bail!("--public-url {raw:?} must not carry a user name or password"); @@ -931,6 +952,11 @@ mod tests { chosen(Some("https://hub.example.org"), None).as_deref(), Some("https://hub.example.org/") ); + // Plain http is accepted on this machine only. + assert_eq!( + chosen(Some("http://localhost:8765/x"), None).as_deref(), + Some("http://localhost:8765/x/") + ); // The flag outranks the variable, which is read when there is no flag. assert_eq!( chosen( @@ -965,6 +991,9 @@ mod tests { "https://hub.example.org/?t=abc", "https://hub.example.org/#top", "https://hub.example.org/a;Domain=evil.net/", + "http://10.0.0.5:8080/", + "http://hub.example.org/", + "https://a*b.example.org/", ] { let error = choose_public_url(Some(refused.to_string()), None) .expect_err(refused) diff --git a/crates/biorouter-mcp/src/agent_drafter/render.rs b/crates/biorouter-mcp/src/agent_drafter/render.rs index bf9537e7e..9d7e61930 100644 --- a/crates/biorouter-mcp/src/agent_drafter/render.rs +++ b/crates/biorouter-mcp/src/agent_drafter/render.rs @@ -118,15 +118,17 @@ fn remove_blocks(html: &str, open: &str, close: &str) -> String { /// refused; the bundle ran twice; and each re-export nested one more layer. It /// removes exactly what the assembler emits and nothing an author writes: the /// theme and overrides `"); out = remove_blocks(&out, THEME_OVERRIDES_TAG, ""); out = remove_blocks(&out, CONFIG_ISLAND_TAG, ""); out = remove_blocks(&out, BUNDLE_TAG, ""); static BASE: std::sync::LazyLock = std::sync::LazyLock::new(|| { - regex::Regex::new(r#"\n?"#).expect("static regex") + regex::Regex::new(r#"\n?|\n"#) + .expect("static regex") }); out = BASE.replace_all(&out, "").into_owned(); if let Some(start) = find_ascii_ci(&out, "process.exit(0),120000).unref(); } // The card heals the same way. assert_eq!(assemble_card(&m, &nested), assemble_card(&m, raw)); + + // The daemon serves the relative base now (issue #402), and a page + // assembled with it heals the same way, from either spelling. + let relative = assemble_app(&m, raw, Some("./"), None, Some("tok")); + for damaged in [&relative, &fresh] { + let served = assemble_app(&m, damaged, Some("./"), None, Some("tok")); + assert_eq!(served, relative, "re-assembling heals to the fresh page"); + assert_eq!(served.matches("/`: `""` at the root of a hostname, and + * `/user/a/proxy/8765` behind a proxy that strips that prefix before + * forwarding (issue #402), which the daemon never sees and so cannot tell the + * page. A path without the app's segment (an export's own page, a test) has + * none, so everything derived from it is what it always was. + */ +function proxyPrefix(path: string, appId: string): string { + const at = path.indexOf(`/apps/${appId}/`); + return at > 0 ? path.slice(0, at) : ""; +} + +/** The path prefix of this page's own address; see {@link proxyPrefix}. */ +function pagePrefix(appId: string): string { + try { + return proxyPrefix(window.location.pathname, appId); + } catch { + return ""; + } +} + +/** + * The endpoint the page's own origin implies, under the page's path prefix + * ({@link proxyPrefix}). `null` under `file://`, where there is no host to + * derive one from. */ function sameOriginEndpoint(appId: string): string | null { const loc = window.location; if (loc.protocol !== "http:" && loc.protocol !== "https:") return null; const proto = loc.protocol === "https:" ? "wss:" : "ws:"; - return `${proto}//${loc.host}/apps/${appId}/agent`; + return `${proto}//${loc.host}${pagePrefix(appId)}/apps/${appId}/agent`; } /** @@ -818,7 +841,11 @@ export class BioRouterClient { constructor(config: AppConfig) { this.config = config; - figureFramePath = "/apps/" + encodeURIComponent(config.appId) + "/figure-frame"; + figureFramePath = + pagePrefix(encodeURIComponent(config.appId)) + + "/apps/" + + encodeURIComponent(config.appId) + + "/figure-frame"; this.ui = new UiRuntime(this); } @@ -1232,14 +1259,17 @@ export class BioRouterClient { */ private httpBase(): string { const ep = this.activeEndpoint; - const appPath = `/apps/${encodeURIComponent(this.config.appId)}`; + const id = encodeURIComponent(this.config.appId); + const appPath = `/apps/${id}`; if (ep) { const u = new URL(ep); const proto = u.protocol === "wss:" ? "https:" : "http:"; - return `${proto}//${u.host}${appPath}`; + // Under the endpoint's own path prefix, so a page behind a proxy that + // strips one asks for `/apps//models`, not the proxy's root. + return `${proto}//${u.host}${proxyPrefix(u.pathname, id)}${appPath}`; } const loc = window.location; - return `${loc.protocol}//${loc.host}${appPath}`; + return `${loc.protocol}//${loc.host}${pagePrefix(id)}${appPath}`; } /** Provider/model catalog the user has available (the provider-agnostic diff --git a/crates/biorouter-server/src/auth.rs b/crates/biorouter-server/src/auth.rs index 0401b7a6b..edd9dfdc1 100644 --- a/crates/biorouter-server/src/auth.rs +++ b/crates/biorouter-server/src/auth.rs @@ -488,8 +488,14 @@ pub fn mint_app_launch(app_id: &str) -> String { /// app's browser surface. A token that is unknown, expired, already redeemed or /// for another app gets `None`, and a redeemed one is gone. /// -/// `Path=/apps/{id}` (no trailing slash) covers the redirect route, the page and -/// everything below it. `HttpOnly` keeps the page's own script from reading it, +/// It carries no `Path`, so the browser scopes it to the directory of the +/// address it redeemed the link at: `/apps/{id}` (no trailing slash), covering +/// the redirect route, the page and everything below it. That is exactly the +/// `Path=/apps/{id}` this used to name, except under a reverse proxy's path +/// prefix (issue #402), which the daemon never sees: there the browser's own +/// address is `//apps/{id}/`, and a cookie scoped to `/apps/{id}` was +/// never sent back, so every app refused its own page. `HttpOnly` keeps the +/// page's own script from reading it, /// and `SameSite=Strict` keeps it off every request another site starts: the /// two flags `routes::web_ui` sets on the served document's cookie. /// @@ -515,7 +521,7 @@ pub fn mint_app_launch(app_id: &str) -> String { pub fn redeem_app_launch(app_id: &str, presented: &str) -> Option { let value = app_access().redeem(app_id, presented, Instant::now())?; Some(format!( - "{}={value}; Path=/apps/{app_id}; HttpOnly; SameSite=Strict", + "{}={value}; HttpOnly; SameSite=Strict", app_access_cookie_name() )) } @@ -626,8 +632,10 @@ fn app_access_granted_by( /// secure context: a loopback address, `localhost` or a name under /// `.localhost`, or any address the browser itself attached Fetch Metadata to /// (`Sec-Fetch-Mode` and `Sec-Fetch-Dest`), which is how an HTTPS reverse proxy -/// in front of the daemon is recognised. A request with no `Host` (nor -/// authority) was not sent by a browser, and counts as one. +/// in front of the daemon is recognised; and for a WebSocket handshake, which +/// carries no Fetch Metadata anywhere, an `Origin` that is `https` +/// ([`is_a_websocket_upgrade_from_an_https_page`]). A request with no `Host` +/// (nor authority) was not sent by a browser, and counts as one. /// /// It is what decides whether [`is_document_navigation`] and the opener /// policies mean anything. A browser sends Fetch Metadata only to such an @@ -672,7 +680,40 @@ pub fn reached_over_a_secure_context( None => return true, }, }; - host_is_loopback(host) || carries_fetch_metadata(headers) + host_is_loopback(host) + || carries_fetch_metadata(headers) + || is_a_websocket_upgrade_from_an_https_page(headers) +} + +/// Whether a request is a WebSocket handshake opened by a page served over +/// `https`: `Upgrade: websocket` and an `Origin` whose scheme is `https`. +/// +/// The one browser request that reaches an app surface with no Fetch Metadata +/// at all, wherever the page is: Chrome attaches none to a WebSocket handshake +/// (measured 2026-10-16). So behind an HTTPS reverse proxy every app's agent +/// socket (`/apps/{id}/agent`) was refused as though the page were at a +/// plain-HTTP address, and the app showed "No Biorouter backend" (issue +/// #402). The handshake's `Origin` answers the same question instead: it is +/// the address of the page that opened the socket, a page at an `https` +/// address is a secure context, and script can set neither `Origin` nor +/// `Upgrade`. A page at a plain-HTTP network address presents an `http` +/// origin and is still refused. Whether the origin is this daemon's own is the +/// socket gate's question (`routes::UpgradeOrigin::is_this_daemons`), asked +/// after this one. +fn is_a_websocket_upgrade_from_an_https_page(headers: &axum::http::HeaderMap) -> bool { + let header = |name: axum::http::header::HeaderName| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + }; + header(axum::http::header::UPGRADE).is_some_and(|value| value.eq_ignore_ascii_case("websocket")) + && header(axum::http::header::ORIGIN) + .and_then(crate::routes::WebOrigin::parse) + // Parsed first, so the scheme is normalised and the rest is an origin; + // the accessor would be dead code in `biorouterd`, which compiles + // `routes` without this lib-only module. + .is_some_and(|origin| origin.to_string().starts_with("https://")) } /// Whether a request carries the Fetch Metadata a browser attaches to a @@ -1961,11 +2002,19 @@ mod tests { // An HTTP LAN page already open during an update can forge this proxy // header on a fetch, but cannot make the browser emit Fetch Metadata. - // Nor does metadata that is present but blank, or only half of it. + // Nor does metadata that is present but blank, or only half of it, + // nor a socket handshake from a page at a plain-HTTP address, nor an + // `https` origin on a request that is not a socket handshake. for forged in [ vec![("x-forwarded-proto", "https")], vec![("sec-fetch-mode", "navigate")], vec![("sec-fetch-mode", " "), ("sec-fetch-dest", "document")], + vec![ + ("upgrade", "websocket"), + ("origin", "http://192.168.1.5:8765"), + ], + vec![("upgrade", "websocket"), ("origin", "null")], + vec![("origin", "https://biorouter.example.org")], ] { for uri in surfaces { let mut headers = forged.clone(); @@ -2004,6 +2053,14 @@ mod tests { behind_a_proxy(Some("https,http")), behind_a_proxy(Some("http")), behind_a_proxy(None), + // A WebSocket handshake carries no Fetch Metadata in Chrome, so an + // app's agent socket behind an HTTPS proxy was refused. Its origin + // says the page that opened it is at an `https` address. + vec![ + ("host", "biorouter.example.org"), + ("upgrade", "websocket"), + ("origin", "https://biorouter.example.org"), + ], // No `Host` at all is not a browser. vec![], ] { diff --git a/crates/biorouter-server/src/commands/agent.rs b/crates/biorouter-server/src/commands/agent.rs index 8c1a945ff..5af0d63c9 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -460,13 +460,25 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { // before it starts the daemon; a launcher that did not is refused here // rather than having its declaration quietly ignored, which would leave the // live views refusing their own page with nothing on screen to say why. - let public_url = match std::env::var(crate::routes::PUBLIC_URL_ENV) { - Ok(value) if !value.trim().is_empty() => Some( - crate::routes::PublicUrl::parse(&value) - .map_err(|e| anyhow::anyhow!("{}: {e}", crate::routes::PUBLIC_URL_ENV))?, - ), - _ => None, - }; + // Only on a daemon that serves the interface: the desktop application hands + // its daemon its whole environment, and a value someone exported must + // neither stop that daemon starting nor widen its socket gates. + let declared = std::env::var(crate::routes::PUBLIC_URL_ENV).ok(); + let public_url = + match crate::routes::public_url_from(settings.serve_ui.as_deref(), declared.as_deref()) { + Some(parsed) => Some( + parsed.map_err(|e| anyhow::anyhow!("{}: {e}", crate::routes::PUBLIC_URL_ENV))?, + ), + None => { + if declared.is_some_and(|value| !value.trim().is_empty()) { + tracing::warn!( + "{} is ignored: this daemon does not serve the web interface", + crate::routes::PUBLIC_URL_ENV + ); + } + None + } + }; if let Some(public) = public_url.as_ref() { info!( "the interface is reached through {}{}; the WebSocket gates admit that origin", diff --git a/crates/biorouter-server/src/routes/apps.rs b/crates/biorouter-server/src/routes/apps.rs index 6ffc1554f..918260f6f 100644 --- a/crates/biorouter-server/src/routes/apps.rs +++ b/crates/biorouter-server/src/routes/apps.rs @@ -116,7 +116,7 @@ fn store() -> ArtifactStore { /// - `frame-src 'self' data:` — the app's own figure frames /// (`/apps//figure-frame`, which carry their own policy) and `data:` frames. /// - `form-action 'none'`, `base-uri 'self'` — no form posts, and the -/// `` stays same-origin. +/// `` (the app root, which the page is served at) stays same-origin. /// - `frame-ancestors 'none'` — nothing frames an app page. ⚠ It was `'self'`, /// and every app shares this daemon's origin, so one app's page could frame /// another's and read its socket token out of the frame's DOM. Nothing frames @@ -408,8 +408,8 @@ struct PageQuery { } /// The answer to a launch link: a page of the app's own origin that sets the -/// access cookie (when the token redeemed) and moves the browser on to -/// `/apps/{id}/`, replacing the tokenised address in its history. +/// access cookie (when the token redeemed) and moves the browser on to the app's +/// page, replacing the tokenised address in its history. /// /// ⚠ A `200` page that navigates, not a `303`. The cookie is `SameSite=Strict`, /// and a navigation another site started stays cross-site through its @@ -422,16 +422,15 @@ struct PageQuery { /// site. The navigation this page starts is same-site, so the cookie goes with /// it. A meta refresh rather than a script, because the page's policy allows no /// script at all. -/// -/// The id is a validated app name, so it needs no escaping in the markup. -fn launch_bounce(id: &str, cookie: Option) -> Response { - let page = format!( - "\ - \ +fn launch_bounce(cookie: Option) -> Response { + // `./` rather than `/apps/{id}/`: the link is redeemed at the page's own + // address, `/apps/{id}/?t=…`, so `./` is that page, and it stays so under a + // reverse proxy's path prefix the daemon never sees (issue #402). + let page = "\ + \ Opening Biorouter app\ \ -

Opening the app. If nothing happens, open it here.

" - ); +

Opening the app. If nothing happens, open it here.

"; let mut bounce = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/html; charset=utf-8") @@ -465,7 +464,7 @@ async fn serve_index(Path(id): Path, Query(query): Query) -> return (StatusCode::BAD_REQUEST, "invalid app id").into_response(); } let cookie = biorouter_server::auth::redeem_app_launch(&id, token); - return launch_bounce(&id, cookie); + return launch_bounce(cookie); } let st = store(); if st.load_manifest(&id).is_err() { @@ -513,7 +512,11 @@ async fn serve_index(Path(id): Path, Query(query): Query) -> Ok(h) => h, Err(_) => return (StatusCode::NOT_FOUND, "entry missing").into_response(), }; - let base_href = format!("/apps/{id}/"); + // Relative: the page is served only at `/apps/{id}/`, so `./` is the app's + // root, and it stays so under a reverse proxy's path prefix, which the + // daemon never sees (issue #402). `/apps/{id}/` sent the browser to the + // proxy's root for the bundle and every asset. + let base_href = "./"; // Embed this daemon's per-app socket token so the served page can // authenticate its agent WebSocket (checked in `agent_ws`). let ws_token = ws_token_for(&id, manifest.app_uid.as_deref()); @@ -524,7 +527,7 @@ async fn serve_index(Path(id): Path, Query(query): Query) -> let html = biorouter_mcp::agent_drafter::render::assemble_app_page( &manifest, &entry_html, - Some(&base_href), + Some(base_href), Some(&ws_token), st.file_exists(&id, "dist/app.js"), &handler_nonce, @@ -9129,8 +9132,10 @@ mod tests { .await .unwrap(); let bounce = String::from_utf8_lossy(&bounce); + // Relative: the link is redeemed at the page's own address, so `./` is + // the page, under a reverse proxy's path prefix too (issue #402). assert!( - bounce.contains(r#""#), + bounce.contains(r#""#), "the page moves on to the app, and the token leaves the address bar: {bounce}" ); assert!(!bounce.contains(&token), "{bounce}"); @@ -9139,9 +9144,14 @@ mod tests { !cookie.contains(&token), "the cookie is not the launch token" ); - for flag in ["Path=/apps/hrd1-launch;", "HttpOnly", "SameSite=Strict"] { + for flag in ["HttpOnly", "SameSite=Strict"] { assert!(cookie.contains(flag), "{flag} missing from {cookie}"); } + // No `Path`: the browser scopes the cookie to the directory of the + // address it redeemed the link at, `/apps/hrd1-launch` at the root and + // `//apps/hrd1-launch` behind a proxy that strips a prefix. A + // fixed `Path=/apps/hrd1-launch` was never sent back through one. + assert!(!cookie.contains("Path="), "{cookie}"); // Spent, it sets nothing; nor does a token that was never minted. let spent = exchange(Some(token)).await; diff --git a/crates/biorouter-server/src/routes/mod.rs b/crates/biorouter-server/src/routes/mod.rs index 1034fac6e..5c1790e7f 100644 --- a/crates/biorouter-server/src/routes/mod.rs +++ b/crates/biorouter-server/src/routes/mod.rs @@ -358,6 +358,17 @@ impl PublicUrl { }; let origin = WebOrigin::with_authority(url.scheme(), &authority) .ok_or_else(|| format!("{value:?} names a host no browser origin can carry"))?; + // A browser applies the shell's protections only at a secure address, + // and at a plain-HTTP network address the daemon refuses the interface + // (`auth::reached_over_a_secure_context`). Declaring one would only make + // the live views connect to a page that cannot be served, or, behind a + // proxy that rewrites `Host` to loopback, to one served without them. + if url.scheme() == "http" && !host_is_loopback_name(host) { + return Err(format!( + "{value:?} is plain http at a network address; use the https address of the \ + proxy in front of it" + )); + } let allowed = |b: u8| b.is_ascii_alphanumeric() || b"-._~!$&'()*+,=:@%/".contains(&b); if !url.path().bytes().all(allowed) { return Err(format!( @@ -387,19 +398,51 @@ impl PublicUrl { } } -/// This process's declared public address, read once. A value that does not -/// parse is `None` here; `commands::agent` refuses to start on one, so a -/// running daemon never reaches this with it. +/// Whether a URL's host names this machine the way a browser counts as secure: +/// a loopback address, `localhost`, or a name under `.localhost`. `host` is as +/// `url::Url::host_str` writes it, an IPv6 address in brackets. +fn host_is_loopback_name(host: &str) -> bool { + let bare = host.trim_start_matches('[').trim_end_matches(']'); + if let Ok(address) = bare.parse::() { + return address.is_loopback(); + } + let name = bare.trim_end_matches('.').to_ascii_lowercase(); + name == "localhost" || name.ends_with(".localhost") +} + +/// This process's declared public address, read once, and only on a daemon +/// that serves the web interface (`BIOROUTER_SERVE_UI`, which `biorouter serve` +/// sets beside it). The desktop application hands its daemon its whole +/// environment, so a value exported in someone's shell must not reach the +/// desktop daemon's socket gates. A value that does not parse is `None` here; +/// `commands::agent` refuses to start on one, so a running daemon never +/// reaches this with it. pub(crate) fn declared_public_url() -> Option<&'static PublicUrl> { static DECLARED: LazyLock> = LazyLock::new(|| { - std::env::var(PUBLIC_URL_ENV) - .ok() - .filter(|value| !value.trim().is_empty()) - .and_then(|value| PublicUrl::parse(&value).ok()) + public_url_from( + std::env::var(SERVE_UI_ENV).ok().as_deref(), + std::env::var(PUBLIC_URL_ENV).ok().as_deref(), + ) + .and_then(Result::ok) }); DECLARED.as_ref() } +/// The variable that makes a daemon serve the web interface +/// (`configuration::Settings::serve_ui`). +const SERVE_UI_ENV: &str = "BIOROUTER_SERVE_UI"; + +/// The public address a daemon with these two variables declares: none unless +/// it serves the web interface and names one, else the parse of it. +pub fn public_url_from( + serve_ui: Option<&str>, + public_url: Option<&str>, +) -> Option> { + let serves_the_interface = serve_ui.is_some_and(|dir| !dir.trim().is_empty()); + let value = public_url.filter(|value| !value.trim().is_empty())?; + serves_the_interface.then(|| PublicUrl::parse(value)) +} + /// What a WebSocket upgrade says about where it came from and where it was /// sent, read off the request once so that both socket gates — the workspace /// socket and the per-app agent socket — ask the same question of it. @@ -849,6 +892,26 @@ mod origin_tests { assert!(!upgrade(None, hub, "https", Some(&public)).is_this_daemons()); } + /// The desktop application hands its daemon its whole environment, so a + /// value someone exported must not reach a daemon that serves no interface: + /// not to refuse its start, and not to widen its socket gates. + #[test] + fn a_public_url_is_read_only_by_a_daemon_that_serves_the_interface() { + let url = Some("https://hub.example.org/p/"); + assert!(super::public_url_from(None, url).is_none()); + assert!(super::public_url_from(Some(" "), url).is_none()); + assert!(super::public_url_from(None, Some("not a url")).is_none()); + assert!(matches!( + super::public_url_from(Some("/usr/share/biorouter/web"), url), + Some(Ok(_)) + )); + assert!(matches!( + super::public_url_from(Some("/usr/share/biorouter/web"), Some("not a url")), + Some(Err(_)) + )); + assert!(super::public_url_from(Some("/usr/share/biorouter/web"), Some("")).is_none()); + } + #[test] fn a_public_url_keeps_its_origin_and_its_path_without_the_trailing_slash() { let parsed = |value: &str| { @@ -864,9 +927,14 @@ mod origin_tests { parsed("https://Proxy.Example.org:443/a/b"), expect("https://proxy.example.org", "/a/b") ); + // Plain http only on this machine (an SSH tunnel's far end). + assert_eq!( + parsed("http://127.0.0.1:8765"), + expect("http://127.0.0.1:8765", "/") + ); assert_eq!( - parsed("http://10.0.0.5:8080"), - expect("http://10.0.0.5:8080", "/") + parsed("http://localhost:8765/x/"), + expect("http://localhost:8765", "/x") ); assert_eq!( parsed("https://[::1]:9443/"), @@ -887,6 +955,11 @@ mod origin_tests { "https://hub.example.org/#top", // `;` would end the cookie's Path attribute and start another. "https://hub.example.org/a;Domain=evil.net/", + // Plain http at a network address: the interface is refused there. + "http://10.0.0.5:8080", + "http://hub.example.org/", + // A host no browser origin carries. + "https://a*b.example.org/", ] { assert!( PublicUrl::parse(refused).is_err(), diff --git a/crates/biorouter-server/src/routes/web_ui.rs b/crates/biorouter-server/src/routes/web_ui.rs index 98f95fc67..1d5598372 100644 --- a/crates/biorouter-server/src/routes/web_ui.rs +++ b/crates/biorouter-server/src/routes/web_ui.rs @@ -165,8 +165,11 @@ impl WebUi { match &self.browser_token { // No token configured: the launcher bound loopback and chose not to // require one. `biorouter serve` refuses this combination for any - // non-loopback bind, so reaching here means the only callers are on - // this machine. + // non-loopback bind, so reaching here means every caller is on this + // machine: its other accounts, and a reverse proxy on it forwarding + // whoever reaches the proxy. That is the operator's call to make, + // and why `docs/deployment/browser-access.md` keeps the token on a + // JupyterHub whose users share one machine. None => true, Some(expected) => presented.is_some_and(|p| super::secret_matches(p, expected)), } @@ -354,7 +357,9 @@ async fn index( /// own URL. /// /// It names only `.` and `..` segments, so nothing from the request reaches the -/// page, and the shell's own policy (`base-uri 'self'`) accepts it. +/// page, and it always resolves to this origin, which the bundle's own content +/// policy (`base-uri 'self'`, in the `` of `ui/desktop/index.html`) +/// accepts. fn mount_href(path: &str) -> String { match path.matches('/').count().saturating_sub(1) { 0 => "./".to_string(), diff --git a/scripts/agent-drafter/ui-control-harness.mjs b/scripts/agent-drafter/ui-control-harness.mjs index a2c752bf5..a751a9e14 100644 --- a/scripts/agent-drafter/ui-control-harness.mjs +++ b/scripts/agent-drafter/ui-control-harness.mjs @@ -1792,6 +1792,38 @@ async function runSelftest() { "KNOWN_EVENT_KINDS is not Object.keys(KNOWN_EVENT_KINDS_MAP)" ); + // ── Issue #402: an app behind a reverse proxy that strips a path prefix ──── + // The daemon serves the page at `/apps//` and never sees the prefix, so + // the SDK reads it off the page's own address. At the root, and on an + // export's own page that holds no `/apps//`, nothing changes. + async function firstSocketUrl(pagePath) { + const domP = new JSDOM( + 'p', + { url: `http://127.0.0.1:${PORT}${pagePath}`, runScripts: "outside-only", pretendToBeVisual: true } + ); + const w = domP.window; + const seen = []; + w.WebSocket = function (url) { + seen.push(String(url)); + throw new Error("no ws (prefix probe)"); + }; + w.console = { log() {}, info() {}, debug() {}, error() {}, warn() {} }; + w.BIOROUTER_APP_CONFIG = { appId: "prefixed-app", autoChat: false, ui: false }; + w.eval(bundle); + await delay(30); + domP.window.close(); + return (seen[0] || "").split("?")[0]; + } + const origin = `ws://127.0.0.1:${PORT}`; + for (const [pagePath, expected] of [ + ["/user/a/proxy/9/apps/prefixed-app/", `${origin}/user/a/proxy/9/apps/prefixed-app/agent`], + ["/apps/prefixed-app/", `${origin}/apps/prefixed-app/agent`], + ["/", `${origin}/apps/prefixed-app/agent`], + ]) { + const got = await firstSocketUrl(pagePath); + check(`an app page at ${pagePath} connects its socket under that page's prefix`, got === expected, got); + } + dom.window.close(); log(failures === 0 ? "ALL PASS" : `${failures} FAILURE(S)`); return failures === 0 ? 0 : 1; diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index 176016645..9a93ca2f4 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -78,16 +78,22 @@ function getHeadlessConfig(): HeadlessConfig { const mountUrl = servedByDaemon ? new URL('./', document.baseURI).href.replace(/\/+$/, '') : window.location.origin; + // ⚠ Never from the address bar on a page the daemon served. That page holds + // the daemon's secret and sends it with every request, so `?api=` or + // `?headless=` naming another host would hand the secret, and everything the + // person types, to whoever wrote the link. Any site can send a browser to such + // a link, and with `--no-token` the shell is served to it. + const queryBase = (name: string) => (servedByDaemon ? null : params.get(name)); const apiBaseUrl = globalConfig.apiBaseUrl || - params.get('api') || + queryBase('api') || (servedByDaemon ? mountUrl : isLocalhost ? 'http://127.0.0.1:3000' : `${window.location.origin}/api`); const headlessBaseUrl = - globalConfig.headlessBaseUrl || params.get('headless') || `${mountUrl}/headless`; + globalConfig.headlessBaseUrl || queryBase('headless') || `${mountUrl}/headless`; if (querySecret) { window.sessionStorage?.setItem('biorouter-headless-secret', querySecret); From b11e670eca4c4b03b96f0db54032b363a5d18968 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 17 Oct 2026 10:27:58 -0700 Subject: [PATCH 4/5] docs(serve): JupyterHub token guidance, apps under a prefix (SD-15) 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. --- CLAUDE.md | 7 +++- docs/deployment/browser-access.md | 60 +++++++++++++++++++++--------- docs/deployment/serve-decisions.md | 22 +++++++++-- 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b67183366..06e8a998f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2086,7 +2086,12 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de `Sec-Fetch-Mode` and `Sec-Fetch-Dest`, which a browser sends only to an https or loopback URL and script cannot forge. It no longer asks `X-Forwarded-Proto`: requiring it beside the metadata added nothing and refused JupyterHub, whose chain reports `https,http`. A plain-HTTP LAN - address is still refused, because Chrome sends no Fetch Metadata there. + address is still refused, because Chrome sends no Fetch Metadata there. ⚠ Chrome sends no Fetch + Metadata on a WebSocket handshake ANYWHERE, so a handshake counts when its `Origin` is `https` + (`is_a_websocket_upgrade_from_an_https_page`); without that arm every app's agent socket was + refused behind an HTTPS proxy. Built apps under a prefix: the page gets ``, the + launch bounce goes to `./`, the app cookie carries no `Path`, and `sdk.ts` reads the prefix off + its own address (`proxyPrefix`). Don't reintroduce a root-absolute `/apps/{id}/` in any of them. - **Tests:** `cargo test -p biorouter-server --lib -- routes::web_ui routes::shell` (the `--` is required: cargo takes one filter before it and rejects a second with a usage error), `cargo test -p biorouter-cli --lib commands::serve`, the `serve` job in diff --git a/docs/deployment/browser-access.md b/docs/deployment/browser-access.md index a7e86b5cc..4365b5b1f 100644 --- a/docs/deployment/browser-access.md +++ b/docs/deployment/browser-access.md @@ -231,24 +231,48 @@ trailing slash: the interface loads its files relative to it. #### JupyterHub With [jupyter-server-proxy](https://jupyter-server-proxy.readthedocs.io/), each person's server can -start Biorouter and serve it at `https:///user//biorouter/`. The hub's own login protects -the address, so the access token can be turned off: - -```python -c.ServerProxy.servers = { - "biorouter": { - "command": [ - "biorouter", "serve", "--port", "{port}", "--no-token", - "--public-url", "https://hub.example.org{base_url}biorouter/", - ], - "timeout": 60, - }, -} -``` - -`{base_url}` is the server's own prefix, for example `/user/alice/`. Replace `hub.example.org` with -the hub's hostname. `--no-token` is accepted because `serve` binds the loopback address, which only -the proxy on the same machine can reach. +start Biorouter and serve it at `https:///user//biorouter/`. `{base_url}` below is the +server's own prefix, for example `/user/alice/`; replace `hub.example.org` with the hub's hostname. + +Whether to keep the access token depends on who else can reach the server's loopback port: + +- **Each server runs in its own container** (KubeSpawner, DockerSpawner). Only the hub's proxy + reaches the port, and the hub's login protects the address, so `--no-token` is reasonable: + + ```python + c.ServerProxy.servers = { + "biorouter": { + "command": [ + "biorouter", "serve", "--port", "{port}", "--no-token", + "--public-url", "https://hub.example.org{base_url}biorouter/", + ], + "timeout": 60, + }, + } + ``` + +- **Servers share one machine** (the default LocalProcessSpawner, The Littlest JupyterHub). Every + other account on that machine can reach the loopback port and would use Biorouter as you, so + keep the token. Put a token in a file only you can read, pass it with `BIOROUTER_BROWSER_TOKEN`, + and open `https://hub.example.org/user//biorouter/?t=` once. The cookie that address + sets lasts until the server stops. + + ```python + import pathlib + + token_file = pathlib.Path.home() / ".config" / "biorouter" / "browser-token" + + c.ServerProxy.servers = { + "biorouter": { + "command": [ + "biorouter", "serve", "--port", "{port}", + "--public-url", "https://hub.example.org{base_url}biorouter/", + ], + "environment": {"BIOROUTER_BROWSER_TOKEN": token_file.read_text().strip()}, + "timeout": 60, + }, + } + ``` ## The model is fixed before you start diff --git a/docs/deployment/serve-decisions.md b/docs/deployment/serve-decisions.md index 2499bf4db..b1d31ade6 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -1191,15 +1191,17 @@ such engines are refused through a non-loopback HTTPS proxy. Per-app origins rem ## SD-15: Behind a reverse proxy, at the root or under a path -**Ruling (2026-10-16, issue #402).** Three changes, taken together because one deployment needed -all three: JupyterHub, where each person's server is reached at +**Ruling (2026-10-16, issue #402).** Four changes, taken together because one deployment needed +all four: JupyterHub, where each person's server is reached at `https://hub.example.org/user//proxy//` through a TLS terminator, configurable-http-proxy and jupyter-server-proxy. 1. **The browser says whether an address is secure.** A request whose `Host` is not loopback counts as reaching a secure address when the browser attached Fetch Metadata to it (`Sec-Fetch-Mode` and `Sec-Fetch-Dest`). `X-Forwarded-Proto` is no longer part of that decision - (`auth::reached_over_a_secure_context`). + (`auth::reached_over_a_secure_context`). A WebSocket handshake, which carries no Fetch Metadata + in Chrome wherever the page is, counts when its `Origin` is `https`: the page that opened it is + at a secure address, and script can set neither `Origin` nor `Upgrade`. 2. **The interface works under a path prefix that a proxy strips.** `npm run build:web` builds the bundle with a relative base (`--base ./`), and the daemon writes `` into the shell, naming the mount relative to the path each request landed on @@ -1209,7 +1211,15 @@ configurable-http-proxy and jupyter-server-proxy. 3. **`biorouter serve --public-url
` names the address a browser uses.** `serve` prints that address with the access token. The daemon admits its origin on the WebSocket gates (`routes::PublicUrl`, `UpgradeOrigin::is_this_daemons`) and scopes the session cookie's `Path` - to its path. `BIOROUTER_PUBLIC_URL` in the shell is read when the flag is absent. + to its path. `BIOROUTER_PUBLIC_URL` in the shell is read when the flag is absent. The address + must be `https` unless it names this machine, and only a daemon that serves the interface reads + the variable, because the desktop application hands its daemon its whole environment. +4. **Built apps work under the prefix too.** An app's page is served with `` + (it is only ever served at `/apps/{id}/`), its launch link moves the browser to `./`, and its + access cookie carries no `Path`, so the browser scopes it to the address it redeemed the link + at. The App SDK reads the prefix off its own page's address, in front of `/apps/{id}/`, for its + agent socket, its REST calls and its figure frames; a page without that segment (an export's + own) derives what it always did. **Why.** Version 1.93.0 refused every JupyterHub deployment of `serve`, and lost the path prefix support that 1.89 had (issue #9). @@ -1232,6 +1242,10 @@ support that 1.89 had (issue #9). The `` closes that. It needs no configuration, because a proxy that strips a prefix leaves the part of the path below the mount unchanged, so the distance back up is the same on both sides of the proxy. +- Every app surface asks the same secure address question, and an app's agent socket is a + WebSocket, so behind any HTTPS proxy every app opened and then said "No Biorouter backend". The + app's page, cookie and SDK also named `/apps/{id}/` from the root, which under a prefix sent + them to the proxy's root. - The live views connect over a WebSocket whose gate compares the page's `Origin` with the `Host` and the scheme from `X-Forwarded-Proto`. Through the same chain that comparison refuses the interface's own socket, so the page loads and every live view stays empty. Chrome sends no Fetch From 4899882f67648de55e9ee7b3e484c1873eb4c4ba Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 17 Oct 2026 10:35:08 -0700 Subject: [PATCH 5/5] fix(apps): keep the trailing-slash redirect under a proxy's path prefix `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 `//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. --- crates/biorouter-server/src/routes/apps.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/biorouter-server/src/routes/apps.rs b/crates/biorouter-server/src/routes/apps.rs index 918260f6f..825b4e92e 100644 --- a/crates/biorouter-server/src/routes/apps.rs +++ b/crates/biorouter-server/src/routes/apps.rs @@ -322,7 +322,11 @@ async fn redirect_to_slash(Path(id): Path) -> Response { if validate_artifact_id(&id).is_err() { return (StatusCode::BAD_REQUEST, "invalid app id").into_response(); } - Redirect::temporary(&format!("/apps/{id}/")).into_response() + // Relative, so it stays under a reverse proxy's path prefix, which the + // daemon never sees (issue #402): `/apps/x` resolves `./x/` to `/apps/x/`, + // and `//apps/x` to `//apps/x/`. The id is letters, digits, + // `-` and `_` only, so it cannot turn the reference into anything else. + Redirect::temporary(&format!("./{id}/")).into_response() } /// The origin a browser on this machine reaches the daemon's own listener at, @@ -9041,7 +9045,7 @@ mod tests { .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()), - Some("/apps/safe-app_2/") + Some("./safe-app_2/") ); }