From 2fca7fa73680858ce5e7ad49a1fa9f77acb406f5 Mon Sep 17 00:00:00 2001 From: subhwastaken Date: Wed, 19 Aug 2026 01:34:31 +0530 Subject: [PATCH 1/6] fix(tauri): allow remote cloud runtime sockets --- app/src-tauri/tauri.conf.json | 2 +- app/src/utils/tauriCsp.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index e280eaad9a..12f817432b 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -25,7 +25,7 @@ } ], "security": { - "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'wasm-unsafe-eval' https://www.googletagmanager.com tauri: tauri://localhost; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* https: wss: data: blob: https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; frame-src 'self' https: data: blob:" + "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'wasm-unsafe-eval' https://www.googletagmanager.com tauri: tauri://localhost; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob: https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; frame-src 'self' https: data: blob:" }, "macOSPrivateApi": true }, diff --git a/app/src/utils/tauriCsp.test.ts b/app/src/utils/tauriCsp.test.ts index b794263219..6186c00bcf 100644 --- a/app/src/utils/tauriCsp.test.ts +++ b/app/src/utils/tauriCsp.test.ts @@ -13,6 +13,13 @@ const scriptSourceTokens = .find(directive => directive.startsWith('script-src ')) ?.split(/\s+/) .slice(1) ?? []; +const connectSourceTokens = + config.app?.security?.csp + ?.split(';') + .map(directive => directive.trim()) + .find(directive => directive.startsWith('connect-src ')) + ?.split(/\s+/) + .slice(1) ?? []; describe('Tauri content security policy', () => { it('allows scripts served from the Wry custom scheme', () => { @@ -24,4 +31,8 @@ describe('Tauri content security policy', () => { expect.arrayContaining(["'self'", "'wasm-unsafe-eval'", 'https://www.googletagmanager.com']) ); }); + + it('allows remote cloud runtime HTTP and WebSocket connections', () => { + expect(connectSourceTokens).toEqual(expect.arrayContaining(['http:', 'ws:'])); + }); }); From 4655c6f91762f7b48541756ac248a297121d4ac2 Mon Sep 17 00:00:00 2001 From: subhwastaken Date: Wed, 19 Aug 2026 01:57:06 +0530 Subject: [PATCH 2/6] fix(tauri): restrict remote RPC destinations --- app/src-tauri/src/core_rpc.rs | 65 ++++++++++++++++++- app/src-tauri/tauri.conf.json | 2 +- .../BootCheckGate/BootCheckGate.tsx | 12 ++-- .../__tests__/BootCheckGate.test.tsx | 19 ++---- .../settings/panels/CoreConnectionPanel.tsx | 5 ++ app/src/lib/bootCheck/index.ts | 9 ++- .../coreRpcClient.selfHostedRelay.test.ts | 18 +++++ app/src/services/coreRpcClient.ts | 19 +++++- .../utils/__tests__/configPersistence.test.ts | 11 +++- app/src/utils/configPersistence.ts | 14 +++- app/src/utils/tauriCsp.test.ts | 22 ++++++- 11 files changed, 164 insertions(+), 32 deletions(-) diff --git a/app/src-tauri/src/core_rpc.rs b/app/src-tauri/src/core_rpc.rs index 84071eacc3..ba5facf71f 100644 --- a/app/src-tauri/src/core_rpc.rs +++ b/app/src-tauri/src/core_rpc.rs @@ -1,5 +1,7 @@ //! Shared helpers for authenticated calls from the Tauri host to the local core RPC. +use std::net::{IpAddr, Ipv6Addr}; +use std::str::FromStr; use std::time::Duration; use reqwest::RequestBuilder; @@ -40,6 +42,50 @@ fn relay_bearer_header(token: Option<&str>) -> Option { .map(|t| format!("Bearer {t}")) } +fn is_local_or_private_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") { + return true; + } + + match IpAddr::from_str(host) { + Ok(IpAddr::V4(ip)) => { + let [a, b, ..] = ip.octets(); + a == 10 + || a == 127 + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) + || (a == 169 && b == 254) + || (a == 100 && (64..=127).contains(&b)) + } + Ok(IpAddr::V6(ip)) => { + let first = ip.segments()[0]; + ip == Ipv6Addr::LOCALHOST + || (first & 0xffc0) == 0xfe80 + || (first & 0xfe00) == 0xfc00 + } + Err(_) => false, + } +} + +fn validate_rpc_url(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|_| "invalid core RPC URL".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("core RPC URL must use HTTP or HTTPS".to_string()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("core RPC URL must not contain credentials".to_string()); + } + if parsed.scheme() == "https" + || parsed + .host_str() + .map(is_local_or_private_host) + .unwrap_or(false) + { + return Ok(parsed); + } + Err("cleartext core RPC is limited to local/private hosts".to_string()) +} + /// Redact a relay URL before it lands in a log line or error string: drop the /// query, fragment, and any userinfo (which can carry tokens/credentials), /// keeping just `scheme://host[:port]/path` so transport diagnostics stay @@ -81,6 +127,7 @@ pub(crate) async fn relay_http_rpc( token: Option, body: String, ) -> Result { + validate_rpc_url(&url)?; post_json_rpc(&url, token.as_deref(), body).await } @@ -94,6 +141,7 @@ pub(crate) async fn post_json_rpc( token: Option<&str>, body: String, ) -> Result { + validate_rpc_url(url)?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() @@ -185,7 +233,22 @@ fn unwrap_rpc_outcome(value: serde_json::Value) -> serde_json::Value { #[cfg(test)] mod tests { - use super::relay_bearer_header; + use super::{relay_bearer_header, validate_rpc_url}; + + #[test] + fn allows_https_and_local_or_private_http() { + assert!(validate_rpc_url("https://core.example.com/rpc").is_ok()); + assert!(validate_rpc_url("http://127.0.0.1:7788/rpc").is_ok()); + assert!(validate_rpc_url("http://192.168.1.74:7788/rpc").is_ok()); + assert!(validate_rpc_url("http://100.116.244.64:7788/rpc").is_ok()); + } + + #[test] + fn rejects_public_http_and_embedded_credentials() { + assert!(validate_rpc_url("http://core.example.com/rpc").is_err()); + assert!(validate_rpc_url("http://user:pass@192.168.1.74:7788/rpc").is_err()); + assert!(validate_rpc_url("ftp://127.0.0.1:7788/rpc").is_err()); + } #[test] fn bearer_header_present_for_real_token() { diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 12f817432b..e280eaad9a 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -25,7 +25,7 @@ } ], "security": { - "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'wasm-unsafe-eval' https://www.googletagmanager.com tauri: tauri://localhost; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob: https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; frame-src 'self' https: data: blob:" + "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'wasm-unsafe-eval' https://www.googletagmanager.com tauri: tauri://localhost; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* https: wss: data: blob: https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; frame-src 'self' https: data: blob:" }, "macOSPrivateApi": true }, diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index d60ef32133..67cdde8f94 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -29,6 +29,7 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { clearStoredCoreMode, clearStoredCoreToken, + isAllowedCloudRpcUrl, isLocalOrPrivateNetworkHost, normalizeRpcUrl, storeCoreMode, @@ -44,10 +45,9 @@ const log = debug('boot-check'); const logError = debug('boot-check:error'); /** - * Plain HTTP to a public host is insecure (unencrypted traffic), but we no - * longer block it — return a non-blocking warning string so the UI can nudge - * the user toward HTTPS while still letting them proceed. Returns null when the - * URL is empty, unparseable, HTTPS, or points at a local/private host. + * Plain HTTP to a public host is insecure (unencrypted traffic). Keep the + * warning visible while the user edits the field; validation below prevents + * committing that URL and directs them to HTTPS instead. */ function httpPublicHostWarning( rawUrl: string, @@ -164,6 +164,10 @@ function ModePicker({ onConfirm }: PickerProps) { setUrlError(t('bootCheck.urlMustStartWith')); return null; } + if (!isAllowedCloudRpcUrl(normalizedUrl)) { + setUrlError(t('bootCheck.httpPublicWarning')); + return null; + } } catch { setUrlError(t('bootCheck.validUrlRequired')); return null; diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index 29aa93d9e3..3a8afd4f8f 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -224,16 +224,14 @@ describe('BootCheckGate — picker (unset mode)', () => { ); }); - it('warns about public HTTP cloud URLs but does not block them', async () => { - mockRunBootCheck.mockResolvedValue({ kind: 'match' }); - + it('rejects public HTTP cloud URLs and directs users to HTTPS', () => { renderGate(); fireEvent.click(screen.getByText('Run on the Cloud (Complex)')); const urlInput = screen.getByPlaceholderText(/https:\/\/core\.example\.com/); fireEvent.change(urlInput, { target: { value: 'http://core.example.com/rpc' } }); - // Non-blocking warning shows inline as soon as the public HTTP URL is typed. + // The warning shows inline as soon as the public HTTP URL is typed. expect(screen.getByText(/traffic will not be encrypted/i)).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), { @@ -241,17 +239,8 @@ describe('BootCheckGate — picker (unset mode)', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); - // The boot check still proceeds with the HTTP URL. - await waitFor(() => { - expect(mockRunBootCheck).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'cloud', - url: 'http://core.example.com/rpc', - token: 'tok-1234', - }), - expect.any(Object) - ); - }); + expect(screen.getByText(/traffic will not be encrypted/i)).toBeInTheDocument(); + expect(screen.getByText('Select a Runtime')).toBeInTheDocument(); }); it('clears the token error as soon as the user types into the token field', () => { diff --git a/app/src/components/settings/panels/CoreConnectionPanel.tsx b/app/src/components/settings/panels/CoreConnectionPanel.tsx index 4a2563e09b..b7c469ebe8 100644 --- a/app/src/components/settings/panels/CoreConnectionPanel.tsx +++ b/app/src/components/settings/panels/CoreConnectionPanel.tsx @@ -33,6 +33,7 @@ import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { CORE_RPC_URL } from '../../../utils/config'; import { clearStoredCoreToken, + isAllowedCloudRpcUrl, isLocalOrPrivateNetworkHost, isTauriEnvironment, normalizeRpcUrl, @@ -192,6 +193,10 @@ const CoreConnectionPanel = () => { setFormError(t('bootCheck.validUrlRequired')); return null; } + if (!isAllowedCloudRpcUrl(normalized)) { + setFormError(t('bootCheck.httpPublicWarning')); + return null; + } } catch { setFormError(t('bootCheck.validUrlRequired')); return null; diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index 21bac220da..b5a8a2452a 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -16,7 +16,7 @@ import debug from 'debug'; import { clearCoreRpcUrlCache } from '../../services/coreRpcClient'; import type { CoreMode } from '../../store/coreModeSlice'; import { APP_VERSION } from '../../utils/config'; -import { storeRpcUrl } from '../../utils/configPersistence'; +import { isAllowedCloudRpcUrl, normalizeRpcUrl, storeRpcUrl } from '../../utils/configPersistence'; const log = debug('boot-check'); const logError = debug('boot-check:error'); @@ -312,7 +312,12 @@ export async function runBootCheck( let safeUrl: string | null = null; let safeOrigin: string | null = null; try { - const parsed = new URL(mode.url); + const normalizedUrl = normalizeRpcUrl(mode.url); + if (!isAllowedCloudRpcUrl(normalizedUrl)) { + logError('[boot-check] cloud mode — unauthorized URL, refusing to connect'); + return { kind: 'unreachable', reason: 'Configured cloud URL is not allowed' }; + } + const parsed = new URL(normalizedUrl); safeOrigin = parsed.origin; safeUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; } catch { diff --git a/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts b/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts index 11876b2fcd..99cf8f9bb8 100644 --- a/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts +++ b/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts @@ -104,4 +104,22 @@ describe('testCoreRpcConnection (self-hosted runtime, #3865)', () => { expect(res.status).toBe(401); expect(res.ok).toBe(false); }); + + test('rejects public cleartext HTTP before sending a token or request', async () => { + await expect(testCoreRpcConnection('http://core.example.com/rpc', 'tok123')).rejects.toThrow( + 'Core RPC URL must use HTTPS or local/private HTTP' + ); + + expect(invoke).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('rejects URLs that embed credentials before sending a token or request', async () => { + await expect( + testCoreRpcConnection('https://user:pass@core.example.com/rpc', 'tok123') + ).rejects.toThrow('Core RPC URL must use HTTPS or local/private HTTP'); + + expect(invoke).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index d8130c1b89..2e18323eb3 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -3,7 +3,12 @@ import debug from 'debug'; import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory'; import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config'; -import { getStoredCoreToken, normalizeRpcUrl, peekStoredRpcUrl } from '../utils/configPersistence'; +import { + getStoredCoreToken, + isAllowedCloudRpcUrl, + normalizeRpcUrl, + peekStoredRpcUrl, +} from '../utils/configPersistence'; import { redactRpcUrlForLog } from '../utils/redactRpcUrlForLog'; import { sanitizeError } from '../utils/sanitize'; // The bridge-gap-aware Tauri guard: returns true only when the IPC bridge @@ -516,6 +521,12 @@ export function rpcUrlNeedsShellRelay(rpcUrl: string): boolean { return !isPotentiallyTrustworthyHost(parsed.hostname); } +function assertAllowedRpcUrl(rpcUrl: string): void { + if (!isAllowedCloudRpcUrl(rpcUrl)) { + throw new Error('Core RPC URL must use HTTPS or local/private HTTP'); + } +} + /** * Perform a JSON-RPC POST via the Rust host (`relay_http_rpc` Tauri command), * returning a synthesized `Response` so callers reuse their existing @@ -530,6 +541,7 @@ async function relayRpcViaShell( body: string, signal?: AbortSignal ): Promise { + assertAllowedRpcUrl(rpcUrl); const invokePromise = invoke<{ status: number; body: string }>('relay_http_rpc', { url: rpcUrl, token: token ?? null, @@ -578,6 +590,7 @@ export async function testCoreRpcConnection( init?: { signal?: AbortSignal } ): Promise { const rpcUrl = normalizeRpcUrl(url); + assertAllowedRpcUrl(rpcUrl); const token = tokenOverride?.trim() || (await getCoreRpcToken()); const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }); @@ -667,7 +680,9 @@ export async function callCoreRpc({ }; try { - const [rpcUrl, token] = await Promise.all([getCoreRpcUrl(), getCoreRpcToken()]); + const rpcUrl = await getCoreRpcUrl(); + assertAllowedRpcUrl(rpcUrl); + const token = await getCoreRpcToken(); coreRpcLog('HTTP request', { id: payload.id, method: payload.method }); if (normalizedMethod === 'openhuman.auth_store_session') { coreRpcLog('[rpc] auth_store_session routing', { diff --git a/app/src/utils/__tests__/configPersistence.test.ts b/app/src/utils/__tests__/configPersistence.test.ts index e93ef8480b..f4de5fd6f6 100644 --- a/app/src/utils/__tests__/configPersistence.test.ts +++ b/app/src/utils/__tests__/configPersistence.test.ts @@ -278,6 +278,11 @@ describe('configPersistence', () => { expect(isAllowedCloudRpcUrl('http://core.example.com/rpc')).toBe(false); expect(isAllowedCloudRpcUrl('http://8.8.8.8:7788/rpc')).toBe(false); }); + + it('rejects URLs that embed credentials', () => { + expect(isAllowedCloudRpcUrl('https://user:pass@core.example.com/rpc')).toBe(false); + expect(isAllowedCloudRpcUrl('http://user:pass@192.168.1.100:7788/rpc')).toBe(false); + }); }); describe('normalizeRpcUrl — edge cases', () => { @@ -342,15 +347,15 @@ describe('configPersistence', () => { describe('clearStoredRpcUrl + getStoredRpcUrl', () => { it('getStoredRpcUrl returns the default after clearStoredRpcUrl', () => { - storeRpcUrl('http://some-host:9999/rpc'); - expect(getStoredRpcUrl()).toBe('http://some-host:9999/rpc'); + storeRpcUrl('http://10.0.0.5:9999/rpc'); + expect(getStoredRpcUrl()).toBe('http://10.0.0.5:9999/rpc'); clearStoredRpcUrl(); expect(getStoredRpcUrl()).toBe('http://127.0.0.1:7788/rpc'); }); it('localStorage key is null after clearStoredRpcUrl', () => { - storeRpcUrl('http://some-host:9999/rpc'); + storeRpcUrl('http://10.0.0.5:9999/rpc'); clearStoredRpcUrl(); expect(localStorage.getItem('openhuman_core_rpc_url')).toBeNull(); }); diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index 0de4481051..18f1c05209 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -59,7 +59,9 @@ export function getStoredRpcUrl(): string { try { const stored = localStorage.getItem(RPC_URL_STORAGE_KEY); if (stored && stored.trim().length > 0) { - return normalizeRpcUrl(stored); + const normalized = normalizeRpcUrl(stored); + if (isAllowedCloudRpcUrl(normalized)) return normalized; + localStorage.removeItem(RPC_URL_STORAGE_KEY); } } catch { // localStorage might be unavailable in some environments @@ -84,7 +86,9 @@ export function peekStoredRpcUrl(): string | null { try { const stored = localStorage.getItem(RPC_URL_STORAGE_KEY); if (stored && stored.trim().length > 0) { - return normalizeRpcUrl(stored); + const normalized = normalizeRpcUrl(stored); + if (isAllowedCloudRpcUrl(normalized)) return normalized; + localStorage.removeItem(RPC_URL_STORAGE_KEY); } } catch { console.warn('[configPersistence] Unable to access localStorage'); @@ -101,6 +105,11 @@ export function storeRpcUrl(url: string): void { try { if (url && url.trim().length > 0) { const normalized = normalizeRpcUrl(url); + if (!isAllowedCloudRpcUrl(normalized)) { + localStorage.removeItem(RPC_URL_STORAGE_KEY); + console.warn('[configPersistence] Refusing to store unauthorized RPC URL'); + return; + } localStorage.setItem(RPC_URL_STORAGE_KEY, normalized); log('Stored RPC URL: %s', redactRpcUrlForLog(normalized)); } else { @@ -185,6 +194,7 @@ export function isAllowedCloudRpcUrl(url: string): boolean { if (!isValidRpcUrl(url)) return false; const parsed = new URL(url.trim()); + if (parsed.username || parsed.password) return false; if (parsed.protocol === 'https:') return true; return parsed.protocol === 'http:' && isLocalOrPrivateNetworkHost(parsed.hostname); } diff --git a/app/src/utils/tauriCsp.test.ts b/app/src/utils/tauriCsp.test.ts index 6186c00bcf..7408078f8c 100644 --- a/app/src/utils/tauriCsp.test.ts +++ b/app/src/utils/tauriCsp.test.ts @@ -32,7 +32,25 @@ describe('Tauri content security policy', () => { ); }); - it('allows remote cloud runtime HTTP and WebSocket connections', () => { - expect(connectSourceTokens).toEqual(expect.arrayContaining(['http:', 'ws:'])); + it('preserves required connections without broad cleartext sources', () => { + expect(connectSourceTokens).toEqual( + expect.arrayContaining([ + "'self'", + 'ipc:', + 'http://ipc.localhost', + 'http://127.0.0.1:*', + 'http://localhost:*', + 'ws://127.0.0.1:*', + 'ws://localhost:*', + 'https:', + 'wss:', + 'data:', + 'blob:', + 'https://*.google-analytics.com', + 'https://*.analytics.google.com', + 'https://*.googletagmanager.com', + ]) + ); + expect(connectSourceTokens).not.toEqual(expect.arrayContaining(['http:', 'ws:'])); }); }); From 564ee865aff57339a0e1ced519cd95e0d2f8e542 Mon Sep 17 00:00:00 2001 From: subhwastaken Date: Wed, 19 Aug 2026 01:58:45 +0530 Subject: [PATCH 3/6] test: align RPC hardening fixtures --- app/src/lib/bootCheck/index.test.ts | 2 +- app/src/services/__tests__/coreRpcClient.test.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts index d24c92ebed..1420bc8e87 100644 --- a/app/src/lib/bootCheck/index.test.ts +++ b/app/src/lib/bootCheck/index.test.ts @@ -397,7 +397,7 @@ describe('runBootCheck — error and edge branches', () => { const result = await runBootCheck({ kind: 'cloud', url: 'not a url' }, transport); expect(result.kind).toBe('unreachable'); if (result.kind === 'unreachable') { - expect(result.reason).toContain('valid URL'); + expect(result.reason).toContain('not allowed'); } expect(transport.callRpc).not.toHaveBeenCalled(); }); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 8c2e9b67d2..f4d8866a10 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -531,11 +531,11 @@ describe('coreRpcClient', () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response); - await testCoreRpcConnection('http://example.test:7788/rpc'); + await testCoreRpcConnection('https://example.test:7788/rpc'); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('http://example.test:7788/rpc'); + expect(url).toBe('https://example.test:7788/rpc'); const requestInit = init as RequestInit; expect(requestInit.method).toBe('POST'); expect(JSON.parse(requestInit.body as string)).toMatchObject({ @@ -566,7 +566,7 @@ describe('coreRpcClient', () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response); - await testCoreRpcConnection('http://example.test:7788/rpc'); + await testCoreRpcConnection('https://example.test:7788/rpc'); const requestInit = fetchMock.mock.calls[0][1] as RequestInit; const headers = requestInit.headers as Record; @@ -604,7 +604,7 @@ describe('coreRpcClient', () => { const probe = { ok: false, status: 405, statusText: 'Method Not Allowed' } as Response; fetchMock.mockResolvedValueOnce(probe); - const response = await testCoreRpcConnection('http://example.test:7788/rpc'); + const response = await testCoreRpcConnection('https://example.test:7788/rpc'); expect(response).toBe(probe); expect(response.status).toBe(405); @@ -1140,6 +1140,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => 'https://core.example.com/rpc', getStoredCoreToken: () => 'cloud-token-abc', + isAllowedCloudRpcUrl: () => true, normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); @@ -1184,6 +1185,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => 'https://core.example.com/rpc', getStoredCoreToken: () => storedToken, + isAllowedCloudRpcUrl: () => true, normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); @@ -1212,6 +1214,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => null, getStoredCoreToken: () => null, + isAllowedCloudRpcUrl: () => true, normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); From 9d7cddbce29f48dc3848023f7b2026c52392c9ad Mon Sep 17 00:00:00 2001 From: subhwastaken Date: Wed, 19 Aug 2026 02:07:58 +0530 Subject: [PATCH 4/6] fix: address latest review follow-ups --- app/src-tauri/src/core_rpc.rs | 1 - .../components/BootCheckGate/__tests__/BootCheckGate.test.tsx | 1 + app/src/lib/bootCheck/index.ts | 2 +- app/src/utils/configPersistence.ts | 1 - 4 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src-tauri/src/core_rpc.rs b/app/src-tauri/src/core_rpc.rs index ba5facf71f..2dd45b4aa6 100644 --- a/app/src-tauri/src/core_rpc.rs +++ b/app/src-tauri/src/core_rpc.rs @@ -127,7 +127,6 @@ pub(crate) async fn relay_http_rpc( token: Option, body: String, ) -> Result { - validate_rpc_url(&url)?; post_json_rpc(&url, token.as_deref(), body).await } diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index 3a8afd4f8f..7bc617a9e8 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -239,6 +239,7 @@ describe('BootCheckGate — picker (unset mode)', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(mockRunBootCheck).not.toHaveBeenCalled(); expect(screen.getByText(/traffic will not be encrypted/i)).toBeInTheDocument(); expect(screen.getByText('Select a Runtime')).toBeInTheDocument(); }); diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index b5a8a2452a..a384a3a678 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -313,11 +313,11 @@ export async function runBootCheck( let safeOrigin: string | null = null; try { const normalizedUrl = normalizeRpcUrl(mode.url); + const parsed = new URL(normalizedUrl); if (!isAllowedCloudRpcUrl(normalizedUrl)) { logError('[boot-check] cloud mode — unauthorized URL, refusing to connect'); return { kind: 'unreachable', reason: 'Configured cloud URL is not allowed' }; } - const parsed = new URL(normalizedUrl); safeOrigin = parsed.origin; safeUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; } catch { diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index 18f1c05209..3f0b35cb67 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -88,7 +88,6 @@ export function peekStoredRpcUrl(): string | null { if (stored && stored.trim().length > 0) { const normalized = normalizeRpcUrl(stored); if (isAllowedCloudRpcUrl(normalized)) return normalized; - localStorage.removeItem(RPC_URL_STORAGE_KEY); } } catch { console.warn('[configPersistence] Unable to access localStorage'); From 19de84ed88efb2496916eea01b320c97be2a7cee Mon Sep 17 00:00:00 2001 From: subhwastaken Date: Wed, 19 Aug 2026 02:14:14 +0530 Subject: [PATCH 5/6] fix: block RPC request redirects --- app/src-tauri/src/core_rpc.rs | 1 + .../__tests__/coreRpcClient.selfHostedRelay.test.ts | 1 + app/src/services/__tests__/coreRpcClient.test.ts | 1 + app/src/services/coreRpcClient.ts | 9 ++++++++- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/core_rpc.rs b/app/src-tauri/src/core_rpc.rs index 2dd45b4aa6..c6866653b9 100644 --- a/app/src-tauri/src/core_rpc.rs +++ b/app/src-tauri/src/core_rpc.rs @@ -142,6 +142,7 @@ pub(crate) async fn post_json_rpc( ) -> Result { validate_rpc_url(url)?; let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(30)) .build() .map_err(|e| format!("failed to build HTTP client: {e}"))?; diff --git a/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts b/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts index 99cf8f9bb8..b7c4344a0b 100644 --- a/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts +++ b/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts @@ -93,6 +93,7 @@ describe('testCoreRpcConnection (self-hosted runtime, #3865)', () => { await testCoreRpcConnection('http://127.0.0.1:7788/rpc', 'tok123'); expect(fetch).toHaveBeenCalledTimes(1); + expect((vi.mocked(fetch).mock.calls[0][1] as RequestInit).redirect).toBe('error'); expect(invoke).not.toHaveBeenCalledWith('relay_http_rpc', expect.anything()); }); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index f4d8866a10..985e08c6a2 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -35,6 +35,7 @@ describe('coreRpcClient', () => { const requestInit = fetchMock.mock.calls[0][1] as RequestInit; const body = JSON.parse(String(requestInit.body)); expect(body.method).toBe('openhuman.auth_get_state'); + expect(requestInit.redirect).toBe('error'); }); test('throws clean error when JSON-RPC error payload is returned', async () => { diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 2e18323eb3..ad754d59aa 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -606,7 +606,13 @@ export async function testCoreRpcConnection( if (token) { headers.Authorization = `Bearer ${token}`; } - return fetch(rpcUrl, { method: 'POST', headers, body, signal: init?.signal }); + return fetch(rpcUrl, { + method: 'POST', + headers, + body, + signal: init?.signal, + redirect: 'error', + }); } export async function getCoreHttpBaseUrl(): Promise { @@ -729,6 +735,7 @@ export async function callCoreRpc({ headers, body: JSON.stringify(payload), signal: controller.signal, + redirect: 'error', }); } } catch (fetchErr) { From ff9427492a3d36556f40d066524636dfaf4904d1 Mon Sep 17 00:00:00 2001 From: subhwastaken Date: Wed, 19 Aug 2026 02:20:29 +0530 Subject: [PATCH 6/6] style: format RPC fetch options --- app/src/services/coreRpcClient.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index ad754d59aa..709e784c65 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -606,13 +606,7 @@ export async function testCoreRpcConnection( if (token) { headers.Authorization = `Bearer ${token}`; } - return fetch(rpcUrl, { - method: 'POST', - headers, - body, - signal: init?.signal, - redirect: 'error', - }); + return fetch(rpcUrl, { method: 'POST', headers, body, signal: init?.signal, redirect: 'error' }); } export async function getCoreHttpBaseUrl(): Promise {