Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion app/src-tauri/src/core_rpc.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -40,6 +42,50 @@ fn relay_bearer_header(token: Option<&str>) -> Option<String> {
.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<url::Url, String> {
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
Expand Down Expand Up @@ -94,7 +140,9 @@ pub(crate) async fn post_json_rpc(
token: Option<&str>,
body: String,
) -> Result<RelayHttpResponse, String> {
validate_rpc_url(url)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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}"))?;
Expand Down Expand Up @@ -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() {
Expand Down
12 changes: 8 additions & 4 deletions app/src/components/BootCheckGate/BootCheckGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
clearStoredCoreMode,
clearStoredCoreToken,
isAllowedCloudRpcUrl,
isLocalOrPrivateNetworkHost,
normalizeRpcUrl,
storeCoreMode,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 5 additions & 15 deletions app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,34 +224,24 @@ 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), {
target: { value: 'tok-1234' },
});
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(mockRunBootCheck).not.toHaveBeenCalled();
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', () => {
Expand Down
5 changes: 5 additions & 0 deletions app/src/components/settings/panels/CoreConnectionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { CORE_RPC_URL } from '../../../utils/config';
import {
clearStoredCoreToken,
isAllowedCloudRpcUrl,
isLocalOrPrivateNetworkHost,
isTauriEnvironment,
normalizeRpcUrl,
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/src/lib/bootCheck/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
9 changes: 7 additions & 2 deletions app/src/lib/bootCheck/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
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' };
}
safeOrigin = parsed.origin;
safeUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
} catch {
Expand Down
19 changes: 19 additions & 0 deletions app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});

Expand All @@ -104,4 +105,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();
});
});
12 changes: 8 additions & 4 deletions app/src/services/__tests__/coreRpcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -531,11 +532,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({
Expand Down Expand Up @@ -566,7 +567,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<string, string>;
Expand Down Expand Up @@ -604,7 +605,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);
Expand Down Expand Up @@ -1140,6 +1141,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);
Expand Down Expand Up @@ -1184,6 +1186,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);
Expand Down Expand Up @@ -1212,6 +1215,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => null,
getStoredCoreToken: () => null,
isAllowedCloudRpcUrl: () => true,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
Expand Down
22 changes: 19 additions & 3 deletions app/src/services/coreRpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -530,6 +541,7 @@ async function relayRpcViaShell(
body: string,
signal?: AbortSignal
): Promise<Response> {
assertAllowedRpcUrl(rpcUrl);
const invokePromise = invoke<{ status: number; body: string }>('relay_http_rpc', {
url: rpcUrl,
token: token ?? null,
Expand Down Expand Up @@ -578,6 +590,7 @@ export async function testCoreRpcConnection(
init?: { signal?: AbortSignal }
): Promise<Response> {
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: {} });

Expand All @@ -593,7 +606,7 @@ 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<string> {
Expand Down Expand Up @@ -667,7 +680,9 @@ export async function callCoreRpc<T>({
};

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', {
Expand Down Expand Up @@ -714,6 +729,7 @@ export async function callCoreRpc<T>({
headers,
body: JSON.stringify(payload),
signal: controller.signal,
redirect: 'error',
});
}
} catch (fetchErr) {
Expand Down
Loading
Loading