mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Allow private HTTP core URLs (#1765)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user