diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 9675afe1..d0033f7c 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { getDb } from '../db/connection.js'; import { encrypt, decrypt } from '../services/crypto.js'; import { config } from '../config.js'; -import { proxyRequest, ProxyConfig, validateUrl } from '../services/proxyService.js'; +import { proxyRequest, ProxyConfig, validateUrl, isPrivateOrLoopback } from '../services/proxyService.js'; import { logger } from '../services/logger.js'; function getProxyConfig(): ProxyConfig | null { @@ -218,7 +218,7 @@ router.post('/api/proxy/ai', async (req, res) => { // Warn if API key is transmitted over non-HTTPS connection try { const parsed = new URL(baseUrl); - if (parsed.protocol !== 'https:') { + if (parsed.protocol !== 'https:' && !isPrivateOrLoopback(parsed.hostname)) { logger.warn('proxy.ai', `AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); } } catch { /* invalid URL, will be caught by validateUrl later */ } @@ -236,7 +236,7 @@ router.post('/api/proxy/ai', async (req, res) => { reasoningEffort = normalizeReasoningEffort(aiConfig.reasoning_effort); try { const parsed = new URL(baseUrl); - if (parsed.protocol !== 'https:') { + if (parsed.protocol !== 'https:' && !isPrivateOrLoopback(parsed.hostname)) { logger.warn('proxy.ai', `AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); } } catch { /* invalid URL, will be caught by validateUrl later */ } @@ -287,6 +287,10 @@ router.post('/api/proxy/ai', async (req, res) => { const timeout = apiType === 'openai-responses' || !!reasoningEffort ? 600000 : 60000; + // 宽松档(放行回环/私有网段)只用于「用户已保存的 AI 配置」(configId)。 + // 内联 config 路径(任意客户端均可携带目标地址)保持严格档,避免 SSRF 放宽被滥用。 + const allowPrivate = Boolean(configId); + const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, @@ -295,6 +299,7 @@ router.post('/api/proxy/ai', async (req, res) => { body: effectiveRequestBody, timeout, proxyConfig, + allowPrivate, }); res.status(result.status).json(result.data); @@ -357,6 +362,8 @@ router.post('/api/proxy/webdav', async (req, res) => { body: requestBody, timeout: 60000, proxyConfig, + // 用户自有配置来源的 WebDAV 地址:放行回环/私有网段(局域网 NAS 等) + allowPrivate: true, }); res.status(result.status).json(result.data); @@ -806,9 +813,9 @@ router.post('/api/download/rpc', async (req, res) => { return; } - // SSRF protection: validate the download URL + // SSRF protection: validate the download URL (allow user-owned private/loopback targets) try { - validateUrl(url); + validateUrl(url, { allowPrivate: true }); } catch (e) { res.status(400).json({ success: false, error: e instanceof Error ? e.message : 'Invalid URL' }); return; diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index 28b77cee..9a7df90f 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -19,6 +19,8 @@ export interface ProxyRequestOptions { timeout?: number; proxyConfig?: ProxyConfig | null; preserveRawResponse?: boolean; + /** 放行回环/私有网段(仅用于用户自有配置来源的 URL)。 */ + allowPrivate?: boolean; } export interface ProxyResponse { @@ -30,32 +32,85 @@ export interface ProxyResponse { const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '169.254.169.254']); const PRIVATE_IP_PATTERNS = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./]; -export function validateUrl(rawUrl: string): void { +export interface ValidateUrlOptions { + /** 宽松模式:放行回环地址与私有网段。仅用于「用户主动保存的自行配置」所来源的 URL + * (如 AI / WebDAV / 下载目标),不应作用于任意或半可信的输入。 */ + allowPrivate?: boolean; +} + +/** 归一化 hostname:去掉 IPv6 方括号、去掉结尾点号(防 `localhost.`/`127.0.0.1.` 绕过), + * 并把 IPv4 映射型 IPv6(如 [::ffff:169.254.169.254])还原为对应的 IPv4 字符串, + * 使 SSRF 过滤看到真实的目标地址。 */ +function normalizeHostname(hostname: string): string { + // Strip trailing dot first to prevent SSRF bypasses (e.g. "localhost." or "127.0.0.1.") + const h = hostname.replace(/\.$/, '').replace(/^\[(.+)\]$/, '$1').toLowerCase(); + // Handle hybrid IPv4-mapped IPv6 written with dotted decimals (::ffff:169.254.169.254) + if (h.startsWith('::ffff:') && h.includes('.')) { + return h.slice(7); + } + // Handle IPv4-mapped IPv6 written in hex form (::ffff:a9fe:a9fe) + const mapped = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (mapped) { + const octets = [mapped[1], mapped[2]].flatMap((hex) => { + const v = parseInt(hex, 16); + return [(v >>> 8) & 0xff, v & 0xff]; + }); + return octets.join('.'); + } + return h; +} + +/** IPv6 私有/本地网段前缀:唯一本地地址 fc00::/7、链路本地 fe80::/10、组播 ff00::/8 */ +function isPrivateOrLocalIpv6(h: string): boolean { + if (!h.includes(':')) return false; + return ( + h.startsWith('fc') || h.startsWith('fd') || + h.startsWith('fe8') || h.startsWith('fe9') || + h.startsWith('fea') || h.startsWith('feb') || + h.startsWith('ff') + ); +} + +/** 判断 hostname 是否为回环地址或私有网段(含 IPv6 私有/本地网段)。 */ +export function isPrivateOrLoopback(hostname: string): boolean { + const h = normalizeHostname(hostname); + if (BLOCKED_HOSTS.has(h)) return true; + if (PRIVATE_IP_PATTERNS.some(p => p.test(h))) return true; + if (isPrivateOrLocalIpv6(h)) return true; + return false; +} + +export function validateUrl(rawUrl: string, opts: ValidateUrlOptions = {}): void { const parsed = new URL(rawUrl); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`Blocked proxy request: unsupported protocol '${parsed.protocol}'`); } - const hostname = parsed.hostname.toLowerCase(); - - // 检查是否是IP地址 - const isIP = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname); - if (isIP) { - // IP地址直接检查是否在阻止列表中 - if (BLOCKED_HOSTS.has(hostname)) { - throw new Error(`Blocked proxy request: IP '${hostname}' is not allowed`); + + // 始终拦截 URL 内嵌的用户名/密码(防止凭证泄露到日志) + if (parsed.username || parsed.password) { + throw new Error(`Blocked proxy request: URL containing credentials is not allowed`); + } + + // 归一化后可同时覆盖明文 169.254.169.254 与 [::ffff:a9fe:a9fe] 等 IPv4 映射型写法。 + const hostname = normalizeHostname(parsed.hostname); + + if (opts.allowPrivate) { + // 宽松模式:仍死守云元数据 IMDS 地址——最高危的 SSRF 目标(云厂商密钥)。 + if (hostname === '169.254.169.254') { + throw new Error(`Blocked proxy request: IMDS address '169.254.169.254' is not allowed`); } + return; } + // 严格模式(默认):拦截回环地址与私有网段(含 IPv6 私有/本地网段)。 if (BLOCKED_HOSTS.has(hostname)) { throw new Error(`Blocked proxy request: hostname '${hostname}' is not allowed`); } if (PRIVATE_IP_PATTERNS.some(p => p.test(hostname))) { throw new Error(`Blocked proxy request: private IP '${hostname}' is not allowed`); } - - // 阻止URL中的用户名和密码(防止凭证泄露) - if (parsed.username || parsed.password) { - throw new Error(`Blocked proxy request: URL containing credentials is not allowed`); + if (isPrivateOrLocalIpv6(hostname)) { + throw new Error(`Blocked proxy request: private IPv6 address '${hostname}' is not allowed`); } } @@ -63,7 +118,7 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise { + const fn = vi.fn(); + fn.isAxiosError = (e: unknown): boolean => + Boolean( + e && + ((e as { isAxiosError?: boolean }).isAxiosError || + (e as { name?: string }).name === 'AbortError') + ); + return { default: fn }; +}); describe('proxyRequest', () => { - let mockFetch: ReturnType; + let mockAxios: ReturnType; beforeEach(() => { - mockFetch = vi.fn(); - globalThis.fetch = mockFetch; + mockAxios = axios as unknown as ReturnType; + mockAxios.mockReset(); }); afterEach(() => { - globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); it('should forward GET request and return JSON response', async () => { const responseData = { items: [1, 2, 3] }; - const headers = new Headers({ 'content-type': 'application/json', 'x-ratelimit': '100' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.resolve(responseData), - text: () => Promise.resolve(JSON.stringify(responseData)), + headers: { 'content-type': 'application/json', 'x-ratelimit': '100' }, + data: responseData, }); const result = await proxyRequest({ url: 'https://api.github.com/user/starred', method: 'GET', - headers: { 'Authorization': 'Bearer test-token' }, + headers: { Authorization: 'Bearer test-token' }, }); expect(result.status).toBe(200); @@ -38,48 +45,44 @@ describe('proxyRequest', () => { expect(result.headers['content-type']).toBe('application/json'); expect(result.headers['x-ratelimit']).toBe('100'); - expect(mockFetch).toHaveBeenCalledOnce(); - const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; - expect(calledUrl).toBe('https://api.github.com/user/starred'); - expect(calledOptions.method).toBe('GET'); - expect(calledOptions.headers['Authorization']).toBe('Bearer test-token'); - expect(calledOptions.body).toBeUndefined(); + expect(mockAxios).toHaveBeenCalledOnce(); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.url).toBe('https://api.github.com/user/starred'); + expect(cfg.method).toBe('get'); // proxyService lowercases method + expect(cfg.headers['Authorization']).toBe('Bearer test-token'); + expect(cfg.data).toBeUndefined(); }); it('should forward POST request with JSON body', async () => { const requestBody = { model: 'gpt-4', messages: [{ role: 'user', content: 'hello' }] }; const responseData = { choices: [{ message: { content: 'hi' } }] }; - const headers = new Headers({ 'content-type': 'application/json' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.resolve(responseData), - text: () => Promise.resolve(JSON.stringify(responseData)), + headers: { 'content-type': 'application/json' }, + data: responseData, }); const result = await proxyRequest({ url: 'https://api.openai.com/v1/chat/completions', method: 'POST', - headers: { 'Authorization': 'Bearer sk-test', 'Content-Type': 'application/json' }, + headers: { Authorization: 'Bearer sk-test', 'Content-Type': 'application/json' }, body: requestBody, }); expect(result.status).toBe(200); expect(result.data).toEqual(responseData); - const [, calledOptions] = mockFetch.mock.calls[0]; - expect(calledOptions.method).toBe('POST'); - expect(calledOptions.body).toBe(JSON.stringify(requestBody)); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.method).toBe('post'); + expect(cfg.data).toEqual(requestBody); // 对象 body 保持原样透传 }); it('should forward POST request with string body', async () => { const xmlBody = ''; - const headers = new Headers({ 'content-type': 'application/xml' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 207, - headers, - json: () => Promise.reject(new Error('not json')), - text: () => Promise.resolve(''), + headers: { 'content-type': 'application/xml' }, + data: '', }); const result = await proxyRequest({ @@ -92,17 +95,15 @@ describe('proxyRequest', () => { expect(result.status).toBe(207); expect(result.data).toBe(''); - const [, calledOptions] = mockFetch.mock.calls[0]; - expect(calledOptions.body).toBe(xmlBody); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.data).toBe(xmlBody); }); it('should auto-set Content-Type to application/json when body is object and no Content-Type header', async () => { - const headers = new Headers({ 'content-type': 'application/json' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.resolve({ ok: true }), - text: () => Promise.resolve('{"ok":true}'), + headers: { 'content-type': 'application/json' }, + data: { ok: true }, }); await proxyRequest({ @@ -111,17 +112,15 @@ describe('proxyRequest', () => { body: { key: 'value' }, }); - const [, calledOptions] = mockFetch.mock.calls[0]; - expect((calledOptions.headers as Record)['Content-Type']).toBe('application/json'); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.headers['Content-Type']).toBe('application/json'); }); it('should NOT attach body for GET requests', async () => { - const headers = new Headers({ 'content-type': 'application/json' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.resolve([]), - text: () => Promise.resolve('[]'), + headers: { 'content-type': 'application/json' }, + data: [], }); await proxyRequest({ @@ -130,17 +129,15 @@ describe('proxyRequest', () => { body: { should: 'be-ignored' }, }); - const [, calledOptions] = mockFetch.mock.calls[0]; - expect(calledOptions.body).toBeUndefined(); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.data).toBeUndefined(); }); it('should NOT attach body for HEAD requests', async () => { - const headers = new Headers({}); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.resolve(null), - text: () => Promise.resolve(''), + headers: {}, + data: null, }); await proxyRequest({ @@ -149,17 +146,15 @@ describe('proxyRequest', () => { body: 'ignored', }); - const [, calledOptions] = mockFetch.mock.calls[0]; - expect(calledOptions.body).toBeUndefined(); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.data).toBeUndefined(); }); it('should return text data when response is not JSON', async () => { - const headers = new Headers({ 'content-type': 'text/html' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.reject(new Error('not json')), - text: () => Promise.resolve('hello'), + headers: { 'content-type': 'text/html' }, + data: 'hello', }); const result = await proxyRequest({ @@ -172,8 +167,13 @@ describe('proxyRequest', () => { }); it('should return 504 on timeout (AbortError)', async () => { - const abortError = new DOMException('The operation was aborted.', 'AbortError'); - mockFetch.mockRejectedValueOnce(abortError); + const abortError = { + name: 'AbortError', + code: 'ECONNABORTED', + message: 'timeout of 100ms exceeded', + isAxiosError: true, + }; + mockAxios.mockRejectedValueOnce(abortError); const result = await proxyRequest({ url: 'https://slow.example.com/api', @@ -186,8 +186,12 @@ describe('proxyRequest', () => { expect(result.headers).toEqual({}); }); - it('should return 502 on network error', async () => { - mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + it('should return 502 with PROXY_CONNECTION_REFUSED on ECONNREFUSED', async () => { + mockAxios.mockRejectedValueOnce({ + isAxiosError: true, + code: 'ECONNREFUSED', + message: 'connect ECONNREFUSED', + }); const result = await proxyRequest({ url: 'https://down.example.com/api', @@ -196,20 +200,18 @@ describe('proxyRequest', () => { expect(result.status).toBe(502); expect(result.data).toEqual({ - error: 'Bad Gateway', - code: 'BAD_GATEWAY', - details: 'ECONNREFUSED', + error: 'Proxy connection refused', + code: 'PROXY_CONNECTION_REFUSED', + details: 'connect ECONNREFUSED', }); expect(result.headers).toEqual({}); }); - it('should pass abort signal to fetch', async () => { - const headers = new Headers({ 'content-type': 'application/json' }); - mockFetch.mockResolvedValueOnce({ + it('should forward the timeout value into the axios config', async () => { + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.resolve({ ok: true }), - text: () => Promise.resolve('{"ok":true}'), + headers: { 'content-type': 'application/json' }, + data: { ok: true }, }); await proxyRequest({ @@ -218,17 +220,15 @@ describe('proxyRequest', () => { timeout: 5000, }); - const [, calledOptions] = mockFetch.mock.calls[0]; - expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.timeout).toBe(5000); }); it('should handle upstream 4xx/5xx status codes transparently', async () => { - const headers = new Headers({ 'content-type': 'application/json' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 403, - headers, - json: () => Promise.resolve({ message: 'Forbidden' }), - text: () => Promise.resolve('{"message":"Forbidden"}'), + headers: { 'content-type': 'application/json' }, + data: { message: 'Forbidden' }, }); const result = await proxyRequest({ @@ -241,43 +241,69 @@ describe('proxyRequest', () => { }); it('should use default timeout of 30000ms', async () => { - const headers = new Headers({ 'content-type': 'application/json' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 200, - headers, - json: () => Promise.resolve({}), - text: () => Promise.resolve('{}'), + headers: { 'content-type': 'application/json' }, + data: {}, }); - // Just verify it doesn't throw — the default timeout is internal const result = await proxyRequest({ url: 'https://example.com/api', method: 'GET', }); expect(result.status).toBe(200); + expect(mockAxios.mock.calls[0][0].timeout).toBe(30000); }); it('should handle PUT method with body for WebDAV', async () => { - const headers = new Headers({ 'content-type': 'text/plain' }); - mockFetch.mockResolvedValueOnce({ + mockAxios.mockResolvedValueOnce({ status: 201, - headers, - json: () => Promise.reject(new Error('not json')), - text: () => Promise.resolve(''), + headers: { 'content-type': 'text/plain' }, + data: '', }); const result = await proxyRequest({ url: 'https://dav.example.com/remote.php/dav/backup.json', method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: '{"repos":[]}', + body: '{"repo":[]}', }); expect(result.status).toBe(201); - const [, calledOptions] = mockFetch.mock.calls[0]; - expect(calledOptions.method).toBe('PUT'); - expect(calledOptions.body).toBe('{"repos":[]}'); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.method).toBe('put'); + expect(cfg.data).toBe('{"repo":[]}'); + }); + + it('should forward to axios for a private-IP URL when allowPrivate is set (fixes #244 local AI)', async () => { + mockAxios.mockResolvedValueOnce({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { ok: true }, + }); + + const result = await proxyRequest({ + url: 'http://192.168.1.10:11434/v1/chat/completions', + method: 'POST', + headers: { Authorization: 'Bearer local' }, + body: { model: 'llama', messages: [] }, + allowPrivate: true, + }); + + expect(result.status).toBe(200); + const cfg = mockAxios.mock.calls[0][0]; + expect(cfg.url).toBe('http://192.168.1.10:11434/v1/chat/completions'); + }); + + it('should return 502 without calling axios for a private-IP URL in strict mode', async () => { + const result = await proxyRequest({ + url: 'http://192.168.1.10:11434/v1/chat/completions', + method: 'POST', + }); + + expect(result.status).toBe(502); + expect(mockAxios).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/services/proxyService.test.ts b/server/tests/services/proxyService.test.ts new file mode 100644 index 00000000..8286465a --- /dev/null +++ b/server/tests/services/proxyService.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { validateUrl, isPrivateOrLoopback } from '../../src/services/proxyService.js'; + +describe('validateUrl', () => { + describe('protocol checks (both modes)', () => { + it('accepts http and https', () => { + expect(() => validateUrl('https://api.openai.com/v1/chat/completions')).not.toThrow(); + expect(() => validateUrl('http://example.com/api')).not.toThrow(); + }); + + it('rejects non-http(s) protocols', () => { + expect(() => validateUrl('file:///etc/passwd')).toThrow(/unsupported protocol/); + expect(() => validateUrl('gopher://example.com')).toThrow(/unsupported protocol/); + expect(() => validateUrl('ftp://example.com/x')).toThrow(/unsupported protocol/); + }); + + it('always rejects URLs with embedded credentials', () => { + expect(() => validateUrl('https://user:pass@example.com/x')).toThrow(/credentials is not allowed/); + // Even in lenient mode credentials must never pass through + expect(() => validateUrl('http://user:pass@192.168.1.10:11434/x', { allowPrivate: true })).toThrow(/credentials is not allowed/); + }); + }); + + describe('strict mode (default) blocks loopback + private', () => { + it('blocks localhost / loopback', () => { + expect(() => validateUrl('http://localhost:11434/v1/chat/completions')).toThrow(/hostname 'localhost' is not allowed/); + expect(() => validateUrl('http://127.0.0.1:11434/v1/chat/completions')).toThrow(/hostname '127.0.0.1' is not allowed/); + expect(() => validateUrl('http://[::1]:11434/x')).toThrow(/hostname '::1' is not allowed/); + }); + + it('blocks private network ranges', () => { + expect(() => validateUrl('http://10.10.16.13/v1/chat/completions')).toThrow(/private IP '10.10.16.13' is not allowed/); + expect(() => validateUrl('http://172.16.0.5/x')).toThrow(/private IP/); + expect(() => validateUrl('http://172.31.255.255/x')).toThrow(/private IP/); + expect(() => validateUrl('http://192.168.1.10:11434/v1/chat/completions')).toThrow(/private IP '192.168.1.10' is not allowed/); + }); + + it('blocks the cloud metadata IMDS address', () => { + expect(() => validateUrl('http://169.254.169.254/latest/meta-data/')).toThrow(/hostname '169.254.169.254' is not allowed/); + }); + + it('blocks IPv4-mapped IPv6 forms (e.g. [::ffff:169.254.169.254])', () => { + expect(() => validateUrl('http://[::ffff:169.254.169.254]/x')).toThrow(/is not allowed/); + expect(() => validateUrl('http://[::ffff:127.0.0.1]/x')).toThrow(/is not allowed/); + expect(() => validateUrl('http://[::ffff:192.168.1.10]/x')).toThrow(/private IP/); + }); + + it('blocks trailing-dot SSRF bypasses', () => { + expect(() => validateUrl('http://localhost./x')).toThrow(/hostname 'localhost' is not allowed/); + expect(() => validateUrl('http://127.0.0.1./x')).toThrow(/hostname '127.0.0.1' is not allowed/); + expect(() => validateUrl('http://169.254.169.254./x')).toThrow(/hostname '169.254.169.254' is not allowed/); + }); + + it('blocks IPv6 private/local ranges', () => { + expect(() => validateUrl('http://[fd00::1]/x')).toThrow(/private IPv6 address/); + expect(() => validateUrl('http://[fe80::1]/x')).toThrow(/private IPv6 address/); + expect(() => validateUrl('http://[fc00::1]/x')).toThrow(/private IPv6 address/); + expect(() => validateUrl('http://[ff02::1]/x')).toThrow(/private IPv6 address/); + }); + + it('still allows public IPv6 addresses in strict mode', () => { + expect(() => validateUrl('http://[2001:db8::1]/x')).not.toThrow(); + }); + }); + + describe('lenient mode (allowPrivate) for user-owned configs', () => { + it('allows localhost / loopback', () => { + expect(() => validateUrl('http://localhost:11434/v1/chat/completions', { allowPrivate: true })).not.toThrow(); + expect(() => validateUrl('http://127.0.0.1:11434/v1/chat/completions', { allowPrivate: true })).not.toThrow(); + }); + + it('allows private network ranges (fixes #244 local AI / NAS)', () => { + expect(() => validateUrl('http://10.10.16.13/v1/chat/completions', { allowPrivate: true })).not.toThrow(); + expect(() => validateUrl('http://192.168.1.10:11434/v1/chat/completions', { allowPrivate: true })).not.toThrow(); + expect(() => validateUrl('http://172.16.0.5/x', { allowPrivate: true })).not.toThrow(); + }); + + it('STILL blocks the cloud metadata IMDS address even when lenient', () => { + expect(() => validateUrl('http://169.254.169.254/latest/meta-data/', { allowPrivate: true })).toThrow(/IMDS address/); + }); + + it('STILL blocks IPv4-mapped IPv6 IMDS even when lenient', () => { + expect(() => validateUrl('http://[::ffff:169.254.169.254]/x', { allowPrivate: true })).toThrow(/IMDS address/); + }); + }); + + describe('isPrivateOrLoopback', () => { + it('detects loopback and private ranges', () => { + expect(isPrivateOrLoopback('localhost')).toBe(true); + expect(isPrivateOrLoopback('127.0.0.1')).toBe(true); + expect(isPrivateOrLoopback('::1')).toBe(true); + expect(isPrivateOrLoopback('10.10.16.13')).toBe(true); + expect(isPrivateOrLoopback('192.168.0.5')).toBe(true); + expect(isPrivateOrLoopback('172.20.1.1')).toBe(true); + }); + + it('detects tailing-dot and IPv4-mapped bypass forms', () => { + expect(isPrivateOrLoopback('localhost.')).toBe(true); + expect(isPrivateOrLoopback('127.0.0.1.')).toBe(true); + expect(isPrivateOrLoopback('[::ffff:127.0.0.1]')).toBe(true); + expect(isPrivateOrLoopback('[::ffff:192.168.1.10]')).toBe(true); + }); + + it('detects IPv6 private/local ranges', () => { + expect(isPrivateOrLoopback('fd00::1')).toBe(true); + expect(isPrivateOrLoopback('fe80::1')).toBe(true); + expect(isPrivateOrLoopback('fc00::1')).toBe(true); + expect(isPrivateOrLoopback('ff02::1')).toBe(true); + }); + + it('returns false for public hosts', () => { + expect(isPrivateOrLoopback('api.openai.com')).toBe(false); + expect(isPrivateOrLoopback('8.8.8.8')).toBe(false); + expect(isPrivateOrLoopback('2001:db8::1')).toBe(false); + }); + }); +});