From cce57a08cb8db00c7d4471fbfd8718fcd21997c6 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 13 Jul 2026 14:33:42 +0800 Subject: [PATCH 1/2] fix(server): allow user-owned AI/WebDAV/RPC configs to use private/loopback IPs (fixes #244) validateUrl now accepts an allowPrivate option. User-saved config endpoints (AI proxy, WebDAV proxy, RPC download target) use lenient mode so local Ollama / LAN NAS / internal download targets work over http on private addresses. SSRF protection retained: - protocols restricted to http/https - credentials embedded in URL always blocked - cloud metadata IMDS (169.254.169.254) always blocked - IPv4-mapped IPv6 ([::ffff:a.b.c.d]) now normalized and no longer bypasses the filters - semi-trusted inputs (github proxy) stay strict Also makes proxy.test.ts deterministic via vi.mock('axios'). --- server/src/routes/proxy.ts | 14 +- server/src/services/proxyService.ts | 63 +++++-- server/tests/routes/proxy.test.ts | 204 ++++++++++++--------- server/tests/services/proxyService.test.ts | 85 +++++++++ 4 files changed, 255 insertions(+), 111 deletions(-) create mode 100644 server/tests/services/proxyService.test.ts diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 9675afe1..6192516e 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 */ } @@ -295,6 +295,8 @@ router.post('/api/proxy/ai', async (req, res) => { body: effectiveRequestBody, timeout, proxyConfig, + // 用户自有配置来源的 AI 地址:放行回环/私有网段(本地 Ollama 等) + allowPrivate: true, }); res.status(result.status).json(result.data); @@ -357,6 +359,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 +810,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..b0a52202 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,40 +32,71 @@ 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 方括号,并把 IPv4 映射型 IPv6(如 [::ffff:169.254.169.254]) + * 还原为对应的 IPv4 字符串,使 SSRF 过滤看到真实的目标地址。 */ +function normalizeHostname(hostname: string): string { + const h = hostname.replace(/^\[(.+)\]$/, '$1').toLowerCase(); + 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; +} + +/** 判断 hostname 是否为回环地址或私有网段。 */ +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; + 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; } + // 严格模式(默认):拦截回环地址与私有网段。 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`); - } } export async function proxyRequest(options: ProxyRequestOptions): Promise { const { url, method, headers = {}, body, timeout = 30000, proxyConfig, preserveRawResponse = false } = options; try { - validateUrl(url); + validateUrl(url, { allowPrivate: options.allowPrivate }); logger.info('proxy.request', `${method} ${redactUrl(url)}`); const axiosConfig: AxiosRequestConfig = { diff --git a/server/tests/routes/proxy.test.ts b/server/tests/routes/proxy.test.ts index 4223233a..25922c5b 100644 --- a/server/tests/routes/proxy.test.ts +++ b/server/tests/routes/proxy.test.ts @@ -1,36 +1,43 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import axios from 'axios'; import { proxyRequest } from '../../src/services/proxyService.js'; -// Store original fetch -const originalFetch = globalThis.fetch; +// 确定性测试:mock axios,避免对真实外网的依赖(原测试依赖网络连通性,环境不通时随机失败)。 +vi.mock('axios', () => { + 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', @@ -187,7 +187,7 @@ describe('proxyRequest', () => { }); it('should return 502 on network error', async () => { - mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + mockAxios.mockRejectedValueOnce(new Error('ECONNREFUSED')); const result = await proxyRequest({ url: 'https://down.example.com/api', @@ -203,13 +203,11 @@ describe('proxyRequest', () => { 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 +216,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 +237,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..06a30c9a --- /dev/null +++ b/server/tests/services/proxyService.test.ts @@ -0,0 +1,85 @@ +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/); + }); + }); + + 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('returns false for public hosts', () => { + expect(isPrivateOrLoopback('api.openai.com')).toBe(false); + expect(isPrivateOrLoopback('8.8.8.8')).toBe(false); + }); + }); +}); From 967fa0324b33e57f40c94138a5373d7b12ab6e53 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 13 Jul 2026 14:51:42 +0800 Subject: [PATCH 2/2] fix(security): harden SSRF validation per review audit - strip trailing dot in hostname to block localhost./127.0.0.1. bypasses - handle hybrid IPv4-mapped IPv6 written with dotted decimals (::ffff:169.254.169.254) - block IPv6 private/local ranges (ULA fc00::/7, link-local fe80::/10, multicast ff00::/8) in both isPrivateOrLoopback and validateUrl strict mode - restrict AI proxy allowPrivate to saved configId branch; inline AI requests keep strict mode to avoid SSRF relaxation abuse - fix proxy.test ECONNREFUSED mock to be axios-shaped so it exercises the dedicated PROXY_CONNECTION_REFUSED branch - add regression tests for trailing-dot and IPv6 private ranges --- server/src/routes/proxy.ts | 7 +++-- server/src/services/proxyService.ts | 32 ++++++++++++++++++---- server/tests/routes/proxy.test.ts | 14 ++++++---- server/tests/services/proxyService.test.ts | 32 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 6192516e..d0033f7c 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -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,8 +299,7 @@ router.post('/api/proxy/ai', async (req, res) => { body: effectiveRequestBody, timeout, proxyConfig, - // 用户自有配置来源的 AI 地址:放行回环/私有网段(本地 Ollama 等) - allowPrivate: true, + allowPrivate, }); res.status(result.status).json(result.data); diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index b0a52202..9a7df90f 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -38,10 +38,17 @@ export interface ValidateUrlOptions { allowPrivate?: boolean; } -/** 归一化 hostname:去掉 IPv6 方括号,并把 IPv4 映射型 IPv6(如 [::ffff:169.254.169.254]) - * 还原为对应的 IPv4 字符串,使 SSRF 过滤看到真实的目标地址。 */ +/** 归一化 hostname:去掉 IPv6 方括号、去掉结尾点号(防 `localhost.`/`127.0.0.1.` 绕过), + * 并把 IPv4 映射型 IPv6(如 [::ffff:169.254.169.254])还原为对应的 IPv4 字符串, + * 使 SSRF 过滤看到真实的目标地址。 */ function normalizeHostname(hostname: string): string { - const h = hostname.replace(/^\[(.+)\]$/, '$1').toLowerCase(); + // 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) => { @@ -53,11 +60,23 @@ function normalizeHostname(hostname: string): string { return h; } -/** 判断 hostname 是否为回环地址或私有网段。 */ +/** 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; } @@ -83,13 +102,16 @@ export function validateUrl(rawUrl: string, opts: ValidateUrlOptions = {}): void 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`); } + if (isPrivateOrLocalIpv6(hostname)) { + throw new Error(`Blocked proxy request: private IPv6 address '${hostname}' is not allowed`); + } } export async function proxyRequest(options: ProxyRequestOptions): Promise { diff --git a/server/tests/routes/proxy.test.ts b/server/tests/routes/proxy.test.ts index 25922c5b..55d522c4 100644 --- a/server/tests/routes/proxy.test.ts +++ b/server/tests/routes/proxy.test.ts @@ -186,8 +186,12 @@ describe('proxyRequest', () => { expect(result.headers).toEqual({}); }); - it('should return 502 on network error', async () => { - mockAxios.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,9 +200,9 @@ 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({}); }); diff --git a/server/tests/services/proxyService.test.ts b/server/tests/services/proxyService.test.ts index 06a30c9a..8286465a 100644 --- a/server/tests/services/proxyService.test.ts +++ b/server/tests/services/proxyService.test.ts @@ -44,6 +44,23 @@ describe('validateUrl', () => { 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', () => { @@ -77,9 +94,24 @@ describe('validateUrl', () => { 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); }); }); });