mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Tighten enterprise login gate validation
Signed-off-by: coder 1 <a93f3b1decd199cec83848f116ff60c20776cdd03861b9bba2610acae7c9eeeb@buzz.block.builderlab.xyz> Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz> Signed-off-by: bb-expert <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<String>, 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<bool, String> {
|
||||
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<String, String> {
|
||||
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!(
|
||||
|
||||
56
desktop/src-tauri/src/commands/enterprise_relay_url.rs
Normal file
56
desktop/src-tauri/src/commands/enterprise_relay_url.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use url::Url;
|
||||
|
||||
pub(crate) fn parse_enterprise_relay_allowlist(raw: &str) -> Result<Vec<String>, 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<String, String> {
|
||||
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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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("<!doctype html><html><body></body></html>", {
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user