Allow private HTTP core URLs (#1765)

This commit is contained in:
Srinivas Vaddi
2026-05-14 21:24:30 -07:00
committed by GitHub
parent 1e169bd588
commit cd911ab18d
13 changed files with 228 additions and 22 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://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:; 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:*; 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:; frame-src 'self' https: data: blob:"
},
"macOSPrivateApi": true
},
@@ -24,6 +24,7 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
clearStoredCoreMode,
clearStoredCoreToken,
isLocalOrPrivateNetworkHost,
storeCoreMode,
storeCoreToken,
storeRpcUrl,
@@ -116,6 +117,12 @@ function ModePicker({ onConfirm }: PickerProps) {
setUrlError('URL must start with http:// or https://');
return null;
}
if (parsed.protocol === 'http:' && !isLocalOrPrivateNetworkHost(parsed.hostname)) {
setUrlError(
'HTTP core URLs are only allowed for localhost or private network hosts. Use HTTPS for public hosts.'
);
return null;
}
} catch {
setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)');
return null;
@@ -39,14 +39,17 @@ vi.mock('../../../services/coreRpcClient', () => ({
testCoreRpcConnection: (...args: unknown[]) => mockTestCoreRpcConnection(...args),
}));
vi.mock('../../../utils/configPersistence', () => ({
storeRpcUrl: vi.fn(),
storeCoreToken: vi.fn(),
clearStoredCoreToken: vi.fn(),
storeCoreMode: vi.fn(),
clearStoredCoreMode: vi.fn(),
isValidRpcUrl: vi.fn().mockReturnValue(true),
}));
vi.mock('../../../utils/configPersistence', async importOriginal => {
const actual = await importOriginal<typeof import('../../../utils/configPersistence')>();
return {
...actual,
storeRpcUrl: vi.fn(),
storeCoreToken: vi.fn(),
clearStoredCoreToken: vi.fn(),
storeCoreMode: vi.fn(),
clearStoredCoreMode: vi.fn(),
};
});
// ---------------------------------------------------------------------------
// Store factory
@@ -157,6 +160,43 @@ describe('BootCheckGate — picker (unset mode)', () => {
expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument();
});
it('accepts a Tailscale HTTP core URL in cloud mode', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
renderGate();
fireEvent.click(screen.getByText('Cloud'));
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
target: { value: 'http://100.116.244.64:7788/rpc' },
});
fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), {
target: { value: 'tok-1234' },
});
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('app-content')).toBeInTheDocument();
});
expect(mockRunBootCheck).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'cloud',
url: 'http://100.116.244.64:7788/rpc',
token: 'tok-1234',
}),
expect.any(Object)
);
});
it('rejects public HTTP cloud URLs', () => {
renderGate();
fireEvent.click(screen.getByText('Cloud'));
const urlInput = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
fireEvent.change(urlInput, { target: { value: 'http://core.example.com/rpc' } });
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(screen.getByText(/HTTP core URLs are only allowed/)).toBeInTheDocument();
});
it('clears the token error as soon as the user types into the token field', () => {
renderGate();
@@ -12,6 +12,8 @@ import {
getStoredCoreMode,
getStoredCoreToken,
getStoredRpcUrl,
isAllowedCloudRpcUrl,
isLocalOrPrivateNetworkHost,
isValidRpcUrl,
normalizeRpcUrl,
peekStoredRpcUrl,
@@ -205,6 +207,56 @@ describe('configPersistence', () => {
});
});
describe('isLocalOrPrivateNetworkHost', () => {
it('allows localhost and loopback addresses', () => {
expect(isLocalOrPrivateNetworkHost('localhost')).toBe(true);
expect(isLocalOrPrivateNetworkHost('app.localhost')).toBe(true);
expect(isLocalOrPrivateNetworkHost('127.0.0.1')).toBe(true);
expect(isLocalOrPrivateNetworkHost('::1')).toBe(true);
});
it('allows RFC1918, link-local, and Tailscale/CGNAT IPv4 addresses', () => {
expect(isLocalOrPrivateNetworkHost('10.0.0.8')).toBe(true);
expect(isLocalOrPrivateNetworkHost('172.16.0.1')).toBe(true);
expect(isLocalOrPrivateNetworkHost('172.31.255.255')).toBe(true);
expect(isLocalOrPrivateNetworkHost('192.168.1.100')).toBe(true);
expect(isLocalOrPrivateNetworkHost('169.254.10.20')).toBe(true);
expect(isLocalOrPrivateNetworkHost('100.64.0.1')).toBe(true);
expect(isLocalOrPrivateNetworkHost('100.116.244.64')).toBe(true);
expect(isLocalOrPrivateNetworkHost('100.127.255.254')).toBe(true);
});
it('allows private IPv6 ranges', () => {
expect(isLocalOrPrivateNetworkHost('fc00::1')).toBe(true);
expect(isLocalOrPrivateNetworkHost('fd12:3456::1')).toBe(true);
expect(isLocalOrPrivateNetworkHost('fe80::1')).toBe(true);
});
it('rejects public hosts and invalid IPv4 addresses', () => {
expect(isLocalOrPrivateNetworkHost('example.com')).toBe(false);
expect(isLocalOrPrivateNetworkHost('8.8.8.8')).toBe(false);
expect(isLocalOrPrivateNetworkHost('100.128.0.1')).toBe(false);
expect(isLocalOrPrivateNetworkHost('256.1.1.1')).toBe(false);
});
});
describe('isAllowedCloudRpcUrl', () => {
it('allows HTTPS cloud URLs on public hosts', () => {
expect(isAllowedCloudRpcUrl('https://core.example.com/rpc')).toBe(true);
});
it('allows HTTP only for local and private-network core URLs', () => {
expect(isAllowedCloudRpcUrl('http://127.0.0.1:7788/rpc')).toBe(true);
expect(isAllowedCloudRpcUrl('http://192.168.1.100:7788/rpc')).toBe(true);
expect(isAllowedCloudRpcUrl('http://100.116.244.64:7788/rpc')).toBe(true);
});
it('rejects public HTTP cloud URLs', () => {
expect(isAllowedCloudRpcUrl('http://core.example.com/rpc')).toBe(false);
expect(isAllowedCloudRpcUrl('http://8.8.8.8:7788/rpc')).toBe(false);
});
});
describe('normalizeRpcUrl — edge cases', () => {
it('does not add /rpc suffix when missing (normalizeRpcUrl only strips, not appends)', () => {
expect(normalizeRpcUrl('http://127.0.0.1:7788')).toBe('http://127.0.0.1:7788');
+48
View File
@@ -124,6 +124,54 @@ export function isValidRpcUrl(url: string): boolean {
}
}
/**
* Return true when `hostname` is local or private-network address space.
*
* This intentionally includes Tailscale/CGNAT (`100.64.0.0/10`): self-hosted
* cores often run on tailnets where the transport is already encrypted and
* the HTTP service is not exposed to the public internet.
*/
export function isLocalOrPrivateNetworkHost(hostname: string): boolean {
const host = hostname
.trim()
.replace(/^\[(.*)\]$/, '$1')
.toLowerCase();
if (!host) return false;
if (host === 'localhost' || host.endsWith('.localhost')) return true;
if (host === '::1') return true;
if (host.startsWith('fe80:')) return true;
if (/^f[cd][0-9a-f]{2}:/i.test(host)) return true;
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!match) return false;
const octets = match.slice(1).map(Number);
if (octets.some(octet => octet < 0 || octet > 255)) return false;
const [a, b] = octets;
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
(a === 100 && b >= 64 && b <= 127)
);
}
/**
* Cloud cores may use HTTPS on any host. Plain HTTP is accepted only for
* localhost/private networks, including tailnets, to avoid encouraging
* bearer-token transport over public plaintext links.
*/
export function isAllowedCloudRpcUrl(url: string): boolean {
if (!isValidRpcUrl(url)) return false;
const parsed = new URL(url.trim());
if (parsed.protocol === 'https:') return true;
return parsed.protocol === 'http:' && isLocalOrPrivateNetworkHost(parsed.hostname);
}
/**
* Normalize an RPC URL by trimming whitespace and trailing slashes.
*
+16 -4
View File
@@ -20,9 +20,12 @@ This guide covers three deploy paths, easiest first:
3. [Any VPS via Docker Compose](#3-any-vps-via-docker-compose)
What gets deployed in every path: a single container running
`openhuman-core serve` on port `7788`, behind the provider's TLS. The desktop
app already knows how to talk to a remote core, set
`OPENHUMAN_CORE_RPC_URL=https://your-host/rpc` and `OPENHUMAN_CORE_TOKEN=...`
`openhuman-core serve` on port `7788`. Public hosts should sit behind the
provider's TLS, for example `https://core.example.com/rpc`. Private-only hosts
on localhost, RFC1918 networks, or tailnets such as Tailscale can use
plain HTTP, for example `http://100.x.x.x:7788/rpc`, when the core is not
reachable from the public internet. The desktop app already knows how to talk
to a remote core; set `OPENHUMAN_CORE_RPC_URL` and `OPENHUMAN_CORE_TOKEN=...`
in `app/.env.local` and launch.
---
@@ -337,8 +340,17 @@ OPENHUMAN_CORE_RPC_URL=https://core.example.com/rpc
OPENHUMAN_CORE_TOKEN=<the same token you set on the server>
```
For a private tailnet-only VM with no public IP, use the tailnet URL instead:
```bash
OPENHUMAN_CORE_RUN_MODE=external
OPENHUMAN_CORE_RPC_URL=http://100.x.x.x:7788/rpc
OPENHUMAN_CORE_TOKEN=<the same token you set on the server>
```
Restart the desktop app. The provider chain in `App.tsx` will route all RPC
calls to the remote core; nothing else changes.
calls to the remote core; nothing else changes. Public `http://` hosts are
rejected by the app picker; use HTTPS for any publicly reachable core.
---
+1 -1
View File
@@ -368,7 +368,7 @@ fn markdown_body_preview(md: &str) -> String {
if len <= BODY_PREVIEW_MAX_BYTES {
md.to_string()
} else {
let start = md.ceil_char_boundary(len - BODY_PREVIEW_MAX_BYTES);
let start = crate::openhuman::util::ceil_char_boundary(md, len - BODY_PREVIEW_MAX_BYTES);
md[start..].to_string()
}
}
@@ -152,7 +152,10 @@ impl ScreenshotTool {
let size = bytes.len();
let mut encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
let truncated = if encoded.len() > MAX_BASE64_BYTES {
encoded.truncate(encoded.floor_char_boundary(MAX_BASE64_BYTES));
encoded.truncate(crate::openhuman::util::floor_char_boundary(
&encoded,
MAX_BASE64_BYTES,
));
true
} else {
false
+8 -2
View File
@@ -223,11 +223,17 @@ impl Tool for NodeExecTool {
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stdout.len() > MAX_OUTPUT_BYTES {
stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES));
stdout.truncate(crate::openhuman::util::floor_char_boundary(
&stdout,
MAX_OUTPUT_BYTES,
));
stdout.push_str("\n... [stdout truncated at 1MB]");
}
if stderr.len() > MAX_OUTPUT_BYTES {
stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES));
stderr.truncate(crate::openhuman::util::floor_char_boundary(
&stderr,
MAX_OUTPUT_BYTES,
));
stderr.push_str("\n... [stderr truncated at 1MB]");
}
+8 -2
View File
@@ -235,11 +235,17 @@ impl Tool for NpmExecTool {
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stdout.len() > MAX_OUTPUT_BYTES {
stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES));
stdout.truncate(crate::openhuman::util::floor_char_boundary(
&stdout,
MAX_OUTPUT_BYTES,
));
stdout.push_str("\n... [stdout truncated at 1MB]");
}
if stderr.len() > MAX_OUTPUT_BYTES {
stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES));
stderr.truncate(crate::openhuman::util::floor_char_boundary(
&stderr,
MAX_OUTPUT_BYTES,
));
stderr.push_str("\n... [stderr truncated at 1MB]");
}
+8 -2
View File
@@ -175,11 +175,17 @@ impl Tool for ShellTool {
// Truncate output to prevent OOM
if stdout.len() > MAX_OUTPUT_BYTES {
stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES));
stdout.truncate(crate::openhuman::util::floor_char_boundary(
&stdout,
MAX_OUTPUT_BYTES,
));
stdout.push_str("\n... [output truncated at 1MB]");
}
if stderr.len() > MAX_OUTPUT_BYTES {
stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES));
stderr.truncate(crate::openhuman::util::floor_char_boundary(
&stderr,
MAX_OUTPUT_BYTES,
));
stderr.push_str("\n... [stderr truncated at 1MB]");
}
+2 -1
View File
@@ -408,7 +408,8 @@ async fn summarize_to_limit(
char_limit
);
// Truncate at a char boundary
let truncated = &response[..response.floor_char_boundary(char_limit)];
let truncated =
&response[..crate::openhuman::util::floor_char_boundary(&response, char_limit)];
truncated.to_string()
} else {
response
+25
View File
@@ -79,6 +79,18 @@ pub fn floor_char_boundary(s: &str, index: usize) -> usize {
end
}
/// Round a byte index UP to the nearest UTF-8 character boundary.
pub fn ceil_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut start = index;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
start
}
/// Utility enum for handling optional values.
pub enum MaybeSet<T> {
Set(T),
@@ -215,6 +227,19 @@ mod tests {
assert_eq!(floor_char_boundary(s, 100), 6);
}
#[test]
fn test_ceil_char_boundary() {
let s = "A🦀C";
assert_eq!(ceil_char_boundary(s, 0), 0);
assert_eq!(ceil_char_boundary(s, 1), 1); // After 'A'
assert_eq!(ceil_char_boundary(s, 2), 5); // Mid-🦀
assert_eq!(ceil_char_boundary(s, 3), 5); // Mid-🦀
assert_eq!(ceil_char_boundary(s, 4), 5); // Mid-🦀
assert_eq!(ceil_char_boundary(s, 5), 5); // After '🦀'
assert_eq!(ceil_char_boundary(s, 6), 6); // After 'C'
assert_eq!(ceil_char_boundary(s, 100), 6);
}
#[test]
fn test_truncate_with_suffix() {
let s = "Hello World";