案例 #1828:Embedding SSRF 校验与安全 HTTP 客户端
标杆案例(横切安全 / 多 provider)— 后续精读默认对齐本篇完整度 · 见 写作标准
PR:#1828 | 13 文件 +112/-26 | 2026-06-26 合入 | lyingbug
结论先行
WeKnora 允许租户为 远程 Embedding 模型 配置自定义 base_url。修复前,9 个 remote embedder 在 New*Embedder 里使用 裸 http.Client,未校验 URL 是否指向 link-local / 内网 / 云元数据 — 构成 SSRF:配置恶意 base_url 后,每次 ingest 与 query embed 都会由 服务器 向该地址发 HTTP。
本 PR 新增 internal/models/embedding/transport.go(validateEmbeddingBaseURL + newEmbeddingHTTPClient),与 internal/models/chat/transport.go 对齐;13 文件横切全部 remote provider;单测对 httptest 使用 SSRF_WHITELIST=127.0.0.1。本地 Ollama 等路径 PR 声明未改。
适合谁读:改 embedding provider、模型配置 API、生产安全审查;竞赛 Ch30 embed / Ch43 检索栈 贡献者。
与 #1248:间接 — 任何「用户可配出站 URL」(webhook、统计上报)应复用 secutils.ValidateURLForSSRF,禁止新写裸 Client。方法论同 case-1774 的 构造期 invariant。
链 Ch30 · Ch43 · case-1784(可行动错误)。
1. 问题现象(What)
1.1 威胁模型(SSRF,不是浏览器 XSS)
| 角色 | 能力 | 后果 |
|---|---|---|
| 租户管理员 / 有模型写权限的 API 调用方 | 创建/更新 Embedding 模型,设置 base_url |
指向 169.254.169.254、127.0.0.1、10.x 等 |
| WeKnora 进程 | 在 ingest / 检索时 代表用户 请求 embed API | 服务器成为 出站跳板,可读云 metadata、打内网 Redis/管理口 |
触发频率:ingest 时 每个 chunk 一次 embed;检索时 每个 query 至少一次 — 比「偶尔点一下 test connection」危险面更大。
1.2 典型攻击目标
| 目标 | 示例 URL | 意图 |
|---|---|---|
| 云实例 metadata | http://169.254.169.254/latest/meta-data/ |
窃取 IAM 临时凭证 |
| 本机服务 | http://127.0.0.1:6379 |
探测/滥用 Redis |
| 内网 API | http://10.0.0.8:8080/admin |
横向移动 |
1.3 修复前 / 修复后(行为对比)
| 场景 | 修复前 | 修复后 |
|---|---|---|
base_url = metadata IP |
New*Embedder 成功;后续 HTTP 实际发出 |
构造期 error,含 SSRF 语义 |
base_url = 合法公网 OpenAI 兼容 |
正常 embed | 不变 |
base_url = 空字符串 |
走 provider 默认公网 endpoint | 不变(空 URL 显式允许,由默认常量保证) |
| 本地 Ollama / 非 HTTP remote 路径 | 不经由本 PR 改的 client | PR test plan:unaffected |
单测 httptest on 127.0.0.1 |
无 whitelist 时可能被 SSRF 规则误伤 | 测试设 SSRF_WHITELIST=127.0.0.1 |
1.4 修复前代码形态(OpenAI 代表)
// internal/models/embedding/openai.go — 修复前模式(各 provider 同构)
timeout := 60 * time.Second
client := &http.Client{Timeout: timeout}
return &OpenAIEmbedder{
baseURL: baseURL, // 来自 DB,未校验
httpClient: client,
// ...
}, nil
2. 定位步骤(从现象到文件)
若你在 audit 或 issue 里看到「Embedding 自定义 base_url 可能 SSRF」,建议顺序:
# 1. 找所有 embedder 构造与 HTTP 客户端
rg "New.*Embedder|http\.Client\{" internal/models/embedding/
# 2. 对比 chat 是否已有安全层(应发现 chat/transport.go 有 secutils)
rg "ValidateURLForSSRF|NewSSRFSafeHTTPClient" internal/models/
# 3. PR 合入后确认 embedding 已对齐
rg "validateEmbeddingBaseURL|newEmbeddingHTTPClient" internal/models/embedding/
阅读顺序:
embedding/transport.go— 新增逻辑 唯一真相源embedding/transport_test.go— 拒绝用例 + OpenAI 构造集成- 任选一个 provider(如
openai.go)— 3 行模板改动 chat/transport.go— diff 对齐方式
3. 根因分析(Why)
3.1 类比
Chat 出站早已「安检 + 安全 Client」;Embedding 是 平行 provider 树,历史上 复制了 http.Client{Timeout} 模板,没复制安全层 — 两扇出口,一扇有安检。
3.2 配置写入 → HTTP 发出(完整 RAG 路径)
flowchart TD
subgraph config [配置阶段]
A[管理 API / UI<br/>创建 Embedding 模型] --> B[(DB: base_url, api_key, …)]
end
subgraph ingest [Ingest — 高频 SSRF]
B --> C[ingest pipeline]
C --> D[NewOpenAIEmbedder 等]
D --> E[BatchEmbed chunks]
E --> F[HTTP POST 到 base_url]
end
subgraph query [Retrieve — 每次提问]
B --> G[检索 pipeline]
G --> H[Embed query]
H --> F
end
F --> I{修复后}
I -->|validateEmbeddingBaseURL 失败| J[构造 error — 无 TCP]
I -->|newEmbeddingHTTPClient| K[仅允许安全出站]
3.3 为何 Chat 有、Embed 没有?
| 目录 | 修复前 | 修复后 |
|---|---|---|
internal/models/chat/ |
transport.go + ValidateURLForSSRF |
不变 |
internal/models/embedding/ |
各文件独立 http.Client |
统一 transport.go |
根因归类:安全债横向不一致(organizational / 复制粘贴),不是 embed 算法 bug。
3.4 本地 vs 远程 embedder 边界
| 类型 | 典型路径 | 本 PR |
|---|---|---|
| Remote HTTP(OpenAI、Jina…) | New*Embedder + httpClient.Do |
✅ 校验 + 安全 Client |
| 本地 / 进程内(Ollama 等) | 不走同一套 remote base_url HTTP |
PR:未改(test plan 明确) |
写贡献或 audit 时 必须 分清:不能假设「所有 embed 都经 validateEmbeddingBaseURL」。
4. 解决方案(How)
4.1 核心:transport.go(+29 行,全包逻辑增量)
package embedding
import (
"fmt"
"net/http"
"time"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func validateEmbeddingBaseURL(baseURL string) error {
if baseURL == "" {
return nil // caller 应用 provider 默认(已知的公网常量)
}
if err := secutils.ValidateURLForSSRF(baseURL); err != nil {
return fmt.Errorf("base URL SSRF check failed: %w", err)
}
return nil
}
func newEmbeddingHTTPClient(timeout time.Duration) *http.Client {
cfg := secutils.DefaultSSRFSafeHTTPClientConfig()
cfg.Timeout = timeout
return secutils.NewSSRFSafeHTTPClient(cfg)
}
4.2 两层防护(缺一不可)
| 层 | 机制 | 防什么 |
|---|---|---|
| 构造期 | ValidateURLForSSRF(baseURL) |
内网 IP、link-local、非法 host 写入即失败 |
| 运行时 | NewSSRFSafeHTTPClient |
DNS 重绑定、重定向 到内网、非预期连接 |
为何不只靠字符串检查 https://? — 攻击面包含 redirect 与解析差异;与 chat 共用 secutils 是为 行为一致。
4.3 Provider 模板(每个 remote 文件 +3~6 行)
if err := validateEmbeddingBaseURL(baseURL); err != nil {
return nil, err
}
return &XxxEmbedder{
httpClient: newEmbeddingHTTPClient(timeout),
// ...
}, nil
4.4 WeKnoraCloud 特殊路径
空 baseURL → 填 provider.WeKnoraCloudBaseURL → 再 validate — 避免「空串跳过校验但后续拼出内网 URL」。
4.5 为何不做「仅 test connection 时校验」?
| 备选 | 缺陷 |
|---|---|
| 只在 UI test connection 校验 | 绕过 API 直写 DB、或跳过 test 仍 ingest |
| 只在第一次 embed 时校验 | 脏配置已进库;fail 晚、难排查 |
| 构造期 fail(本 PR) | New*Embedder 失败 → 模型不可用 → 无 TCP |
4.6 逐文件改动表(13 文件)
| 文件 | ± | 类型 | 说明 |
|---|---|---|---|
embedding/transport.go |
+29 | NEW | 共享 validate + client |
embedding/transport_test.go |
+37 | NEW | metadata 拒绝、空 URL、OpenAI 构造 |
embedding/openai.go |
+3/-4 | MOD | 模板接入 |
embedding/azure_openai.go |
+5/-1 | MOD | 同上 |
embedding/aliyun.go |
+3/-3 | MOD | 同上 |
embedding/gemini.go |
+6/-1 | MOD | 同上 |
embedding/jina.go |
+3/-4 | MOD | 同上 |
embedding/nvidia.go |
+3/-4 | MOD | 同上 |
embedding/volcengine.go |
+3/-3 | MOD | 同上 |
embedding/zhipu.go |
+3/-4 | MOD | 同上 |
embedding/weknoracloud.go |
+10/-2 | MOD | 默认 base + 校验 |
embedding/openai_test.go |
+1 | MOD | SSRF_WHITELIST |
embedding/gemini_test.go |
+6 | MOD | 3 个 test 各 +whitelist |
读 PR 技巧:13 文件 ≈ 29+37 行新逻辑 + 9× 模板;review 先 transport.go,再 spot-check 2 个 provider。
4.7 测试策略
拒绝(必须 fail):
err := validateEmbeddingBaseURL("http://169.254.169.254/latest/meta-data")
// err != nil && strings.Contains(err.Error(), "SSRF")
httptest(必须 whitelist):
func TestGeminiEmbedderBatchEmbedUsesNativeAPI(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
server := httptest.NewServer(...)
// ...
}
SSRF_WHITELIST仅测试进程;不等于生产允许 localhost。
命令:
go test ./internal/models/embedding/... -v
go test ./internal/models/embedding/ -run 'TestValidateEmbeddingBaseURL|TestNewOpenAIEmbedder_RejectsPrivateBaseURL' -v
5. Review 与合入(Maintainer 视角)
5.1 PR 描述四段式(本 PR 实际对应)
| 段 | 本 PR 内容 |
|---|---|
| What | Remote embedder 裸 Client,自定义 base_url 可 SSRF |
| Why | Embedding 未对齐 chat 的 SSRF transport |
| How | transport.go + 全 provider;WeKnoraCloud 默认 URL;测试 whitelist |
| Test | go test ./internal/models/embedding/...;staging 恶意 URL → 400;合法 URL 通 |
5.2 维护者 checklist
- 是否 所有 remote HTTP embedder 都走
validateEmbeddingBaseURL?(grep 漏网) - 新增 provider 是否复制 3 行模板 + 测试 whitelist?
- 空 URL 语义是否与 provider 默认常量一致?
- 本地 embed 路径是否误伤?(Ollama 回归)
- 与
chat/transport.go行为是否仍对齐?(secutils 升级时双端同测)
5.3 合入特征
- 作者 lyingbug(维护者)— 安全横切典型 当天可合
- 112 行净增 / 13 文件 — 重复模式 PR,review 成本在 transport + 测试,不在 swagger
6. 与 RAG 管线
| 阶段 | embed 调用 | SSRF 含义 |
|---|---|---|
| Ingest | 每 chunk → embed | 恶意 base_url × chunk 数 = 放大出站 |
| Query | query → embed | 每次提问触发 |
| 换模型 Ch30 | 新 base_url 写入 DB |
配置变更 = 新攻击面 |
7. 可复用模式
- 先抽
transport.go,再横切 N 个 provider — 避免只修 OpenAI 留 Jina - 构造期 fail fast — 配置类 bug 不要拖到第一次 embed
- 与已有子系统对齐 — grep
ValidateURLForSSRF找 rerank / webhook 遗漏 - 测试 whitelist 写进案例 — 否则后人加 embed 单测 CI 全红
8. 常见误区
错误认知:SSRF 只影响 Chat。
正确:Embed 同样是服务端 HTTP,且 ingest 更高频。
错误认知:禁止 http:// 或只允许 https 就够。
正确:link-local、redirect、DNS 问题需 NewSSRFSafeHTTPClient。
错误认知:13 文件 = 大 PR 新手不能碰。
正确:增量逻辑 ~66 行;其余是 机械模板 — 适合学横切。
错误认知:测试 whitelist = 生产放开 127.0.0.1。
正确:仅 t.Setenv 测试环境。
错误认知:test connection 通过就安全。
正确:必须 构造期 拦截;且 test 与 ingest 须同一套 New*Embedder。
9. Staging 验证 playbook
- 创建 remote embedding,
base_url=http://169.254.169.254/- 期望:保存/测试失败,错误含 SSRF,无 向该 IP 的连接(可 netstat / 防火墙日志)
- 改为合法公网兼容 URL → test connection 成功
- 上传 1 个小文档 → chunk 数 > 0,维度符合模型配置
rg 'http\.Client\{' internal/models/embedding/→ 应只剩transport.go间接创建
10. 思考点
- 新 provider 接入 checklist:哪 3 行必须复制?测试必须 env 什么?
- Ollama 若未来走 HTTP
base_url,应走 remote 还是单独localTransport? - case-1774 删除引用检查 vs 本 PR URL 检查 — 还有哪些 构造期 invariant?
- 设计竞赛题:证明恶意 URL 零 outbound TCP — 用 mock dialer 还是集成测?
章末自检
- 能画「DB base_url → ingest/query → HTTP」并标 SSRF 点
- 说清 validate(构造)与 SafeHTTPClient(连接/redirect)分工
- 解释 httptest 为何要
SSRF_WHITELIST=127.0.0.1 - 说清本地 Ollama 路径为何不在本 PR 范围