Skip to content

fix(server): allow user-owned AI/WebDAV/RPC configs to use private IPs (fixes #244)#246

Merged
AmintaCCCP merged 2 commits into
mainfrom
fix/ssrf-allow-private-local-ai
Jul 13, 2026
Merged

fix(server): allow user-owned AI/WebDAV/RPC configs to use private IPs (fixes #244)#246
AmintaCCCP merged 2 commits into
mainfrom
fix/ssrf-allow-private-local-ai

Conversation

@AmintaCCCP

@AmintaCCCP AmintaCCCP commented Jul 13, 2026

Copy link
Copy Markdown
Owner

问题

v0.7.0 配置局域网/本机 AI(如 Ollama http://10.10.16.13192.168.x.x)时报错:
Blocked proxy request: private IP '10.10.16.13' is not allowed
AI API key transmitted over http ... 只是无害日志警告,不是失败原因——失败来自 validateUrl 对私有 IP 的拦截。

根因

server/src/services/proxyService.tsvalidateUrl 一刀切拦截回环/私有网段,被 AI 代理、WebDAV 代理、RPC 下载目标共用。但 AI / WebDAV 的 baseUrl用户自己存进 DB 的服务地址,属可信来源,应与任意/半可信输入区别对待。

修复

  • validateUrl(rawUrl, { allowPrivate }) 新增宽松档:放行回环/私有网段,仅用于用户自有配置来源的 URL(零额外配置)。
  • 始终保留的 SSRF 防护:仅允许 http/https;URL 内嵌凭证始终拦截;云元数据 IMDS 169.254.169.254 无论如何始终拦截。
  • AI 代理、WebDAV 代理、RPC 下载目标改走宽松档 → 本地 Ollama / 局域网 NAS / 内网下载目标可用。
  • github 代理(半可信输入)保持严格档。
  • 审计补充:[::ffff:a.b.c.d] IPv4 映射型 IPv6 原会绕过过滤,现已归一化为对应 IPv4 再校验。
  • proxy.test.ts 改为 vi.mock('axios') 确定性测试(原测试依赖真实外网,环境不通时随机失败)。

验证

  • npx vitest run → 51/51 通过
  • npx tsc --noEmit → 0 错误

不受影响(确认无需改动)

  • 向量搜索 embedding、WebDAV 备份/恢复:均前端浏览器直连,不过服务端 validateUrl
  • 网络代理 proxyConfig:作为出口代理传给 axios,非被请求目标。

Closes #244

Summary by CodeRabbit

  • Security
    • Strengthened proxy URL validation for private/loopback and IPv6 scenarios, while continuing to block cloud metadata and URLs with embedded credentials.
    • Private/loopback destinations are now permitted where appropriate for AI proxying (saved configurations) and WebDAV proxying, and allowed for RPC downloads.
  • Bug Fixes
    • Improved proxy request handling for methods, request bodies, content types, timeouts, and clearer mapping of upstream/proxy errors.
    • Kept upstream non-success responses more faithfully.
  • Tests
    • Expanded automated coverage for proxy behavior, URL validation rules, private destination handling, and error translation.

…opback 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').
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Private and loopback URL handling is added to proxy validation. AI, WebDAV, and RPC routes can explicitly permit these destinations, while credentials and the cloud metadata endpoint remain blocked. Proxy tests now mock axios and cover validation, forwarding, responses, and errors.

Changes

Proxy validation and routing

Layer / File(s) Summary
URL validation and private-host classification
server/src/services/proxyService.ts, server/tests/services/proxyService.test.ts
Proxy options support allowPrivate; hostname normalization and private/loopback classification cover IPv6 and mapped IPv6 forms. Validation retains credential and metadata endpoint blocking.
Route-level private destination allowance
server/src/routes/proxy.ts
AI and WebDAV proxy requests, plus RPC downloads, explicitly allow private destinations; AI HTTP warnings exclude private or loopback hosts.
Axios proxy behavior coverage
server/tests/routes/proxy.test.ts
Tests mock axios and verify request bodies, headers, methods, timeouts, response handling, errors, status passthrough, and private-target behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: allowing private IPs for user-owned AI/WebDAV/RPC configs while fixing #244.
Linked Issues check ✅ Passed The changes address #244 by allowing saved AI configs to reach private/loopback hosts while keeping SSRF and IMDS protections.
Out of Scope Changes check ✅ Passed The WebDAV/RPC support and SSRF hardening are consistent with the stated PR objectives, not unrelated changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ssrf-allow-private-local-ai

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an allowPrivate option to the proxy service to permit loopback and private network addresses for user-configured services (such as local AI or WebDAV) while maintaining SSRF protection for cloud metadata IMDS. It also refactors proxy tests to use a mocked axios client for deterministic testing. The review feedback recommends strengthening SSRF protections by normalizing hostnames to strip trailing dots, handling hybrid IPv4-mapped IPv6 addresses, blocking IPv6 private and local ranges in both strict and lenient modes, and adding corresponding unit tests.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +43 to +54
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

To prevent SSRF bypasses, we should normalize hostnames by stripping trailing dots (e.g., localhost. or 127.0.0.1.) and correctly handling hybrid IPv4-mapped IPv6 addresses (e.g., ::ffff:169.254.169.254). Without this, these patterns can bypass the blocked hosts and private IP checks.

function normalizeHostname(hostname: string): string {
  // Strip trailing dot if present to prevent SSRF bypasses (e.g., "localhost.")
  const h = hostname.replace(/\.$/, '').replace(/^\[(.+)\]$/, '$1').toLowerCase();
  
  // Handle hybrid IPv4-mapped IPv6 (e.g., ::ffff:169.254.169.254)
  if (h.startsWith('::ffff:') && h.includes('.')) {
    return h.slice(7);
  }

  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;
}

Comment on lines +57 to +62
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

We should also block IPv6 private and local ranges (Unique Local Addresses fc00::/7, Link-Local fe80::/10, and Multicast ff00::/8) to prevent SSRF via 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;
  
  // Block IPv6 private/local ranges (ULA, Link-Local, Multicast)
  if (
    h.startsWith('fc') ||
    h.startsWith('fd') ||
    h.startsWith('fe8') ||
    h.startsWith('fe9') ||
    h.startsWith('fea') ||
    h.startsWith('feb') ||
    h.startsWith('ff')
  ) {
    if (h.includes(':')) return true;
  }
  return false;
}

Comment thread server/src/services/proxyService.ts Outdated
Comment on lines 86 to 93
// 严格模式(默认):拦截回环地址与私有网段。
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`);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Ensure that IPv6 private and local ranges are also blocked in strict mode within validateUrl.

  // 严格模式(默认):拦截回环地址与私有网段。
  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 (
    hostname.startsWith('fc') ||
    hostname.startsWith('fd') ||
    hostname.startsWith('fe8') ||
    hostname.startsWith('fe9') ||
    hostname.startsWith('fea') ||
    hostname.startsWith('feb') ||
    hostname.startsWith('ff')
  ) {
    if (hostname.includes(':')) {
      throw new Error(`Blocked proxy request: private IPv6 address '${hostname}' is not allowed`);
    }
  }
}

Comment on lines +42 to +46
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/);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add unit tests to verify that trailing dot bypasses and IPv6 private/local ranges are correctly blocked.

    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 bypasses', () => {
      expect(() => validateUrl('http://localhost./x')).toThrow(/is not allowed/);
      expect(() => validateUrl('http://127.0.0.1./x')).toThrow(/is not allowed/);
    });

    it('blocks IPv6 private and local ranges', () => {
      expect(() => validateUrl('http://[fd00::1]/x')).toThrow(/private IPv6 address/);
      expect(() => validateUrl('http://[fe80::1]/x')).toThrow(/private IPv6 address/);
    });

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/src/routes/proxy.ts (1)

290-300: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict private-network access to saved AI configs Inline requests can still reach private/loopback targets here, so allowPrivate: true should be limited to the configId branch. That keeps the relaxed SSRF allowance scoped to user-saved configs instead of every API client with the shared secret.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/routes/proxy.ts` around lines 290 - 300, Update the proxyRequest
options in the configId handling branch so allowPrivate is true only for saved
AI configurations. Remove the unconditional allowPrivate: true from the shared
request path, and ensure inline requests retain the default private-network
restriction while configId requests keep the relaxed allowance.
🧹 Nitpick comments (1)
server/tests/routes/proxy.test.ts (1)

189-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mock doesn't actually exercise the ECONNREFUSED-specific axios-error branch.

The mocked rejection is a plain Error('ECONNREFUSED') with no isAxiosError/code, so axios.isAxiosError(error) returns false inside proxyRequest, and the assertion is satisfied by the generic BAD_GATEWAY fallback — not the dedicated error.code === 'ECONNREFUSED'PROXY_CONNECTION_REFUSED branch that a real axios connection-refused error would hit. Worth shaping the mock like a real AxiosError to actually cover that branch.

🧪 Proposed fix
-    mockAxios.mockRejectedValueOnce(new Error('ECONNREFUSED'));

     const result = await proxyRequest({
       url: 'https://down.example.com/api',
       method: 'GET',
     });

-    expect(result.status).toBe(502);
-    expect(result.data).toEqual({
-      error: 'Bad Gateway',
-      code: 'BAD_GATEWAY',
-      details: 'ECONNREFUSED',
-    });
+    expect(result.status).toBe(502);
+    expect(result.data).toEqual({
+      error: 'Proxy connection refused',
+      code: 'PROXY_CONNECTION_REFUSED',
+      details: 'connect ECONNREFUSED',
+    });
     expect(result.headers).toEqual({});

And update the rejection above it to:

+    mockAxios.mockRejectedValueOnce({
+      isAxiosError: true,
+      code: 'ECONNREFUSED',
+      message: 'connect ECONNREFUSED',
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/tests/routes/proxy.test.ts` around lines 189 - 204, Update the mock
rejection in the “should return 502 on network error” test to use an
Axios-shaped error with isAxiosError true and code ECONNREFUSED, so proxyRequest
exercises the dedicated connection-refused branch and asserts its corresponding
response code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/src/routes/proxy.ts`:
- Around line 290-300: Update the proxyRequest options in the configId handling
branch so allowPrivate is true only for saved AI configurations. Remove the
unconditional allowPrivate: true from the shared request path, and ensure inline
requests retain the default private-network restriction while configId requests
keep the relaxed allowance.

---

Nitpick comments:
In `@server/tests/routes/proxy.test.ts`:
- Around line 189-204: Update the mock rejection in the “should return 502 on
network error” test to use an Axios-shaped error with isAxiosError true and code
ECONNREFUSED, so proxyRequest exercises the dedicated connection-refused branch
and asserts its corresponding response code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aeb0cbc3-b556-4f78-9e75-493eb0815ca3

📥 Commits

Reviewing files that changed from the base of the PR and between 3adaef4 and cce57a0.

📒 Files selected for processing (4)
  • server/src/routes/proxy.ts
  • server/src/services/proxyService.ts
  • server/tests/routes/proxy.test.ts
  • server/tests/services/proxyService.test.ts

- 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
@AmintaCCCP

Copy link
Copy Markdown
Owner Author

已根据审计意见全部修复并推送到本分支:

安全(阻断绕过,HIGH)

  • normalizeHostname 现在先去掉结尾点号,阻止 localhost. / 127.0.0.1. 这类尾部点绕过;并同时覆盖 IPv4 映射型 IPv6 的两种写法:点分形式 ::ffff:169.254.169.254 与十六进制形式 ::ffff:a9fe:a9fe
  • isPrivateOrLoopbackvalidateUrl 严格模式都新增 IPv6 私有/本地网段拦截:唯一本地 fc00::/7、链路本地 fe80::/10、组播 ff00::/8(仅在确为 IPv6 时判定,公网 IPv6 如 2001:db8::1 不受影响)。

权限收窄(MAJOR)

  • AI 代理的 allowPrivate(放行回环/私有网段)现在作用于已保存配置的路径(configId)。内联 config 路径保持严格档,避免任意客户端借共享密钥滥用 SSRF 放宽。WebDAV(始终走已保存配置)与 RPC 下载目标保持不变。

测试(MEDIUM / 质量)

  • 新增尾部点绕过、IPv6 私有/本地网段的回归测试;isPrivateOrLoopback 补 IPv6 与映射型写法用例。
  • 修正 proxy.test.ts 的 ECONNREFUSED mock,改为 axios 形态,真正覆盖 PROXY_CONNECTION_REFUSED 分支。

验证:npx vitest run 56/56 通过;npx tsc --noEmit 0 错误。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
server/tests/services/proxyService.test.ts (1)

54-59: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test names say "private/local" but include multicast ff02::1.

ff02::1 (ff00::/8) is a multicast address, not strictly private or local. The test descriptions at lines 54 and 104 say "IPv6 private/local ranges" which is slightly misleading. Consider updating the test names to include "multicast" for accuracy.

✏️ Optional naming fix
-    it('blocks IPv6 private/local ranges', () => {
+    it('blocks IPv6 private/local/multicast ranges', () => {
-    it('detects IPv6 private/local ranges', () => {
+    it('detects IPv6 private/local/multicast ranges', () => {

Also applies to: 104-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/tests/services/proxyService.test.ts` around lines 54 - 59, Update the
test descriptions covering the IPv6 range cases near validateUrl to mention
multicast in addition to private/local, accurately reflecting the ff02::1
assertion. Apply the naming change to both affected tests while leaving their
assertions and behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/tests/services/proxyService.test.ts`:
- Around line 54-59: Update the test descriptions covering the IPv6 range cases
near validateUrl to mention multicast in addition to private/local, accurately
reflecting the ff02::1 assertion. Apply the naming change to both affected tests
while leaving their assertions and behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5798bb4b-5729-48af-88f6-8324c52c7e72

📥 Commits

Reviewing files that changed from the base of the PR and between cce57a0 and 967fa03.

📒 Files selected for processing (4)
  • server/src/routes/proxy.ts
  • server/src/services/proxyService.ts
  • server/tests/routes/proxy.test.ts
  • server/tests/services/proxyService.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • server/src/routes/proxy.ts
  • server/src/services/proxyService.ts
  • server/tests/routes/proxy.test.ts

@AmintaCCCP
AmintaCCCP merged commit 1dedeb5 into main Jul 13, 2026
5 checks passed
@AmintaCCCP
AmintaCCCP deleted the fix/ssrf-allow-private-local-ai branch July 13, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] v0.7.0 不支持本地AI的http模型吗?:AI API key transmitted over http: (not HTTPS). Consider using HTTPS for security.

1 participant