From c47a8eb44ecbcc5590fcbdfd1126bc0e293e30b5 Mon Sep 17 00:00:00 2001 From: Vikas Rajput Date: Sun, 4 Oct 2026 23:59:43 +0530 Subject: [PATCH] Allow HTTP base URLs for private LAN hosts (fixes #45) Co-Authored-By: Claude Opus 5.5 --- lib/config.mjs | 14 +++++++++++++- tests/backend.test.mjs | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/config.mjs b/lib/config.mjs index 37e5eb0..124e3e4 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -90,10 +90,22 @@ export function activateProfile(provider) { writeJSON(CONFIG, config); return publicConfig(config); } +// Loopback, RFC 1918, link-local and IPv6 ULA hosts. Expects a WHATWG URL hostname (normalized IPv4, bracketed IPv6). +function isPrivateHost(hostname) { + if (hostname === 'localhost' || hostname === '[::1]') return true; + if (hostname.startsWith('[')) { + const first = parseInt(hostname.slice(1).split(':')[0] || '0', 16); + return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; + } + const octets = hostname.split('.').map(Number); + if (octets.length !== 4 || octets.some(n => !Number.isInteger(n))) return false; + const [a, b] = octets; + return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254); +} export function validateBaseURL(value) { const url = new URL(value); if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Use an HTTP(S) base URL without credentials, query, or fragment'); - if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('Remote endpoints require HTTPS'); + if (url.protocol === 'http:' && !isPrivateHost(url.hostname)) throw new Error('Remote endpoints require HTTPS'); return url.href.replace(/\/+$/, ''); } export function publicConfig(config = readConfig()) { diff --git a/tests/backend.test.mjs b/tests/backend.test.mjs index 9b1b0b3..aeb7265 100644 --- a/tests/backend.test.mjs +++ b/tests/backend.test.mjs @@ -23,6 +23,8 @@ test('all nine providers and safe configuration round trips',()=>{ saveProfile({...profile,key:undefined,model:'next'});assert.equal(readConfig().profiles.custom.key,profile.key);saveProfile(profile); assert.equal(redact(`value ${profile.key}`),'value [REDACTED]'); assert.throws(()=>validateBaseURL('http://remote.example/v1'),/HTTPS/);assert.throws(()=>validateBaseURL('https://key:secret@example.com'),/without credentials/); + for(const lan of ['http://192.168.137.37:1234','http://10.0.0.5:1234','http://172.16.0.1:1234','http://127.0.0.2:1234','http://[fd00::1]:1234','http://[fe80::1]:1234'])assert.equal(validateBaseURL(lan),lan); + for(const remote of ['http://8.8.8.8:1234','http://172.32.0.1','http://192.169.0.1','http://[2001:db8::1]','http://10.0.0.5.nip.io:1234'])assert.throws(()=>validateBaseURL(remote),/HTTPS/); saveProfile({provider:'openrouter',model:'free-test',auth:'api',baseUrl:'https://openrouter.ai/api/v1',key:'fixture-key'});assert.equal(readConfig().profiles.openrouter.baseUrl,'https://openrouter.ai/api'); assert.equal(activateProfile('openrouter').active,'openrouter');assert.throws(()=>activateProfile('gemini'),/Configure/);saveProfile(profile); });