Security: 修复SSRF漏洞,添加URL验证防护 - #234
Conversation
修复了 /api/v1/config/test-llm 和相关端点的SSRF(服务器端请求伪造)漏洞。 攻击者之前可以通过提供内网地址来探测内部网络服务。 主要改动: - 添加 is_safe_url() 函数,验证URL是否指向内网地址 - 阻止所有内网IP范围(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16等) - 阻止IPv6内网地址(::1, fc00::/7, fe80::/10) - 对域名进行DNS解析并检查解析后的IP地址 受保护的端点: - POST /api/v1/config/test-llm (测试LLM连接) - PUT /api/v1/config/me (更新用户配置) - POST /api/v1/embedding-config/test (测试嵌入模型) - PUT /api/v1/embedding-config/config (更新嵌入配置) 安全影响: - 修复前:攻击者可探测内网服务、绕过防火墙、访问内部API - 修复后:所有内网地址访问被阻止,返回明确错误信息 Fixes lintsinghua#137
PR Summary by QodoBlock SSRF by validating user-supplied base URLs in config and embedding endpoints
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
Context used 1. is_safe_url() misses IPv6-mapped IPs
|
| # 尝试解析为IP地址 | ||
| try: | ||
| ip = ipaddress.ip_address(hostname) | ||
| # 检查是否为内网IP | ||
| for network in INTERNAL_IP_RANGES: | ||
| if ip in network: | ||
| return False | ||
| return True | ||
| except ValueError: | ||
| # 不是IP地址,是域名,需要解析 | ||
| try: | ||
| # 解析域名获取IP地址 | ||
| resolved_ip = socket.gethostbyname(hostname) | ||
| ip = ipaddress.ip_address(resolved_ip) | ||
| # 检查解析后的IP是否为内网IP | ||
| for network in INTERNAL_IP_RANGES: | ||
| if ip in network: | ||
| return False | ||
| return True | ||
| except (socket.gaierror, ValueError): | ||
| # 域名解析失败,拒绝访问 | ||
| return False |
There was a problem hiding this comment.
1. is_safe_url() misses ipv6-mapped ips 📎 Requirement gap ⛨ Security
The new SSRF URL validation in is_safe_url() can be bypassed because it only resolves and checks a single IPv4 address via socket.gethostbyname (missing AAAA and multi-A records) and does not block IPv4-mapped IPv6 loopback/private addresses like ::ffff:127.0.0.1. As a result, endpoints such as /api/v1/config/test-llm may still be able to reach internal targets despite the intended protections.
Agent Prompt
## Issue description
`is_safe_url()` is intended to prevent SSRF by blocking connections to internal/private/link-local targets, but it can currently be bypassed because hostname validation only checks a single IPv4 address returned by `socket.gethostbyname()` (missing AAAA and multi-A records), and IP-literal validation does not correctly block IPv4-mapped IPv6 addresses (e.g., `::ffff:127.0.0.1`) that should be treated as loopback/private IPv4.
## Issue Context
- Compliance requires hostname resolution checks to prevent SSRF to localhost/private/link-local networks.
- Outbound requests are made with `httpx.AsyncClient` (or similar), which performs its own DNS resolution and may connect using any returned address family/record; therefore SSRF validation must evaluate **all** resolvable A/AAAA addresses and fail closed if any resolve to a disallowed range.
- Current logic parses the hostname/IP using `ipaddress.ip_address()` and checks membership via `ip in network` against `INTERNAL_IP_RANGES`, which does not catch IPv6-mapped IPv4 unless explicitly handled.
## Fix Focus Areas
- backend/app/api/v1/endpoints/config.py[51-104]
- backend/app/api/v1/endpoints/embedding_config.py[27-81]
- backend/app/services/rag/embeddings.py[93-97]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| # SSRF防护:验证baseUrl是否安全 | ||
| if request.baseUrl and not is_safe_url(request.baseUrl): | ||
| print(f"[LLM Test] SSRF防护: 拒绝访问内网地址 {request.baseUrl}") | ||
| debug_info["error_type"] = "ssrf_blocked" | ||
| debug_info["error_category"] = "security" | ||
| return LLMTestResponse( | ||
| success=False, | ||
| message="不允许访问内网地址,请使用公网API地址", | ||
| debug=debug_info | ||
| ) | ||
|
|
There was a problem hiding this comment.
2. Localhost default bypass 🐞 Bug ⛨ Security
test-llm/test-embedding 只在用户显式提供 baseUrl/base_url 时校验,但当该字段为空时,下游仍会回退到 Ollama 的 localhost 默认地址并发起请求,SSRF 仍可通过选择 provider=ollama 触发。此问题使“禁止内网访问”的修复不完整。
Agent Prompt
## Issue description
`/api/v1/config/test-llm` 和 `/api/v1/embedding-config/test` 当前只校验请求体里“用户提供的 URL 字段”,但当该字段为空时,实际发起请求的 base URL 会由下游适配器/Provider 使用默认值(如 Ollama 的 `http://localhost:11434/...`),从而仍可触发对内网/本机地址的 SSRF。
## Issue Context
- `test_llm_connection` 在构造 `LLMConfig` 时把 `base_url` 设为 `request.baseUrl`(可能为 `None`),而 `LiteLLMAdapter` 会在 `provider==OLLAMA` 时回退到 `DEFAULT_BASE_URLS[OLLAMA]`(localhost)。
- `test_embedding` 同样在 `request.base_url` 为空时把 `None` 传入下游,`OllamaEmbedding` 会回退到 `http://localhost:11434`。
## Fix Focus Areas
- backend/app/api/v1/endpoints/config.py[436-501]
- backend/app/services/llm/adapters/litellm_adapter.py[151-165]
- backend/app/services/llm/types.py[108-121]
- backend/app/api/v1/endpoints/embedding_config.py[384-414]
- backend/app/services/rag/embeddings.py[182-206]
## Suggested fix
1) 在端点内对“最终将被使用的 effective base URL”做校验,而不是仅校验用户输入字段:
- `test_llm_connection`: 先计算 `effective_base_url = request.baseUrl or DEFAULT_BASE_URLS.get(provider, "")`,然后对 `effective_base_url` 做 `is_safe_url` 校验;同时构造 `LLMConfig(base_url=effective_base_url)`(避免把 None 交给下游再回退)。
- `test_embedding`: 根据 provider 推断默认 base_url(或在 EmbeddingService/Provider 层提供可查询的默认值),对 effective 值校验后再创建 service。
2) 如果业务上确实需要支持本地 Ollama:引入显式配置开关/allowlist(例如仅允许管理员、仅允许某些固定内网网段/仅允许 127.0.0.1 且不可由普通用户指定),并把默认值/前端展示与服务端校验逻辑保持一致。
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| # SSRF防护:验证llmBaseUrl和ollamaBaseUrl是否安全 | ||
| if config_in.llmConfig: | ||
| if config_in.llmConfig.llmBaseUrl and not is_safe_url(config_in.llmConfig.llmBaseUrl): | ||
| print(f"[Config] SSRF防护: 拒绝保存内网地址 {config_in.llmConfig.llmBaseUrl}") | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail="不允许使用内网地址作为 LLM Base URL,请使用公网API地址" | ||
| ) | ||
| if config_in.llmConfig.ollamaBaseUrl and not is_safe_url(config_in.llmConfig.ollamaBaseUrl): | ||
| print(f"[Config] SSRF防护: 拒绝保存内网地址 {config_in.llmConfig.ollamaBaseUrl}") | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail="不允许使用内网地址作为 Ollama Base URL,请使用公网API地址" | ||
| ) | ||
|
|
There was a problem hiding this comment.
3. Blocking dns in async 🐞 Bug ☼ Reliability
is_safe_url() 在 async FastAPI 端点中直接调用同步 socket.gethostbyname(),会阻塞事件循环。DNS 慢或被攻击者触发大量解析时,会显著拉高延迟并降低并发处理能力。
Agent Prompt
## Issue description
同步 DNS 调用(`socket.gethostbyname` / `getaddrinfo`)在 async handler 内执行会阻塞 event loop,影响整个 worker 的并发与尾延迟。
## Issue Context
当前 `update_my_config` / `test_llm_connection` / embedding 相关端点均为 `async def`,并直接调用 `is_safe_url()`,而 `is_safe_url()` 内部包含同步 DNS 解析。
## Fix Focus Areas
- backend/app/api/v1/endpoints/config.py[64-104]
- backend/app/api/v1/endpoints/config.py[253-274]
- backend/app/api/v1/endpoints/embedding_config.py[40-81]
- backend/app/api/v1/endpoints/embedding_config.py[348-401]
## Suggested fix
- 将 URL 校验改为 async 版本(例如 `async def is_safe_url_async(url: str) -> bool`),并使用以下任一方式避免阻塞:
1) `await asyncio.get_running_loop().getaddrinfo(...)` 获取 DNS 结果(推荐,返回所有地址,顺带解决多记录/IPv6 覆盖)。
2) 使用 `anyio.to_thread.run_sync(socket.getaddrinfo, ...)` / `run_in_executor` 把阻塞解析移到线程池。
- 在端点内 `await is_safe_url_async(...)`。
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| # SSRF防护:验证llmBaseUrl和ollamaBaseUrl是否安全 | ||
| if config_in.llmConfig: | ||
| if config_in.llmConfig.llmBaseUrl and not is_safe_url(config_in.llmConfig.llmBaseUrl): | ||
| print(f"[Config] SSRF防护: 拒绝保存内网地址 {config_in.llmConfig.llmBaseUrl}") | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail="不允许使用内网地址作为 LLM Base URL,请使用公网API地址" | ||
| ) | ||
| if config_in.llmConfig.ollamaBaseUrl and not is_safe_url(config_in.llmConfig.ollamaBaseUrl): | ||
| print(f"[Config] SSRF防护: 拒绝保存内网地址 {config_in.llmConfig.ollamaBaseUrl}") | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail="不允许使用内网地址作为 Ollama Base URL,请使用公网API地址" | ||
| ) |
There was a problem hiding this comment.
4. Default config round-trip breaks 🐞 Bug ≡ Correctness
GET 默认配置返回的 ollamaBaseUrl 是 http://localhost:11434/v1,但 PUT /api/v1/config/me 新增校验会拒绝该值为内网地址。任何客户端将默认配置原样回传(round-trip)都会收到 400,造成行为不一致。
Agent Prompt
## Issue description
默认配置与更新接口的 SSRF 校验逻辑冲突:默认值包含 localhost Ollama URL,但更新接口拒绝保存内网 URL,导致 round-trip 失败。
## Issue Context
- `get_default_config()` 把 `ollamaBaseUrl` 默认设置为 `http://localhost:11434/v1`。
- `update_my_config()` 新增 SSRF 校验会拒绝 `ollamaBaseUrl` 为内网地址。
## Fix Focus Areas
- backend/app/api/v1/endpoints/config.py[161-184]
- backend/app/api/v1/endpoints/config.py[260-273]
## Suggested fix
选择一种一致策略并实现:
1) 如果产品目标是“严格禁止任何内网 URL”:
- 将默认 `ollamaBaseUrl` 改为空(或移除该默认),并在前端/文档明确需要公网地址;同时在所有使用 Ollama 默认 localhost 的调用路径中禁用或改为显式配置。
2) 如果需要支持本地 Ollama:
- 为 `ollamaBaseUrl` 引入受控 allowlist/开关(例如仅允许 127.0.0.1,或仅允许管理员/自托管模式),并让默认值与 allowlist 相匹配。
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
修复了 /api/v1/config/test-llm 和相关端点的SSRF(服务器端请求伪造)漏洞。 攻击者之前可以通过提供内网地址来探测内部网络服务。
主要改动:
受保护的端点:
安全影响:
Fixes #137