mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
Allow private HTTP core URLs (#1765)
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user