Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion lib/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
2 changes: 2 additions & 0 deletions tests/backend.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down