From 31ea152d74f976b81f6676f00d64b687e3dc169b Mon Sep 17 00:00:00 2001 From: coder 1 Date: Thu, 17 Sep 2026 13:43:28 -0400 Subject: [PATCH] Tighten enterprise login gate validation Signed-off-by: coder 1 Signed-off-by: coder 0 Signed-off-by: bb-expert <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz> --- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/build.rs | 20 +-- .../src-tauri/src/commands/enterprise_auth.rs | 86 ++++++++----- .../src/commands/enterprise_relay_url.rs | 56 +++++++++ desktop/src-tauri/src/commands/mod.rs | 1 + .../communities/useCommunityInit.test.mjs | 116 +++++++++++++++++- 6 files changed, 232 insertions(+), 48 deletions(-) create mode 100644 desktop/src-tauri/src/commands/enterprise_relay_url.rs diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index ab77581c14..ced8781b29 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-build = { version = "2", features = [] } +url = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 7486c943fd..cc6f1710b2 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,6 +1,7 @@ // Shared schema, included from the same source the runtime command parses with, // so the build-time validation below and the runtime parse cannot drift. include!("src/commands/reconnect_hook_config.rs"); +include!("src/commands/enterprise_relay_url.rs"); // Same source of truth the runtime filters with, so a baked build env cannot // carry a reserved key the runtime believes it already rejected. include!("src/managed_agents/reserved_env_keys.rs"); @@ -128,24 +129,7 @@ fn main() { if let Ok(relays) = std::env::var("BUZZ_BUILD_ENTERPRISE_AUTH_RELAYS") { let trimmed = relays.trim(); - if trimmed.is_empty() { - panic!("BUZZ_BUILD_ENTERPRISE_AUTH_RELAYS must not be empty when set"); - } - for relay in trimmed - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if !(relay.starts_with("wss://") - || relay.starts_with("ws://") - || relay.starts_with("https://") - || relay.starts_with("http://")) - { - panic!( - "BUZZ_BUILD_ENTERPRISE_AUTH_RELAYS entries must be relay URLs, got {relay:?}" - ); - } - } + parse_enterprise_relay_allowlist(trimmed).unwrap_or_else(|error| panic!("{error}")); println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_ENTERPRISE_AUTH_RELAYS={trimmed}"); } diff --git a/desktop/src-tauri/src/commands/enterprise_auth.rs b/desktop/src-tauri/src/commands/enterprise_auth.rs index 776c3685b0..eb5fd226c8 100644 --- a/desktop/src-tauri/src/commands/enterprise_auth.rs +++ b/desktop/src-tauri/src/commands/enterprise_auth.rs @@ -1,13 +1,11 @@ use std::time::Duration; -use serde::Serialize; -use tauri::State; -use url::Url; - use crate::{ app_state::AppState, relay::{classify_request_error, parse_json_response, relay_error_message}, }; +use serde::Serialize; +use tauri::State; #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", tag = "status")] @@ -141,40 +139,17 @@ fn trusted_enterprise_relays(raw: Option<&'static str>) -> Result, S let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(Vec::new()); }; - raw.split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(canonical_relay_url) - .collect() + super::enterprise_relay_url::parse_enterprise_relay_allowlist(raw) } fn relay_matches_any_trusted(relay_url: &str, trusted: &[String]) -> Result { if trusted.is_empty() { return Ok(false); } - let relay = canonical_relay_url(relay_url)?; + let relay = super::enterprise_relay_url::canonical_enterprise_relay_url(relay_url)?; Ok(trusted.iter().any(|trusted| trusted == &relay)) } -fn canonical_relay_url(raw: &str) -> Result { - let mut url = Url::parse(raw.trim()).map_err(|error| format!("invalid relay URL: {error}"))?; - let scheme = match url.scheme() { - "ws" | "http" => "ws", - "wss" | "https" => "wss", - other => return Err(format!("unsupported relay URL scheme: {other}")), - }; - url.set_scheme(scheme) - .map_err(|_| "could not normalize relay URL scheme".to_owned())?; - if url.host_str().is_none() { - return Err("relay URL must include a host".to_owned()); - } - url.set_fragment(None); - url.set_query(None); - let path = url.path().trim_end_matches('/').to_owned(); - url.set_path(&path); - Ok(url.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -205,6 +180,59 @@ mod tests { ); } + #[test] + fn default_ports_are_canonicalized_for_trusted_matching() { + assert_eq!( + evaluate_enterprise_login_gate( + "https://buzz.block.builderlab.xyz/", + &discovery_document(), + Some("wss://buzz.block.builderlab.xyz:443"), + ) + .unwrap(), + EnterpriseLoginGateStatus::Required, + ); + assert_eq!( + evaluate_enterprise_login_gate( + "http://localhost/", + &discovery_document(), + Some("ws://localhost:80"), + ) + .unwrap(), + EnterpriseLoginGateStatus::Required, + ); + } + + #[test] + fn trusted_allowlist_rejects_empty_entries() { + let error = trusted_enterprise_relays(Some("wss://relay.example,")) + .expect_err("empty entries must fail closed"); + assert!(error.contains("entry 2 must not be empty")); + } + + #[test] + fn trusted_allowlist_rejects_userinfo_query_and_fragment() { + for relay in [ + "wss://user@relay.example", + "wss://relay.example?env=prod", + "wss://relay.example#prod", + ] { + assert!( + trusted_enterprise_relays(Some(relay)).is_err(), + "{relay} should be rejected", + ); + } + } + + #[test] + fn trusted_allowlist_rejects_missing_host_and_unsupported_scheme() { + for relay in ["wss://", "file://relay.example"] { + assert!( + trusted_enterprise_relays(Some(relay)).is_err(), + "{relay} should be rejected", + ); + } + } + #[test] fn ordinary_relay_without_discovery_is_not_required() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/enterprise_relay_url.rs b/desktop/src-tauri/src/commands/enterprise_relay_url.rs new file mode 100644 index 0000000000..2f1976c436 --- /dev/null +++ b/desktop/src-tauri/src/commands/enterprise_relay_url.rs @@ -0,0 +1,56 @@ +use url::Url; + +pub(crate) fn parse_enterprise_relay_allowlist(raw: &str) -> Result, String> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("BUZZ_BUILD_ENTERPRISE_AUTH_RELAYS must not be empty when set".to_owned()); + } + + trimmed + .split(',') + .enumerate() + .map(|(index, relay)| { + let relay = relay.trim(); + if relay.is_empty() { + return Err(format!( + "BUZZ_BUILD_ENTERPRISE_AUTH_RELAYS entry {} must not be empty", + index + 1 + )); + } + canonical_enterprise_relay_url(relay) + }) + .collect() +} + +pub(crate) fn canonical_enterprise_relay_url(raw: &str) -> Result { + let mut url = Url::parse(raw.trim()).map_err(|error| format!("invalid relay URL: {error}"))?; + let scheme = match url.scheme() { + "ws" | "http" => "ws", + "wss" | "https" => "wss", + other => return Err(format!("unsupported relay URL scheme: {other}")), + }; + url.set_scheme(scheme) + .map_err(|_| "could not normalize relay URL scheme".to_owned())?; + + if url.host_str().is_none() { + return Err("relay URL must include a host".to_owned()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("relay URL must not include userinfo".to_owned()); + } + if url.query().is_some() { + return Err("relay URL must not include a query string".to_owned()); + } + if url.fragment().is_some() { + return Err("relay URL must not include a fragment".to_owned()); + } + + if matches!((scheme, url.port()), ("ws", Some(80)) | ("wss", Some(443))) { + url.set_port(None) + .map_err(|_| "could not normalize relay URL port".to_owned())?; + } + + let path = url.path().trim_end_matches('/').to_owned(); + url.set_path(&path); + Ok(url.to_string()) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1bca27a738..ac450b7c75 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -22,6 +22,7 @@ mod clipboard; mod dms; mod engrams; mod enterprise_auth; +mod enterprise_relay_url; mod export_util; mod global_agent_config; mod identity; diff --git a/desktop/src/features/communities/useCommunityInit.test.mjs b/desktop/src/features/communities/useCommunityInit.test.mjs index 046d86437e..8152a69524 100644 --- a/desktop/src/features/communities/useCommunityInit.test.mjs +++ b/desktop/src/features/communities/useCommunityInit.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { after, afterEach, before, test } from "node:test"; +import { after, afterEach, before, mock, test } from "node:test"; import { JSDOM } from "jsdom"; const dom = new JSDOM("", { @@ -16,6 +16,7 @@ before(async () => { Object.assign(globalThis, { window: dom.window, document: dom.window.document, + HTMLElement: dom.window.HTMLElement, localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true, }); @@ -176,3 +177,116 @@ for (const failingUpdate of [0, 1]) { assert.equal(pendingTrust.length, failingUpdate + 1); }); } + +function installTauriInvoke(handler) { + const previous = globalThis.window.__TAURI_INTERNALS__; + globalThis.window.__TAURI_INTERNALS__ = { invoke: handler }; + globalThis.__TAURI_INTERNALS__ = globalThis.window.__TAURI_INTERNALS__; + return () => { + if (previous === undefined) { + delete globalThis.window.__TAURI_INTERNALS__; + delete globalThis.__TAURI_INTERNALS__; + } else { + globalThis.window.__TAURI_INTERNALS__ = previous; + globalThis.__TAURI_INTERNALS__ = previous; + } + }; +} + +function testCommunity() { + return { + id: "community-1", + name: "Enterprise", + relayUrl: "wss://enterprise.example", + token: "token-1", + reposDir: "/tmp/buzz-repos", + addedAt: "2026-09-17T00:00:00.000Z", + }; +} + +test("useCommunityInit gates enterprise login before applying the community", async () => { + const calls = []; + const restore = installTauriInvoke(async (command, args) => { + calls.push([command, args]); + if (command === "get_identity") { + return { pubkey: "pubkey-1", display_name: "Tester" }; + } + if (command === "enterprise_login_gate") { + return { status: "notRequired" }; + } + if (command === "apply_workspace") { + return null; + } + throw new Error(`unexpected command: ${command}`); + }); + + try { + const community = testCommunity(); + const hook = renderHook(() => + useCommunityInit(community, "community-key", false, true), + ); + + await waitFor(() => assert.equal(hook.result.current.isReady, true)); + + assert.deepEqual( + calls.map(([command]) => command), + ["get_identity", "enterprise_login_gate", "apply_workspace"], + ); + assert.deepEqual(calls[1], [ + "enterprise_login_gate", + { relayUrl: community.relayUrl }, + ]); + assert.deepEqual(calls[2], [ + "apply_workspace", + { + relayUrl: community.relayUrl, + nsec: null, + token: community.token, + reposDir: community.reposDir, + agentManagedProfiles: false, + }, + ]); + hook.unmount(); + } finally { + restore(); + mock.reset(); + } +}); + +test("useCommunityInit blocks community apply when enterprise login gate fails", async () => { + const consoleError = mock.method(console, "error", () => {}); + const calls = []; + const restore = installTauriInvoke(async (command, args) => { + calls.push([command, args]); + if (command === "get_identity") { + return { pubkey: "pubkey-1", display_name: "Tester" }; + } + if (command === "enterprise_login_gate") { + throw new Error("enterprise login unavailable"); + } + if (command === "apply_workspace") { + throw new Error("apply_workspace must not run"); + } + throw new Error(`unexpected command: ${command}`); + }); + + try { + const hook = renderHook(() => + useCommunityInit(testCommunity(), "community-key", false, true), + ); + + await waitFor(() => + assert.equal(hook.result.current.error, "enterprise login unavailable"), + ); + + assert.deepEqual( + calls.map(([command]) => command), + ["get_identity", "enterprise_login_gate"], + ); + assert.equal(consoleError.mock.calls.length, 1); + hook.unmount(); + } finally { + restore(); + mock.reset(); + } +});