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