From 5b737b808e4f5660e8ba3dc95d6c35b0cf710c50 Mon Sep 17 00:00:00 2001 From: Kyler Cao Date: Tue, 28 Jul 2026 14:21:22 -0500 Subject: [PATCH 1/3] feat(relay): serve the relay under an optional BUZZ_BASE_PATH prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay mounted every route at the root, so it could not be deployed behind a gateway that routes to apps by path rather than by hostname. Serving a WebSocket at `/` is not an option on such a platform, which left self-hosters with a path-routing ingress no choice but a fork. Add `BUZZ_BASE_PATH`, empty by default. When set, the whole surface nests under the prefix: the WebSocket and NIP-11 handshake answer on `wss://host/` and the HTTP routes live beneath it. Health probes stay mounted at the root as well, since Kubernetes reaches the pod directly rather than through the gateway. The prefix is normalized at load time (one leading slash, no trailing slash) and values that would make the server's URL reconstruction differ from the client's connect URL — query, fragment, whitespace, dot or empty segments — fail startup rather than surfacing later as an unexplainable 401. Threaded through every place a URL is rebuilt from the request host, since each one is signed and compared: - `nip98_expected_url` — the single choke point for bridge, invite, and moderation `u` tags - `nip42_expected_relay_url` — the WebSocket AUTH `relay` tag - the operator API's expected URL - the NIP-11 `push.origin` descriptor, which push clients dial verbatim Client-side, three places assumed the relay owned the origin root: `buzz-cli` rejected prefixed media URLs as non-media, the desktop Download affordance keyed on a root-anchored `/media/` path, and mobile used `Uri.resolve('/query')`, which discards a base path. Mobile now joins endpoints through a shared `relayEndpoint` helper. Known limitation, documented on the config field: the browser web bundle bakes absolute `/assets/...` URLs at build time, so serving that SPA under a prefix also needs a matching Vite `base`. The protocol surface is unaffected. Closes #3354 Signed-off-by: Kyler Cao --- .env.example | 5 + crates/buzz-cli/src/client.rs | 57 ++++++- crates/buzz-relay/src/api/bridge.rs | 134 +++++++++++++--- crates/buzz-relay/src/api/invites.rs | 7 +- crates/buzz-relay/src/api/operator.rs | 6 +- crates/buzz-relay/src/audio/handler.rs | 6 +- crates/buzz-relay/src/config.rs | 146 ++++++++++++++++++ crates/buzz-relay/src/handlers/auth.rs | 7 +- crates/buzz-relay/src/nip11.rs | 44 +++++- crates/buzz-relay/src/router.rs | 110 ++++++++++++- deploy/compose/.env.example | 4 + .../shared/ui/markdown/mediaEntry.test.mjs | 15 ++ desktop/src/shared/ui/markdown/mediaEntry.ts | 9 +- mobile/lib/shared/relay/media_auth.dart | 3 +- mobile/lib/shared/relay/media_upload.dart | 6 +- mobile/lib/shared/relay/relay_client.dart | 7 +- mobile/lib/shared/relay/relay_endpoint.dart | 34 ++++ mobile/lib/shared/relay/relay_session.dart | 3 +- .../shared/relay/relay_endpoint_test.dart | 56 +++++++ .../test/shared/relay/relay_session_test.dart | 8 +- 20 files changed, 612 insertions(+), 55 deletions(-) create mode 100644 mobile/lib/shared/relay/relay_endpoint.dart create mode 100644 mobile/test/shared/relay/relay_endpoint_test.dart diff --git a/.env.example b/.env.example index 3dc54856e7..f0e27c97e4 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,11 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 +# Optional URL path prefix to serve the relay under. Empty (the default) mounts +# everything at /. Set this only when a gateway routes to the relay by path +# instead of by hostname — the WebSocket then answers on ws://host/ and +# every HTTP route lives under it. RELAY_URL must include the same prefix. +# BUZZ_BASE_PATH=/relay # Stable relay signing key. Set this in dev if you want REST-created forum posts # to keep resolving to the original author across relay restarts. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..353e671744 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -272,12 +272,17 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result/media/`, so the relay URL's own path is stripped before + // the `/media/` check rather than assuming the relay owns the root. + let relay_prefix = relay.path().trim_end_matches('/'); + let media_path = parsed + .path() + .strip_prefix(relay_prefix) + .unwrap_or(parsed.path()); + let Some(sha256_ext) = media_path.strip_prefix("/media/") else { return Err(CliError::Usage( "media URL must point at a /media/ path".to_string(), )); @@ -287,8 +292,6 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result/media/`, so both the sha shorthand and a full URL have to + /// work against a relay URL that carries a path. + #[test] + fn media_url_handles_a_relay_served_under_a_base_path() { + let hash = "a".repeat(64); + assert_eq!( + media_url_from_input("https://relay.example/relay", &format!("{hash}.jpg")).unwrap(), + format!("https://relay.example/relay/media/{hash}.jpg"), + "sha shorthand resolves under the prefix" + ); + assert_eq!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://relay.example/relay/media/{hash}.jpg") + ) + .unwrap(), + format!("https://relay.example/relay/media/{hash}.jpg"), + "a full prefixed URL is accepted, not rejected as non-media" + ); + assert!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://relay.example/relay/media-evil/{hash}.jpg") + ) + .is_err(), + "the /media/ requirement still holds inside the prefix" + ); + assert!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://evil.example/relay/media/{hash}.jpg") + ) + .is_err(), + "origin pinning still holds under a prefix" + ); + } + #[test] fn media_url_accepts_only_same_relay_media_urls() { let hash = "a".repeat(64); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 2e00d6bd2f..0a28380e06 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -192,8 +192,14 @@ async fn check_nip98_replay_with_guard( /// pass and the relay would proceed against the wrong tenant's auth context), /// and (b) reject every legitimate request whose community host isn't the /// single configured one. Substituting `tenant.host()` closes both directions. +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). Callers pass the route's own unprefixed path — the same +/// literal the route is declared with — and the prefix is applied here, at the +/// single choke point, so a prefixed deployment reconstructs the URL the client +/// actually signed instead of 401ing on a path mismatch. pub(crate) fn nip98_expected_url( config_relay_url: &str, + base_path: &str, tenant: &TenantContext, path: &str, ) -> String { @@ -202,7 +208,7 @@ pub(crate) fn nip98_expected_url( } else { "http" }; - format!("{scheme}://{}{path}", tenant.host()) + format!("{scheme}://{}{base_path}{path}", tenant.host()) } /// Construct the NIP-42 expected `relay` URL for a connection bound to `tenant`. @@ -220,15 +226,22 @@ pub(crate) fn nip98_expected_url( /// the client's connect URL embedded in the signed AUTH event; the helper /// preserves the deployment's TLS posture from `config_relay_url`'s prefix so /// `wss://` deployments stay `wss://` and `ws://` dev/test stays `ws://`. -/// Path is empty — clients put the bare WS origin (`ws://host[:port]`) in the -/// `relay` tag, matching how `EventBuilder::auth` accepts a [`nostr::RelayUrl`]. -pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantContext) -> String { +/// Path is the deployment's `BUZZ_BASE_PATH` prefix and nothing more: clients put +/// their connect URL in the `relay` tag, which is the bare WS origin +/// (`ws://host[:port]`) at the root and `ws://host[:port]/` when the relay +/// is served under one. Matching how `EventBuilder::auth` accepts a +/// [`nostr::RelayUrl`]. +pub(crate) fn nip42_expected_relay_url( + config_relay_url: &str, + base_path: &str, + tenant: &TenantContext, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") { "wss" } else { "ws" }; - format!("{scheme}://{}", tenant.host()) + format!("{scheme}://{}{base_path}", tenant.host()) } /// Extract a channel UUID from a single filter's `#h` tag. @@ -632,7 +645,12 @@ pub async fn submit_event( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/events", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -900,7 +918,12 @@ pub async fn query_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/query", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -1335,7 +1358,12 @@ pub async fn count_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/count", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -2066,7 +2094,12 @@ async fn authorize_moderation_read( Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + &path_with_query, + ); let (pubkey, event_id_bytes) = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; check_nip98_replay(state, &tenant, event_id_bytes).await?; @@ -2462,7 +2495,7 @@ mod tests { let config_relay_url = "wss://host-a.example"; // doesn't matter — only used for scheme. let tenant_b = fresh_tenant("host-b.example"); - let expected_url = nip98_expected_url(config_relay_url, &tenant_b, "/events"); + let expected_url = nip98_expected_url(config_relay_url, "", &tenant_b, "/events"); let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) .expect_err( @@ -2528,7 +2561,7 @@ mod tests { // tenant host — proving the helper uses `tenant.host()`, not the config. let config_relay_url = "wss://other-config-host.example"; let tenant_a = fresh_tenant("host-a.example"); - let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events"); + let expected_url = nip98_expected_url(config_relay_url, "", &tenant_a, "/events"); let (pubkey, _event_id_bytes) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) @@ -2553,7 +2586,7 @@ mod tests { Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - nip98_expected_url(config_relay_url, tenant, &path_with_query) + nip98_expected_url(config_relay_url, "", tenant, &path_with_query) } /// L7 read-auth blocker (Wren, #1591 sweep): the CLI signs the *full* @@ -2675,15 +2708,15 @@ mod tests { let tenant_a = fresh_tenant("host-a.example"); let tenant_b = fresh_tenant("host-b.example"); - let url_a = nip98_expected_url("wss://config-host.example", &tenant_a, "/events"); - let url_b = nip98_expected_url("wss://config-host.example", &tenant_b, "/events"); + let url_a = nip98_expected_url("wss://config-host.example", "", &tenant_a, "/events"); + let url_b = nip98_expected_url("wss://config-host.example", "", &tenant_b, "/events"); assert_eq!(url_a, "https://host-a.example/events"); assert_eq!(url_b, "https://host-b.example/events"); // Same tenant, two different config hosts → output is identical. // (If config-host ever leaked into the URL, this assertion would bite.) let url_a_alt_config = - nip98_expected_url("wss://different-config.example", &tenant_a, "/events"); + nip98_expected_url("wss://different-config.example", "", &tenant_a, "/events"); assert_eq!( url_a, url_a_alt_config, "config-relay-url's host MUST NOT influence the NIP-98 expected URL — \ @@ -2698,17 +2731,69 @@ mod tests { fn nip98_expected_url_derives_scheme_from_config() { let tenant = fresh_tenant("host-a.example"); assert_eq!( - nip98_expected_url("wss://config.example", &tenant, "/events"), + nip98_expected_url("wss://config.example", "", &tenant, "/events"), "https://host-a.example/events", "wss:// production config → https:// URL" ); assert_eq!( - nip98_expected_url("ws://config.example", &tenant, "/events"), + nip98_expected_url("ws://config.example", "", &tenant, "/events"), "http://host-a.example/events", "ws:// dev config → http:// URL" ); } + /// A relay served under `BUZZ_BASE_PATH` must reconstruct the *prefixed* URL, + /// because that is what the client signed into its NIP-98 `u` tag. Getting + /// this wrong 401s every authenticated HTTP call on a prefixed deployment + /// while looking like a signature failure. + #[test] + fn nip98_expected_url_includes_the_base_path_prefix() { + let tenant = fresh_tenant("host-a.example"); + assert_eq!( + nip98_expected_url("wss://config.example", "/relay", &tenant, "/events"), + "https://host-a.example/relay/events" + ); + assert_eq!( + nip98_expected_url("wss://config.example", "/buzz/relay", &tenant, "/query"), + "https://host-a.example/buzz/relay/query" + ); + assert_eq!( + nip98_expected_url( + "wss://config.example", + "/relay", + &tenant, + "/moderation/audit?limit=20" + ), + "https://host-a.example/relay/moderation/audit?limit=20", + "the prefix precedes the path and the query stays last" + ); + assert_eq!( + nip98_expected_url("wss://config.example", "", &tenant, "/events"), + "https://host-a.example/events", + "an empty prefix is byte-identical to pre-base-path behavior" + ); + } + + /// NIP-42 sibling: the `relay` tag carries the client's connect URL, which + /// includes the prefix when the relay is served under one. + #[test] + fn nip42_expected_relay_url_includes_the_base_path_prefix() { + let tenant = fresh_tenant("host-a.example"); + assert_eq!( + nip42_expected_relay_url("wss://config.example", "/relay", &tenant), + "wss://host-a.example/relay" + ); + assert_eq!( + nip42_expected_relay_url("ws://config.example", "/relay", &tenant), + "ws://host-a.example/relay" + ); + assert_eq!( + nip42_expected_relay_url("wss://config.example", "", &tenant), + "wss://host-a.example", + "an empty prefix is byte-identical to pre-base-path behavior" + ); + } + // ----- NIP-42 host-binding tests (sibling of NIP-98 row 44 obligation) ----- /// Sign a NIP-42 AUTH event with `relay` tag = `relay_url`, then verify @@ -2752,7 +2837,7 @@ mod tests { let config_relay_url = "ws://host-a.example:3100"; let signed_relay_url = "ws://host-a.example:3100"; let tenant_b = fresh_tenant("host-b.example:3100"); - let expected = nip42_expected_relay_url(config_relay_url, &tenant_b); + let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_b); let err = verify_nip42_with_urls(challenge, signed_relay_url, &expected).expect_err( "cross-host NIP-42 AUTH event MUST be rejected — row 44 sibling: \ @@ -2778,7 +2863,7 @@ mod tests { // host — proving the helper uses `tenant.host()`, not the config. let config_relay_url = "ws://other-config-host.example"; let tenant_a = fresh_tenant("host-a.example:3100"); - let expected = nip42_expected_relay_url(config_relay_url, &tenant_a); + let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_a); verify_nip42_with_urls(challenge, signed_relay_url, &expected) .expect("matching-host NIP-42 AUTH event must verify"); @@ -2792,15 +2877,16 @@ mod tests { let tenant_a = fresh_tenant("host-a.example:3100"); let tenant_b = fresh_tenant("host-b.example:3100"); - let url_a = nip42_expected_relay_url("ws://config-host.example", &tenant_a); - let url_b = nip42_expected_relay_url("ws://config-host.example", &tenant_b); + let url_a = nip42_expected_relay_url("ws://config-host.example", "", &tenant_a); + let url_b = nip42_expected_relay_url("ws://config-host.example", "", &tenant_b); assert_eq!(url_a, "ws://host-a.example:3100"); assert_eq!(url_b, "ws://host-b.example:3100"); // Same tenant, two different config hosts → output is identical. // (If config-host ever leaked into the URL, this assertion would bite — // catches the exact "reverted to config host" regression.) - let url_a_alt_config = nip42_expected_relay_url("ws://different-config.example", &tenant_a); + let url_a_alt_config = + nip42_expected_relay_url("ws://different-config.example", "", &tenant_a); assert_eq!( url_a, url_a_alt_config, "config-relay-url's host MUST NOT influence the NIP-42 expected URL — \ @@ -2816,12 +2902,12 @@ mod tests { fn nip42_expected_relay_url_derives_scheme_from_config() { let tenant = fresh_tenant("host-a.example:3100"); assert_eq!( - nip42_expected_relay_url("wss://config.example", &tenant), + nip42_expected_relay_url("wss://config.example", "", &tenant), "wss://host-a.example:3100", "wss:// production config → wss:// URL" ); assert_eq!( - nip42_expected_relay_url("ws://config.example", &tenant), + nip42_expected_relay_url("ws://config.example", "", &tenant), "ws://host-a.example:3100", "ws:// dev config → ws:// URL" ); diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171cca..0315c84339 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -246,7 +246,12 @@ async fn authenticate( ) })?; - let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let url = bridge::nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + path, + ); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, "POST", diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874..8073068ce5 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -74,7 +74,11 @@ async fn authorize_operator_request( Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - let url = format!("{origin}{path_with_query}"); + // `RELAY_OPERATOR_API_ORIGIN` is validated to carry no path, so the + // deployment's `BUZZ_BASE_PATH` prefix is applied here — the operator's + // signed `u` tag is the URL they actually called, prefix included. + let base_path = state.config.base_path.as_str(); + let url = format!("{origin}{base_path}{path_with_query}"); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, method, diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c..9598e16bc4 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -216,7 +216,11 @@ async fn handle_active_audio_connection( // Extract NIP-OA auth tag before verify_auth_event consumes the event. let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); - let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); + let relay_url = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + ); let auth_ctx = match state .auth .verify_auth_event(auth_msg.event, &challenge, &relay_url) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a1691349d6..13dc730197 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -74,6 +74,27 @@ pub struct Config { pub db_pool_size: u32, /// Public WebSocket URL of this relay, advertised in NIP-11. pub relay_url: String, + /// URL path prefix the relay is served under (`BUZZ_BASE_PATH`). + /// + /// Empty by default: every route mounts at the root and behavior is + /// identical to deployments that never set this. When non-empty, the whole + /// HTTP + WebSocket surface nests under the prefix, so the relay can sit + /// behind a gateway that routes by path instead of by hostname — the + /// WebSocket lands on `wss://host/` and the bridge endpoints on + /// `https://host//events` and friends. + /// + /// Normalized at load time to exactly one leading slash and no trailing + /// slash (`relay`, `/relay`, and `/relay/` all become `/relay`). The + /// prefix participates in NIP-98 `u` tags and the NIP-42 `relay` tag, so + /// it must match what clients connect to byte-for-byte. `RELAY_URL` and + /// `BUZZ_MEDIA_BASE_URL` must carry the same prefix. + /// + /// Known limitation: the browser web bundle (`BUZZ_WEB_DIR`) references its + /// assets at absolute `/assets/...` URLs baked in at build time, so serving + /// that SPA under a prefix additionally requires rebuilding it with a + /// matching Vite `base`. The relay's protocol surface — WebSocket, bridge, + /// media, git — is unaffected. + pub base_path: String, /// Public WebSocket URL of the dedicated device-pairing relay, when configured. pub pairing_relay_url: Option, /// Maximum number of concurrent WebSocket connections. @@ -275,6 +296,47 @@ fn parse_bind_addr(raw: &str) -> Result { .map_err(|e| ConfigError::InvalidBindAddr(e.to_string())) } +/// Normalize a `BUZZ_BASE_PATH` value to the canonical form the router and the +/// auth-URL builders expect: either empty (mount at the root) or exactly one +/// leading slash with no trailing slash. +/// +/// The prefix is concatenated into signed-URL expectations (NIP-98 `u`, NIP-42 +/// `relay`), so anything that could make the server's reconstruction differ from +/// the client's connect URL is rejected at startup rather than surfacing later +/// as an unexplainable 401. That means no query, fragment, whitespace, dot +/// segments, or empty interior segments. +pub(crate) fn normalize_base_path(raw: &str) -> Result { + let trimmed = raw.trim(); + let stripped = trimmed.trim_matches('/'); + if stripped.is_empty() { + // Unset, blank, and a bare "/" all mean "serve at the root". + return Ok(String::new()); + } + + let invalid = |reason: &str| { + Err(ConfigError::InvalidValue(format!( + "BUZZ_BASE_PATH {reason} (got {raw:?})" + ))) + }; + + if stripped.contains('?') || stripped.contains('#') { + return invalid("must not contain a query string or fragment"); + } + if stripped.chars().any(char::is_whitespace) { + return invalid("must not contain whitespace"); + } + for segment in stripped.split('/') { + if segment.is_empty() { + return invalid("must not contain empty path segments"); + } + if segment == "." || segment == ".." { + return invalid("must not contain dot segments"); + } + } + + Ok(format!("/{stripped}")) +} + fn positive_u64_from_env(name: &str, default: u64) -> Result { match std::env::var(name) { Ok(raw) => raw @@ -441,6 +503,11 @@ impl Config { let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); + let base_path = match std::env::var("BUZZ_BASE_PATH") { + Ok(raw) => normalize_base_path(&raw)?, + Err(_) => String::new(), + }; + let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL") .ok() .map(|value| value.trim().to_string()) @@ -891,6 +958,7 @@ impl Config { redis_pool_size, db_pool_size, relay_url, + base_path, pairing_relay_url, max_connections, max_concurrent_handlers, @@ -949,10 +1017,88 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn base_path_normalizes_equivalent_spellings() { + for raw in ["relay", "/relay", "relay/", "/relay/", " /relay/ "] { + assert_eq!( + normalize_base_path(raw).expect("valid prefix"), + "/relay", + "{raw:?} should normalize to /relay" + ); + } + assert_eq!( + normalize_base_path("/buzz/relay").expect("valid prefix"), + "/buzz/relay", + "multi-segment prefixes are preserved" + ); + } + + #[test] + fn base_path_treats_blank_and_root_as_unprefixed() { + for raw in ["", " ", "/", "//"] { + assert_eq!( + normalize_base_path(raw).expect("valid prefix"), + "", + "{raw:?} should mean serve at the root" + ); + } + } + + #[test] + fn base_path_rejects_values_that_would_break_signed_urls() { + // Each of these would make the server's URL reconstruction differ from + // the client's connect URL, surfacing as an unexplainable 401. + for raw in [ + "/relay?x=1", + "/relay#frag", + "/re lay", + "/relay//inner", + "/relay/../etc", + "/./relay", + ] { + let err = normalize_base_path(raw).expect_err("should be rejected"); + assert!( + matches!(err, ConfigError::InvalidValue(_)), + "{raw:?} should be an InvalidValue error, got {err:?}" + ); + } + } + + #[test] + fn base_path_defaults_to_empty_when_unset() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_BASE_PATH"); + std::env::remove_var("BUZZ_BASE_PATH"); + let config = Config::from_env().expect("default config"); + assert_eq!( + config.base_path, "", + "an unset BUZZ_BASE_PATH must leave routes at the root" + ); + + std::env::set_var("BUZZ_BASE_PATH", "relay/"); + let prefixed = Config::from_env().expect("prefixed config"); + assert_eq!(prefixed.base_path, "/relay"); + + std::env::set_var("BUZZ_BASE_PATH", "/relay?x=1"); + assert!( + Config::from_env().is_err(), + "an invalid prefix must fail startup, not degrade silently" + ); + + match previous { + Some(value) => std::env::set_var("BUZZ_BASE_PATH", value), + None => std::env::remove_var("BUZZ_BASE_PATH"), + } + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); let config = Config::from_env().expect("default config"); + assert_eq!( + config.base_path, "", + "base_path should default to empty (root-mounted)" + ); assert!(config.bind_addr.port() > 0); assert!(!config.database_url.is_empty()); assert!(!config.redis_url.is_empty()); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..ccc0990e9b 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -77,8 +77,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); - let relay_url = - crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); + let relay_url = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &state.config.base_path, + &conn.tenant, + ); let auth_svc = Arc::clone(&state.auth); metrics::counter!("buzz_auth_attempts_total", "method" => "nip42").increment(1); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21..75e93012ae 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -180,9 +180,14 @@ pub async fn relay_info_handler( axum::response::Json(nip11_document(&state, raw_host).await) } +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). Push clients dial the advertised `origin` directly, so a +/// prefixed deployment must advertise the prefix or every push registration +/// lands on a path the gateway does not route. fn push_descriptor( push_configured: bool, relay_url: &str, + base_path: &str, executor_key_id: &str, relay_keypair: &nostr::Keys, tenant_host: Option<&str>, @@ -195,7 +200,7 @@ fn push_descriptor( "ws" }; Some(serde_json::json!({ - "origin": format!("{scheme}://{host}"), + "origin": format!("{scheme}://{host}{base_path}"), "keys": [{ "id": executor_key_id, "pubkey": relay_keypair.public_key().to_hex(), @@ -253,6 +258,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st if let Some(push) = push_descriptor( state.config.push_gateway_delivery_url.is_some(), &state.config.relay_url, + &state.config.base_path, &state.config.push_executor_key_id, &state.relay_keypair, tenant_host.as_deref(), @@ -341,12 +347,19 @@ mod tests { #[test] fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { let keys = nostr::Keys::generate(); - assert!( - push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none() - ); - assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none()); - let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example")) - .expect("configured push descriptor"); + assert!(push_descriptor( + false, + "ws://relay", + "", + "key", + &keys, + Some("tenant.example") + ) + .is_none()); + assert!(push_descriptor(true, "ws://relay", "", "key", &keys, None).is_none()); + let descriptor = + push_descriptor(true, "ws://relay", "", "key", &keys, Some("tenant.example")) + .expect("configured push descriptor"); assert_eq!(descriptor["origin"], "ws://tenant.example"); assert_eq!( descriptor["push_kinds"], @@ -354,6 +367,23 @@ mod tests { ); } + /// Push clients dial the advertised `origin` verbatim, so a relay served + /// under `BUZZ_BASE_PATH` has to advertise the prefix. + #[test] + fn push_descriptor_origin_carries_the_base_path_prefix() { + let keys = nostr::Keys::generate(); + let descriptor = push_descriptor( + true, + "wss://relay", + "/relay", + "key", + &keys, + Some("tenant.example"), + ) + .expect("configured push descriptor"); + assert_eq!(descriptor["origin"], "wss://tenant.example/relay"); + } + #[test] fn supported_nips_includes_nip23_and_nip33() { // Tests the production SUPPORTED_NIPS constant directly — no Config::from_env() diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..2004a4c6ac 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -186,12 +186,47 @@ pub fn build_router(state: Arc) -> Router { merged = merged.fallback_service(spa_fallback); } - merged + // Optional path prefix (`BUZZ_BASE_PATH`). Empty is the default and leaves + // the router exactly as built above. When set, the whole surface nests + // under the prefix so the relay can live behind a gateway that routes by + // path rather than by hostname. + // + // `nest` strips the prefix before the inner router sees the request, so + // every route match, the SPA fallback's `req.uri().path()` checks, and the + // media/git sub-routers keep working against their unprefixed paths. + // + // Health probes stay mounted at the root as well: Kubernetes probes reach + // the pod directly rather than through the gateway that needs the prefix, + // and `deploy/compose` curls `/_liveness` on the published port. + let root_health = Router::new() + .route("/health", get(health_handler)) + .route("/_liveness", get(liveness_handler)) + .route("/_readiness", get(readiness_handler)) + .with_state(state.clone()); + let routed = nest_under_base_path(&state.config.base_path, merged, root_health); + + routed .layer(middleware::from_fn(track_metrics)) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } +/// Mount `router` under `base_path`, keeping `root_extras` reachable at the root. +/// +/// An empty `base_path` returns `router` untouched — the default, and byte-identical +/// to the pre-`BUZZ_BASE_PATH` router. Otherwise everything nests under the prefix +/// so the relay can sit behind a gateway that routes by path rather than hostname. +/// `nest` strips the prefix before the inner router sees the request, so route +/// matches and the SPA fallback's own `req.uri().path()` checks keep working +/// against their unprefixed paths. +fn nest_under_base_path(base_path: &str, router: Router, root_extras: Router) -> Router { + if base_path.is_empty() { + router + } else { + Router::new().nest(base_path, router).merge(root_extras) + } +} + fn http_trace_layer() -> TraceLayer) -> tracing::Span> { TraceLayer::new_for_http().make_span_with(make_http_span as fn(&Request) -> tracing::Span) } @@ -490,6 +525,79 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + /// Build the two-router pair `build_router` hands to [`nest_under_base_path`], + /// standing in for the real relay surface and the root-mounted health probes. + fn base_path_fixture(base_path: &str) -> Router { + // The real surface already carries the health probes (they live in + // `api_router`), so `root_extras` is a deliberate duplicate that only + // matters once the surface moves under a prefix. + let surface = Router::new() + .route("/", get(|| async { "ws-or-nip11" })) + .route("/events", get(|| async { "events" })) + .route("/_liveness", get(|| async { "ok" })); + let root_extras = Router::new().route("/_liveness", get(|| async { "ok" })); + nest_under_base_path(base_path, surface, root_extras) + } + + async fn status_of(router: Router, path: &str) -> StatusCode { + let request = Request::builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + router + .oneshot(request) + .await + .expect("router response") + .status() + } + + #[tokio::test(flavor = "current_thread")] + async fn empty_base_path_leaves_every_route_at_the_root() { + let router = base_path_fixture(""); + assert_eq!(status_of(router.clone(), "/").await, StatusCode::OK); + assert_eq!(status_of(router.clone(), "/events").await, StatusCode::OK); + assert_eq!(status_of(router, "/_liveness").await, StatusCode::OK); + } + + #[tokio::test(flavor = "current_thread")] + async fn base_path_moves_the_surface_under_the_prefix() { + let router = base_path_fixture("/relay"); + // The WebSocket/NIP-11 route answers on the bare prefix, which is what a + // client connecting to wss://host/relay asks for. + assert_eq!(status_of(router.clone(), "/relay").await, StatusCode::OK); + assert_eq!( + status_of(router.clone(), "/relay/events").await, + StatusCode::OK + ); + // Root probes stay reachable: Kubernetes hits the pod directly, bypassing + // the gateway that requires the prefix. + assert_eq!( + status_of(router.clone(), "/_liveness").await, + StatusCode::OK + ); + // Unprefixed application paths no longer resolve — the gateway owns the + // root, and a stale client hitting it should fail loudly, not silently + // reach a half-configured relay. + assert_eq!( + status_of(router.clone(), "/events").await, + StatusCode::NOT_FOUND + ); + assert_eq!(status_of(router, "/other").await, StatusCode::NOT_FOUND); + } + + #[tokio::test(flavor = "current_thread")] + async fn multi_segment_base_path_routes() { + let router = base_path_fixture("/buzz/relay"); + assert_eq!( + status_of(router.clone(), "/buzz/relay").await, + StatusCode::OK + ); + assert_eq!( + status_of(router, "/buzz/relay/events").await, + StatusCode::OK + ); + } + #[tokio::test(flavor = "current_thread")] async fn http_and_datastore_spans_are_exported_in_the_same_trace() { let exporter = InMemorySpanExporter::default(); diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index cebe879da0..b1eb8d0449 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -11,6 +11,10 @@ RELAY_URL=wss://buzz.example.com BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com BUZZ_CORS_ORIGINS=https://buzz.example.com +# Optional URL path prefix, for gateways that route by path rather than by +# hostname (the relay is normally served at the root, so leave this unset). +# When set, RELAY_URL and BUZZ_MEDIA_BASE_URL must carry the same prefix. +# BUZZ_BASE_PATH=/relay # Production defaults. Closed relay mode requires RELAY_OWNER_PUBKEY and a stable relay key. BUZZ_REQUIRE_AUTH_TOKEN=true diff --git a/desktop/src/shared/ui/markdown/mediaEntry.test.mjs b/desktop/src/shared/ui/markdown/mediaEntry.test.mjs index bbd10898be..c8276a50a8 100644 --- a/desktop/src/shared/ui/markdown/mediaEntry.test.mjs +++ b/desktop/src/shared/ui/markdown/mediaEntry.test.mjs @@ -76,6 +76,21 @@ test("isRelayDownloadable: non-/media/ path on the relay is not eligible", () => assert.equal(isRelayDownloadable(`${RELAY}/other/abc.mp4`, RELAY), false); }); +test("isRelayDownloadable: relay served under a base path is eligible", () => { + // BUZZ_BASE_PATH deployments host media at /media/. + assert.equal( + isRelayDownloadable(`${RELAY}/relay/media/abc.mp4`, RELAY), + true, + ); +}); + +test("isRelayDownloadable: near-miss segment under a base path is not eligible", () => { + assert.equal( + isRelayDownloadable(`${RELAY}/relay/media-evil/abc.mp4`, RELAY), + false, + ); +}); + test("isRelayDownloadable: malformed URL is not eligible", () => { assert.equal(isRelayDownloadable("not a url", RELAY), false); }); diff --git a/desktop/src/shared/ui/markdown/mediaEntry.ts b/desktop/src/shared/ui/markdown/mediaEntry.ts index 8cc0160de7..5f90e8909b 100644 --- a/desktop/src/shared/ui/markdown/mediaEntry.ts +++ b/desktop/src/shared/ui/markdown/mediaEntry.ts @@ -60,6 +60,13 @@ export function isVideoMedia(src: string, imetaMime?: string): boolean { * it anyway). Callers must read `relayOrigin` from a reactive source * (`useRelayOrigin`) so eligibility recomputes — and Download appears for a * genuine relay URL — the moment the origin resolves. + * + * The `/media/` segment is matched anywhere in the path rather than only at the + * root, because a relay served under `BUZZ_BASE_PATH` hosts media at + * `/media/`. `relayOrigin` is origin-only by design (it backs + * proxy decisions and is canonicalized for case-insensitive host comparison), + * so the prefix isn't available here to anchor against. Requiring a full + * `/media/` segment still rejects near-misses like `/media-evil/`. */ export function isRelayDownloadable( src: string, @@ -72,6 +79,6 @@ export function isRelayDownloadable( } catch { return false; } - if (!parsed.pathname.startsWith("/media/")) return false; + if (!parsed.pathname.includes("/media/")) return false; return parsed.origin === relayOrigin; } diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index b21eeca36d..3e6661e895 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'relay_endpoint.dart'; import 'relay_provider.dart'; const _mediaGetAuthKind = 24242; @@ -95,7 +96,7 @@ class MediaGetAuthService { if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) { return false; } - return uri.path.startsWith('/media/'); + return isRelayMediaPath(uri.path); } nostr.Event _buildGetAuthEvent(String nsec) { diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 58c93979d7..0a6640f9ae 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -13,6 +13,7 @@ import 'package:pointycastle/digests/sha256.dart'; import 'animated_image_sanitizer.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; +import 'relay_endpoint.dart'; import 'relay_provider.dart'; const _mediaUploadPath = '/upload'; @@ -405,7 +406,10 @@ class MediaUploadService { required String sha256, required String path, }) { - final request = http.Request('PUT', Uri.parse(_baseUrl).resolve(path)); + final request = http.Request( + 'PUT', + Uri.parse(relayEndpoint(_baseUrl, path)), + ); request.bodyBytes = bytes; request.headers.addAll( _buildUploadHeaders(mimeType: mimeType, sha256: sha256), diff --git a/mobile/lib/shared/relay/relay_client.dart b/mobile/lib/shared/relay/relay_client.dart index 453e11459e..bdb0f53c3c 100644 --- a/mobile/lib/shared/relay/relay_client.dart +++ b/mobile/lib/shared/relay/relay_client.dart @@ -1,5 +1,7 @@ import 'package:http/http.dart' as http; +import 'relay_endpoint.dart'; + /// Lightweight HTTP context for talking to the Buzz relay. /// /// In the pure-nostr architecture, all data flow happens over the relay @@ -18,10 +20,7 @@ class RelayClient { http.Client get httpClient => _http; /// Fully-qualified URL for the relay's Blossom-style media upload endpoint. - String get mediaUploadUrl { - final base = Uri.parse(baseUrl); - return base.resolve('/upload').toString(); - } + String get mediaUploadUrl => relayEndpoint(baseUrl, '/upload'); void dispose() => _http.close(); } diff --git a/mobile/lib/shared/relay/relay_endpoint.dart b/mobile/lib/shared/relay/relay_endpoint.dart new file mode 100644 index 0000000000..ee74f6d3b8 --- /dev/null +++ b/mobile/lib/shared/relay/relay_endpoint.dart @@ -0,0 +1,34 @@ +/// Joining relay HTTP endpoint URLs so they survive a base-path deployment. +/// +/// `Uri.resolve('/events')` discards any path the base URL carries, which is +/// correct for an origin-rooted relay but silently wrong for one served under +/// `BUZZ_BASE_PATH` (`https://host/relay`): the request lands on `/events` +/// instead of `/relay/events`. Since the relay's NIP-98 check reconstructs the +/// prefixed URL, a dropped prefix surfaces as a 401 or a 404 rather than +/// anything that names the real cause. +library; + +/// Join `path` onto `baseUrl`, preserving any path prefix the base carries. +/// +/// `path` is treated as relative to the base regardless of whether it has a +/// leading slash, so callers can keep passing the route literals they declare +/// (`/events`, `/media/upload`). +/// +/// ```dart +/// relayEndpoint('https://host', '/query'); // https://host/query +/// relayEndpoint('https://host/relay', '/query'); // https://host/relay/query +/// relayEndpoint('https://host/relay/', 'query'); // https://host/relay/query +/// ``` +String relayEndpoint(String baseUrl, String path) { + final base = baseUrl.replaceAll(RegExp(r'/+$'), ''); + final suffix = path.replaceAll(RegExp(r'^/+'), ''); + return suffix.isEmpty ? base : '$base/$suffix'; +} + +/// Whether `urlPath` addresses the relay's media route. +/// +/// Matches `/media/` as a full path segment anywhere in the path rather than +/// only at the root, because a base-path deployment serves media at +/// `/media/`. Requiring the whole segment still rejects +/// near-misses such as `/media-evil/`. +bool isRelayMediaPath(String urlPath) => urlPath.contains('/media/'); diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 9c167e10b1..716bf5c4db 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -13,6 +13,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; +import 'relay_endpoint.dart'; import 'relay_provider.dart'; import 'relay_socket.dart'; @@ -133,7 +134,7 @@ class RelaySessionNotifier extends Notifier { Duration timeout = const Duration(seconds: 8), }) async { final config = ref.read(relayConfigProvider); - final url = Uri.parse(config.baseUrl).resolve('/query').toString(); + final url = relayEndpoint(config.baseUrl, '/query'); final bodyBytes = utf8.encode( jsonEncode(filters.map((filter) => filter.toJson()).toList()), ); diff --git a/mobile/test/shared/relay/relay_endpoint_test.dart b/mobile/test/shared/relay/relay_endpoint_test.dart new file mode 100644 index 0000000000..be1ddaa0a0 --- /dev/null +++ b/mobile/test/shared/relay/relay_endpoint_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/shared/relay/relay_endpoint.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('relayEndpoint', () { + test('joins onto an origin-rooted relay unchanged', () { + expect(relayEndpoint('https://host', '/query'), 'https://host/query'); + expect( + relayEndpoint('https://host/', '/media/upload'), + 'https://host/media/upload', + ); + }); + + test('preserves a base path so BUZZ_BASE_PATH deployments resolve', () { + expect( + relayEndpoint('https://host/relay', '/query'), + 'https://host/relay/query', + ); + expect( + relayEndpoint('https://host/relay/', 'query'), + 'https://host/relay/query', + ); + expect( + relayEndpoint('https://host/buzz/relay', '/media/upload'), + 'https://host/buzz/relay/media/upload', + ); + }); + + test('treats the path as relative regardless of leading slashes', () { + expect( + relayEndpoint('https://host/relay', 'upload'), + 'https://host/relay/upload', + ); + expect( + relayEndpoint('https://host/relay', '//upload'), + 'https://host/relay/upload', + ); + }); + + test('returns the bare base for an empty path', () { + expect(relayEndpoint('https://host/relay/', '/'), 'https://host/relay'); + }); + }); + + group('isRelayMediaPath', () { + test('accepts the media route at the root and under a prefix', () { + expect(isRelayMediaPath('/media/abc'), isTrue); + expect(isRelayMediaPath('/relay/media/abc'), isTrue); + }); + + test('rejects near-miss segments', () { + expect(isRelayMediaPath('/media-evil/abc'), isFalse); + expect(isRelayMediaPath('/other/abc'), isFalse); + }); + }); +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 6cd601f56b..6ded6fb449 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -50,7 +50,9 @@ void main() { expect(capturedRequest, isNotNull); expect(capturedRequest!.method, 'POST'); - expect(capturedRequest!.url.toString(), 'https://relay.example/query'); + // The fixture's base URL carries a path (a BUZZ_BASE_PATH deployment), so + // the bridge call must land under it rather than at the origin root. + expect(capturedRequest!.url.toString(), 'https://relay.example/base/query'); expect(capturedRequest!.headers['Content-Type'], 'application/json'); expect(jsonDecode(capturedRequest!.body), [filter.toJson()]); @@ -70,9 +72,11 @@ void main() { expect(authEvent['kind'], 27235); expect(authEvent['pubkey'], keychain.public); + // The signed `u` tag must carry the base path as well: the relay + // reconstructs the prefixed URL, so a bare-origin tag would 401. expect( tags, - anyElement(equals(['u', 'https://relay.example/query'])), + anyElement(equals(['u', 'https://relay.example/base/query'])), ); expect(tags, anyElement(equals(['method', 'POST']))); expect(tags, anyElement(equals(['payload', payloadHash]))); From cb8d117b629261d53ab317169ff6dd0a92133e25 Mon Sep 17 00:00:00 2001 From: Kyler Cao Date: Wed, 29 Jul 2026 09:22:53 -0500 Subject: [PATCH 2/3] fix(desktop): accept invite URLs from a relay served under a base path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseInviteInput anchored the invite path to the origin root and rebuilt the relay URL from url.host alone. Against a relay served under BUZZ_BASE_PATH both halves fail: https://host/relay/invite/ does not match the regex and is rejected as a non-invite URL, and even when matched the derived relay URL would drop the prefix and the client would dial a relay that is not there. Captures an optional base path before /invite/ and carries it into the derived ws(s):// URL. Found in the field — an invite link generated by a prefixed deployment 404s for the recipient. Signed-off-by: Kyler Cao --- desktop/src/shared/api/inviteHelpers.ts | 20 ++++++--- .../src/shared/api/parseInviteInput.test.mjs | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/desktop/src/shared/api/inviteHelpers.ts b/desktop/src/shared/api/inviteHelpers.ts index 2b5efe4e94..5fa15ce1cc 100644 --- a/desktop/src/shared/api/inviteHelpers.ts +++ b/desktop/src/shared/api/inviteHelpers.ts @@ -18,6 +18,7 @@ export type ParsedInvite = * Accepted input forms: * - `https:///invite/` → `{ relayWsUrl: "wss://", code }` * - `http:///invite/` → `{ relayWsUrl: "ws://", code }` + * - `https:////invite/` → `{ relayWsUrl: "wss:///", code }` * - `buzz://join?relay=&code=` → `{ relayWsUrl, code }` * - bare code (no `://`, no `/`) → `{ code }` * @@ -52,16 +53,21 @@ export function parseInviteInput(input: string): ParsedInvite | null { return { relayWsUrl: relay, code }; } - // https(s):///invite/ + // https(s)://[/]/invite/ if (url.protocol === "https:" || url.protocol === "http:") { if (url.username || url.password || url.hash) return null; - // pathname must be /invite/ with optional single trailing slash - const match = url.pathname.match(/^\/invite\/([^/]+)\/?$/); - if (!match?.[1]) return null; - const code = decodeURIComponent(match[1]); + // `/invite/` may be preceded by a base path when the relay is served + // under one (BUZZ_BASE_PATH), e.g. https://host/relay/invite/. The + // prefix is captured so it can be carried into the derived relay URL — + // rebuilding from `url.host` alone would drop it and the client would then + // dial a relay that isn't there. + const match = url.pathname.match(/^(\/.*?)?\/invite\/([^/]+)\/?$/); + if (!match?.[2]) return null; + const basePath = match[1] ?? ""; + const code = decodeURIComponent(match[2]); // Convert scheme: https → wss, http → ws. url.host already includes port. - const relayWsUrl = - url.protocol === "https:" ? `wss://${url.host}` : `ws://${url.host}`; + const scheme = url.protocol === "https:" ? "wss" : "ws"; + const relayWsUrl = `${scheme}://${url.host}${basePath}`; return { relayWsUrl, code }; } diff --git a/desktop/src/shared/api/parseInviteInput.test.mjs b/desktop/src/shared/api/parseInviteInput.test.mjs index 858f725880..a9988afd3f 100644 --- a/desktop/src/shared/api/parseInviteInput.test.mjs +++ b/desktop/src/shared/api/parseInviteInput.test.mjs @@ -28,6 +28,51 @@ test("parseInviteInput_http_invite_url_returns_ws_relay_and_code", () => { }); }); +// --------------------------------------------------------------------------- +// Base-path deployments (BUZZ_BASE_PATH): the prefix must survive into the +// derived relay URL, or the client dials a relay that isn't there. +// --------------------------------------------------------------------------- + +test("parseInviteInput_base_path_invite_url_keeps_prefix_in_relay_url", () => { + const result = parseInviteInput( + "https://relay.example.com/relay/invite/abc123", + ); + assert.deepEqual(result, { + relayWsUrl: "wss://relay.example.com/relay", + code: "abc123", + }); +}); + +test("parseInviteInput_multi_segment_base_path_keeps_full_prefix", () => { + const result = parseInviteInput( + "https://relay.example.com/buzz/relay/invite/abc123", + ); + assert.deepEqual(result, { + relayWsUrl: "wss://relay.example.com/buzz/relay", + code: "abc123", + }); +}); + +test("parseInviteInput_base_path_invite_url_over_http_uses_ws", () => { + const result = parseInviteInput( + "http://relay.example.com:3000/relay/invite/abc123", + ); + assert.deepEqual(result, { + relayWsUrl: "ws://relay.example.com:3000/relay", + code: "abc123", + }); +}); + +test("parseInviteInput_base_path_invite_url_tolerates_trailing_slash", () => { + const result = parseInviteInput( + "https://relay.example.com/relay/invite/abc123/", + ); + assert.deepEqual(result, { + relayWsUrl: "wss://relay.example.com/relay", + code: "abc123", + }); +}); + test("parseInviteInput_https_with_port_preserves_port", () => { const result = parseInviteInput( "https://relay.example.com:8443/invite/abc123", From 24f2e17b47332c39f9cca544827e4aa22561717e Mon Sep 17 00:00:00 2001 From: Kyler Cao Date: Wed, 29 Jul 2026 12:17:23 -0500 Subject: [PATCH 3/3] fix(relay): advertise media under the base path in upload descriptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media_base_url_for_tenant built every descriptor URL as {scheme}://{tenant_host}/media, ignoring the base path. On a relay served under BUZZ_BASE_PATH that produces an unreachable URL — and behind a path-routing gateway it resolves to the gateway rather than the relay, so fetches land on an SSO redirect instead of the file. This is the worst of the URL-rebuilding sites because the result is embedded in published events: every upload bakes a broken link into message history, and agents reading those messages report it as an auth problem, which it is not. Also relaxes two client-side root anchors so prefixed media is recognised: - desktop RELAY_MEDIA_RE, which decides whether to route media through the authenticated local proxy; prefixed URLs looked external and skipped it - the Tauri download validator's /media/ path check, still origin-pinned Found by running a prefixed deployment: two agents failed to fetch attachments and a direct curl confirmed the 302. Signed-off-by: Kyler Cao --- crates/buzz-relay/src/api/media.rs | 77 +++++++++++++++++-- crates/buzz-relay/src/handlers/ingest.rs | 7 +- .../src/handlers/product_feedback.rs | 7 +- .../src-tauri/src/commands/media_download.rs | 36 ++++++++- desktop/src/shared/lib/mediaUrl.test.mjs | 27 +++++++ desktop/src/shared/lib/mediaUrl.ts | 5 +- 6 files changed, 147 insertions(+), 12 deletions(-) diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..a7478c7d91 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -402,6 +402,7 @@ pub async fn upload_blob( rewrite_descriptor_urls_for_tenant( &mut descriptor, &state.config.relay_url, + &state.config.base_path, auth.tenant.host(), ); @@ -444,7 +445,17 @@ pub async fn upload_blob( Ok(Json(descriptor)) } -pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &str) -> String { +/// Build the tenant-scoped media base URL that upload descriptors advertise. +/// +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). The prefix must be present: this URL is what clients and +/// agents fetch, and it is embedded in published events, so a missing prefix +/// bakes an unreachable URL into message history rather than failing loudly. +pub(crate) fn media_base_url_for_tenant( + config_relay_url: &str, + base_path: &str, + tenant_host: &str, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") || config_relay_url.trim_start().starts_with("https://") { @@ -452,15 +463,16 @@ pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &st } else { "http" }; - format!("{scheme}://{tenant_host}/media") + format!("{scheme}://{tenant_host}{base_path}/media") } fn rewrite_descriptor_urls_for_tenant( descriptor: &mut BlobDescriptor, config_relay_url: &str, + base_path: &str, tenant_host: &str, ) { - let base = media_base_url_for_tenant(config_relay_url, tenant_host); + let base = media_base_url_for_tenant(config_relay_url, base_path, tenant_host); let ext = descriptor .url .rsplit_once('.') @@ -1271,15 +1283,69 @@ mod tests { #[test] fn media_base_url_for_tenant_uses_tenant_host_and_http_scheme() { assert_eq!( - media_base_url_for_tenant("wss://config.example", "tenant-b.example"), + media_base_url_for_tenant("wss://config.example", "", "tenant-b.example"), "https://tenant-b.example/media" ); assert_eq!( - media_base_url_for_tenant("ws://config.example", "localhost:3100"), + media_base_url_for_tenant("ws://config.example", "", "localhost:3100"), "http://localhost:3100/media" ); } + /// A relay served under BUZZ_BASE_PATH must advertise media under the prefix. + /// This URL is embedded in published events and fetched by clients and agents, + /// so dropping the prefix bakes an unreachable URL into message history — and + /// behind a path-routing gateway it resolves to the gateway, not the relay. + #[test] + fn media_base_url_for_tenant_includes_the_base_path_prefix() { + assert_eq!( + media_base_url_for_tenant("wss://config.example", "/relay", "tenant-b.example"), + "https://tenant-b.example/relay/media" + ); + assert_eq!( + media_base_url_for_tenant("wss://config.example", "/buzz/relay", "tenant-b.example"), + "https://tenant-b.example/buzz/relay/media" + ); + assert_eq!( + media_base_url_for_tenant("ws://config.example", "/relay", "localhost:3100"), + "http://localhost:3100/relay/media" + ); + } + + #[test] + fn rewrite_descriptor_urls_for_tenant_applies_the_base_path_prefix() { + let hash = "b".repeat(64); + let mut descriptor = BlobDescriptor { + url: format!("https://primary.example/media/{hash}.png"), + sha256: hash.clone(), + size: 7, + mime_type: "image/png".to_string(), + uploaded: 1700000000, + dim: None, + blurhash: None, + thumb: Some(format!("https://primary.example/media/{hash}.thumb.jpg")), + duration: None, + }; + + rewrite_descriptor_urls_for_tenant( + &mut descriptor, + "wss://primary.example", + "/relay", + "tenant-b.example", + ); + + assert_eq!( + descriptor.url, + format!("https://tenant-b.example/relay/media/{hash}.png") + ); + assert_eq!( + descriptor.thumb, + Some(format!( + "https://tenant-b.example/relay/media/{hash}.thumb.jpg" + )) + ); + } + #[test] fn rewrite_descriptor_urls_for_tenant_replaces_global_media_host() { let hash = "a".repeat(64); @@ -1298,6 +1364,7 @@ mod tests { rewrite_descriptor_urls_for_tenant( &mut descriptor, "wss://primary.example", + "", "tenant-b.example", ); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index a30b0e714d..1457cb64b2 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2253,8 +2253,11 @@ async fn ingest_event_inner( .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) .collect(); if !imeta_tags.is_empty() { - let tenant_media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + let tenant_media_base = crate::api::media::media_base_url_for_tenant( + &state.config.relay_url, + &state.config.base_path, + tenant.host(), + ); crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage) diff --git a/crates/buzz-relay/src/handlers/product_feedback.rs b/crates/buzz-relay/src/handlers/product_feedback.rs index 92d045e194..c11b1f57c2 100644 --- a/crates/buzz-relay/src/handlers/product_feedback.rs +++ b/crates/buzz-relay/src/handlers/product_feedback.rs @@ -27,8 +27,11 @@ pub async fn handle( .map(|tag| tag.as_slice().iter().map(ToString::to_string).collect()) .collect::>>(); if !imeta_tags.is_empty() { - let media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + let media_base = crate::api::media::media_base_url_for_tenant( + &state.config.relay_url, + &state.config.base_path, + tenant.host(), + ); crate::api::validate_imeta_tags(&imeta_tags, &media_base)?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage).await?; } diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 016865878e..b2b1a4e8d9 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -49,9 +49,13 @@ fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { return Err("download URL must match the relay origin".to_string()); } - // Path must be /media/{filename}. + // Path must contain a /media/{filename} segment. The segment may be preceded + // by a base path when the relay is served under one (BUZZ_BASE_PATH), e.g. + // /relay/media/.png. Requiring the full segment still rejects + // near-misses such as /media-evil/, and the origin check above already + // pins the request to the relay. let path = parsed.path(); - if !path.starts_with("/media/") { + if !path.contains("/media/") { return Err("download URL must be a /media/ path".to_string()); } @@ -788,6 +792,34 @@ mod tests { ); } + #[test] + fn test_validate_download_url_accepts_base_path_prefixed_media() { + // A relay served under BUZZ_BASE_PATH hosts media at /media/. + assert!(validate_download_url( + "https://relay.example.com/relay/media/abc123.png", + RELAY_BASE, + ) + .is_ok()); + assert!(validate_download_url( + "https://relay.example.com/buzz/relay/media/abc123.png", + RELAY_BASE, + ) + .is_ok()); + } + + #[test] + fn test_validate_download_url_rejects_media_lookalike_segment() { + // The full /media/ segment is still required, prefix or not. + let result = + validate_download_url("https://relay.example.com/media-evil/abc123.png", RELAY_BASE); + assert!(result.is_err()); + let prefixed = validate_download_url( + "https://relay.example.com/relay/media-evil/abc123.png", + RELAY_BASE, + ); + assert!(prefixed.is_err()); + } + #[test] fn test_validate_download_url_non_relay_origin_rejected() { let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs index 9a293e8b3e..c918244372 100644 --- a/desktop/src/shared/lib/mediaUrl.test.mjs +++ b/desktop/src/shared/lib/mediaUrl.test.mjs @@ -3,6 +3,7 @@ import { test } from "node:test"; import { beginRelayOriginFetch, + rewriteRelayUrl, getCachedRelayOrigin, mediaProxyUrl, resetMediaCaches, @@ -20,6 +21,32 @@ function deferred() { return { promise, resolve }; } +const PREFIX_HASH = "a".repeat(64); + +test("rewriteRelayUrl: recognises relay media under a base path", () => { + // BUZZ_BASE_PATH deployments serve media at /media/. Without a + // proxy port cached, a recognised relay URL falls back to buzz-media://; an + // unrecognised one is returned unchanged, which is how we detect the match. + resetMediaCaches(); + const prefixed = `https://relay.example.com/relay/media/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(prefixed), `buzz-media://localhost/media/${PREFIX_HASH}.png`); + + const multi = `https://relay.example.com/buzz/relay/media/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(multi), `buzz-media://localhost/media/${PREFIX_HASH}.png`); +}); + +test("rewriteRelayUrl: root-hosted relay media still recognised", () => { + resetMediaCaches(); + const root = `https://relay.example.com/media/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(root), `buzz-media://localhost/media/${PREFIX_HASH}.png`); +}); + +test("rewriteRelayUrl: a media-lookalike segment is left alone", () => { + resetMediaCaches(); + const evil = `https://relay.example.com/relay/media-evil/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(evil), evil); +}); + test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", () => { assert.equal( mediaProxyUrl(54321, `${HASH}.png`), diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index 8875920ef2..de58251cf3 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -19,8 +19,11 @@ import { invoke } from "@tauri-apps/api/core"; // Matches: https://anything.com/media/{64-hex}.{ext} // Also matches thumbnails: /media/{64-hex}.thumb.jpg +// An optional path prefix before /media/ is allowed so relays served under +// BUZZ_BASE_PATH (https://host/relay/media/...) are still recognised as relay +// media; without it their URLs look external and skip the authenticated proxy. const RELAY_MEDIA_RE = - /^(?:https?:\/\/[^/]+)\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp|mp4|webm|mov)(?:\?.*)?)$/; + /^(?:https?:\/\/[^/]+)(?:\/.*?)?\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp|mp4|webm|mov)(?:\?.*)?)$/; /** Cached proxy port — fetched once from the Tauri backend. */ let cachedPort: number | null = null;