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
@@ -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.
*