Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base href> 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
Expand Down Expand Up @@ -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'

Expand All @@ -668,6 +671,29 @@ 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 '<head><base href="./">' "$RUNNER_TEMP/shell.html" || fail 'the shell names no 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 '<base href="../../">' "$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
# 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)
Expand Down
33 changes: 27 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -2066,11 +2071,27 @@ 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 `<exe>/../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 `<base href>` (`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 `<base>`. The renderer derives its API base from the same
base URI, so nothing in the served page may assume it lives at `/`. And `<exe>/../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. ⚠ 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 `<base href="./">`, 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
Expand Down
6 changes: 4 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base href> 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.
#
Expand Down
12 changes: 12 additions & 0 deletions crates/biorouter-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,17 @@ struct ServeOptions {
)]
web_dir: Option<std::path::PathBuf>,

/// 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<String>,

/// Configure a separate approval key for Biorouter Copilot tasks in the browser
#[arg(
long,
Expand All @@ -1495,6 +1506,7 @@ impl ServeOptions {
self.token,
self.no_token,
self.web_dir,
self.public_url,
self.open,
self.computer_use_approval,
)
Expand Down
194 changes: 192 additions & 2 deletions crates/biorouter-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ pub async fn handle_serve(
token: Option<String>,
no_token: bool,
web_dir: Option<PathBuf>,
public_url: Option<String>,
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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -554,6 +591,90 @@ 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<String>,
variable: Option<String>,
) -> Result<Option<String>> {
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");
}
// 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");
}
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();
Expand Down Expand Up @@ -812,6 +933,75 @@ 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/<name>/proxy/<port>/`). 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/")
);
// 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(
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/",
"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)
.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
Expand Down
Loading
Loading