diff --git a/bun.lock b/bun.lock index 9ec4e195..b9c3d3fe 100644 --- a/bun.lock +++ b/bun.lock @@ -232,6 +232,8 @@ "openchamber": "./bin/cli.js", }, "dependencies": { + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-go": "^6.0.1", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index b00f1779..73376c12 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1082,9 +1082,37 @@ fn normalize_host_url(raw: &str) -> Option { normalized.push(':'); normalized.push_str(&port.to_string()); } + let path = parsed.path(); + if path.is_empty() { + normalized.push('/'); + } else { + normalized.push_str(path); + } + if let Some(query) = parsed.query() { + normalized.push('?'); + normalized.push_str(query); + } Some(normalized) } +fn sanitize_host_url_for_storage(raw: &str) -> Option { + normalize_host_url(raw) +} + +fn build_health_url(base_url: &str) -> Option { + let normalized = normalize_host_url(base_url)?; + let mut parsed = url::Url::parse(&normalized).ok()?; + let current_path = parsed.path(); + let trimmed_path = current_path.trim_end_matches('/'); + let health_path = if trimmed_path.is_empty() { + "/health".to_string() + } else { + format!("{trimmed_path}/health") + }; + parsed.set_path(&health_path); + Some(parsed.to_string()) +} + fn settings_file_path() -> PathBuf { if let Ok(dir) = env::var("OPENCHAMBER_DATA_DIR") { if !dir.trim().is_empty() { @@ -1134,7 +1162,10 @@ fn write_desktop_local_port_to_disk(port: u16) -> Result<()> { fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig { - let path = settings_file_path(); + read_desktop_hosts_config_from_path(&settings_file_path()) +} + +fn read_desktop_hosts_config_from_path(path: &Path) -> DesktopHostsConfig { let raw = fs::read_to_string(path).ok(); let parsed = raw .as_deref() @@ -1158,7 +1189,7 @@ fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig { if host.id.trim().is_empty() || host.id == LOCAL_HOST_ID { continue; } - if let Some(url) = normalize_host_url(&host.url) { + if let Some(url) = sanitize_host_url_for_storage(&host.url) { hosts.push(DesktopHost { id: host.id, label: if host.label.trim().is_empty() { @@ -1215,7 +1246,10 @@ fn write_desktop_window_state_to_disk(state: &DesktopWindowState) -> Result<()> } fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> { - let path = settings_file_path(); + write_desktop_hosts_config_to_path(&settings_file_path(), config) +} + +fn write_desktop_hosts_config_to_path(path: &Path, config: &DesktopHostsConfig) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -1238,7 +1272,7 @@ fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> if id.is_empty() || id == LOCAL_HOST_ID { return None; } - let url = normalize_host_url(&h.url)?; + let url = sanitize_host_url_for_storage(&h.url)?; Some(DesktopHost { id: id.to_string(), label: if h.label.trim().is_empty() { @@ -1281,8 +1315,7 @@ struct HostProbeResult { #[tauri::command] async fn desktop_host_probe(url: String) -> Result { - let normalized = normalize_host_url(&url).ok_or_else(|| "Invalid URL".to_string())?; - let health = format!("{}/health", normalized.trim_end_matches('/')); + let health = build_health_url(&url).ok_or_else(|| "Invalid URL".to_string())?; let client = reqwest::Client::builder() .no_proxy() .timeout(Duration::from_secs(2)) @@ -1782,21 +1815,7 @@ fn resolve_web_dist_dir(app: &tauri::AppHandle) -> Result { } fn normalize_server_url(input: &str) -> Option { - let trimmed = input.trim(); - if trimmed.is_empty() { - return None; - } - - match url::Url::parse(trimmed) { - Ok(url) => { - if url.scheme() == "http" || url.scheme() == "https" { - Some(trimmed.trim_end_matches('/').to_string()) - } else { - None - } - } - Err(_) => None, - } + normalize_host_url(input) } #[derive(Deserialize)] @@ -2690,3 +2709,55 @@ fn main() { } }); } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_settings_path(test_name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock drift") + .as_nanos(); + std::env::temp_dir().join(format!("openchamber-{test_name}-{nanos}-settings.json")) + } + + #[test] + fn sanitize_host_url_for_storage_keeps_query_params() { + let input = "https://example.com?coder_session_token=xxxxxx"; + let sanitized = sanitize_host_url_for_storage(input).expect("sanitized url"); + assert_eq!(sanitized, "https://example.com/?coder_session_token=xxxxxx"); + } + + #[test] + fn sanitize_host_url_for_storage_strips_fragment_and_keeps_query() { + let input = "https://example.com/workspace?coder_session_token=xxxxxx#ignored"; + let sanitized = sanitize_host_url_for_storage(input).expect("sanitized url"); + assert_eq!(sanitized, "https://example.com/workspace?coder_session_token=xxxxxx"); + } + + #[test] + fn write_and_read_hosts_config_preserves_query_params() { + let path = unique_settings_path("desktop-hosts-query"); + let config = DesktopHostsConfig { + hosts: vec![DesktopHost { + id: "remote-1".to_string(), + label: "Remote".to_string(), + url: "https://example.com?coder_session_token=xxxxxx".to_string(), + }], + default_host_id: Some("remote-1".to_string()), + }; + + write_desktop_hosts_config_to_path(&path, &config).expect("write config"); + let read_back = read_desktop_hosts_config_from_path(&path); + let _ = fs::remove_file(&path); + + assert_eq!(read_back.hosts.len(), 1); + assert_eq!( + read_back.hosts[0].url, + "https://example.com/?coder_session_token=xxxxxx" + ); + assert_eq!(read_back.default_host_id.as_deref(), Some("remote-1")); + } +} diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 8eca3081..48134a13 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -39,6 +39,9 @@ import { desktopHostsGet, desktopHostsSet, desktopOpenNewWindowAtUrl, + locationMatchesHost, + normalizeHostUrl, + redactSensitiveUrl, type DesktopHost, type HostProbeResult, } from '@/lib/desktopHosts'; @@ -50,33 +53,21 @@ type HostStatus = { latencyMs: number; }; -const normalizeHostUrl = (raw: string): string | null => { - const trimmed = raw.trim(); - if (!trimmed) return null; - try { - const url = new URL(trimmed); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return null; - } - return url.origin; - } catch { - // Tauri/WebKit edge: accept origin without trailing slash. - try { - const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return null; - } - return url.origin; - } catch { - return null; - } +const toNavigationUrl = (rawUrl: string): string => { + const normalized = normalizeHostUrl(rawUrl); + if (!normalized) { + return rawUrl.trim(); } -}; -const toNavigationUrl = (origin: string): string => { - const trimmed = origin.trim(); - if (!trimmed) return trimmed; - return trimmed.endsWith('/') ? trimmed : `${trimmed}/`; + try { + const url = new URL(normalized); + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/`; + } + return url.toString(); + } catch { + return normalized; + } }; const getLocalOrigin = (): string => { @@ -119,18 +110,17 @@ const buildLocalHost = (): DesktopHost => ({ }); const resolveCurrentHost = (hosts: DesktopHost[]) => { - const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin; + const currentHref = typeof window === 'undefined' ? '' : window.location.href; const localOrigin = getLocalOrigin(); - const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin; const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin; + const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref; - if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) { + if (currentHref && locationMatchesHost(currentHref, localOrigin)) { return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; } const match = hosts.find((h) => { - const normalized = normalizeHostUrl(h.url); - return normalized && normalized === normalizedCurrent; + return currentHref ? locationMatchesHost(currentHref, h.url) : false; }); if (match) { @@ -139,7 +129,7 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => { return { id: 'custom', - label: normalizedCurrent || 'Instance', + label: redactSensitiveUrl(normalizedCurrent || 'Instance'), url: normalizedCurrent, }; }; @@ -279,7 +269,7 @@ export function DesktopHostSwitcherDialog({ })); if (probe.status === 'unreachable') { - toast.error(`Instance "${host.label}" is unreachable`); + toast.error(`Instance "${redactSensitiveUrl(host.label)}" is unreachable`); setSwitchingHostId(null); return; } @@ -321,7 +311,7 @@ export function DesktopHostSwitcherDialog({ return; } - const label = (editLabel || url).trim(); + const label = (editLabel || redactSensitiveUrl(url)).trim(); const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h)); await persist(nextHosts, defaultHostId); cancelEdit(); @@ -333,7 +323,7 @@ export function DesktopHostSwitcherDialog({ setError('Invalid URL (must be http/https)'); return; } - const label = (newLabel || url).trim(); + const label = (newLabel || redactSensitiveUrl(url)).trim(); const id = makeId(); const nextHosts = [{ id, label, url }, ...configHosts]; @@ -381,10 +371,10 @@ export function DesktopHostSwitcherDialog({
Current - {current.label} + {redactSensitiveUrl(current.label)} Default - {currentDefaultLabel} + {redactSensitiveUrl(currentDefaultLabel)}
@@ -734,7 +726,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost const all = [local, ...(cfg.hosts || [])]; const current = resolveCurrentHost(all); if (cancelled) return; - setLabel(current.label || 'Instance'); + setLabel(redactSensitiveUrl(current.label || 'Instance')); const normalized = normalizeHostUrl(current.url); if (!normalized) { setStatus(null); @@ -767,9 +759,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost const isCurrentlyLocal = (() => { try { - const current = normalizeHostUrl(window.location.origin); - const local = normalizeHostUrl(getLocalOrigin()); - return Boolean(current && local && current === local); + return locationMatchesHost(window.location.href, getLocalOrigin()); } catch { return false; } @@ -790,6 +780,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost : label === 'Local' ? fallbackLabel : label; + const safeEffectiveLabel = redactSensitiveUrl(effectiveLabel); return ( <> @@ -804,7 +795,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost > - {effectiveLabel} + {safeEffectiveLabel} { return; } - const normalizeHostUrl = (raw: string): string | null => { - const trimmed = raw.trim(); - if (!trimmed) return null; - try { - const url = new URL(trimmed); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return null; - } - return url.origin; - } catch { - try { - const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return null; - } - return url.origin; - } catch { - return null; - } - } - }; - try { const cfg = await desktopHostsGet(); - const currentOrigin = window.location.origin; - const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || currentOrigin; - const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin; - const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin; + const currentHref = window.location.href; + const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; - if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) { + if (locationMatchesHost(currentHref, localOrigin)) { setCurrentInstanceLabel('Local'); return; } const match = cfg.hosts.find((host) => { - const normalized = normalizeHostUrl(host.url); - return normalized && normalized === normalizedCurrent; + return locationMatchesHost(currentHref, host.url); }); if (match?.label?.trim()) { - setCurrentInstanceLabel(match.label.trim()); + setCurrentInstanceLabel(redactSensitiveUrl(match.label.trim())); return; } @@ -1794,7 +1769,9 @@ export const Header: React.FC = () => {

- {isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')}) + {isDesktopApp + ? `Current instance: ${currentInstanceLabel}` + : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')})

diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index a56a7794..f74ef703 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -24,6 +24,71 @@ export type HostProbeResult = { latencyMs: number; }; +const SENSITIVE_QUERY_KEY = /token|auth|secret|api/i; + +export const normalizeHostUrl = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + return trimmed.split('#')[0] || null; + } catch { + return null; + } +}; + +export const redactSensitiveUrl = (raw: string): string => { + const normalized = normalizeHostUrl(raw); + if (!normalized) { + return raw; + } + + try { + const url = new URL(normalized); + const keys = Array.from(new Set(Array.from(url.searchParams.keys()))); + for (const key of keys) { + if (SENSITIVE_QUERY_KEY.test(key)) { + url.searchParams.set(key, '[REDACTED]'); + } + } + return url.toString(); + } catch { + return normalized; + } +}; + +export const locationMatchesHost = (locationHref: string, hostUrl: string): boolean => { + const normalizedCurrent = normalizeHostUrl(locationHref); + const normalizedHost = normalizeHostUrl(hostUrl); + if (!normalizedCurrent || !normalizedHost) { + return false; + } + + try { + const current = new URL(normalizedCurrent); + const host = new URL(normalizedHost); + if (current.origin !== host.origin) { + return false; + } + + if (host.search && current.search !== host.search) { + return false; + } + + const hostPath = host.pathname.length > 1 ? host.pathname.replace(/\/+$/, '') : host.pathname; + const currentPath = current.pathname.length > 1 ? current.pathname.replace(/\/+$/, '') : current.pathname; + if (hostPath === '/') { + return true; + } + return currentPath === hostPath || currentPath.startsWith(`${hostPath}/`); + } catch { + return false; + } +}; + const isRecord = (value: unknown): value is Record => { return typeof value === 'object' && value !== null; }; diff --git a/packages/web/package.json b/packages/web/package.json index 0d49f349..a69eed65 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,6 +22,8 @@ "start": "node bin/cli.js serve" }, "dependencies": { + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-go": "^6.0.1", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1",