犀牛鸟 2026 研究笔记 estelledc.github.io

案例 #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.govalidateEmbeddingBaseURL + 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/

阅读顺序

  1. embedding/transport.go — 新增逻辑 唯一真相源
  2. embedding/transport_test.go — 拒绝用例 + OpenAI 构造集成
  3. 任选一个 provider(如 openai.go)— 3 行模板改动
  4. 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

5.3 合入特征


6. 与 RAG 管线

阶段 embed 调用 SSRF 含义
Ingest 每 chunk → embed 恶意 base_url × chunk 数 = 放大出站
Query query → embed 每次提问触发
换模型 Ch30 base_url 写入 DB 配置变更 = 新攻击面

7. 可复用模式

  1. 先抽 transport.go,再横切 N 个 provider — 避免只修 OpenAI 留 Jina
  2. 构造期 fail fast — 配置类 bug 不要拖到第一次 embed
  3. 与已有子系统对齐 — grep ValidateURLForSSRF 找 rerank / webhook 遗漏
  4. 测试 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

  1. 创建 remote embedding,base_url = http://169.254.169.254/
    • 期望:保存/测试失败,错误含 SSRF, 向该 IP 的连接(可 netstat / 防火墙日志)
  2. 改为合法公网兼容 URL → test connection 成功
  3. 上传 1 个小文档 → chunk 数 > 0,维度符合模型配置
  4. rg 'http\.Client\{' internal/models/embedding/ → 应只剩 transport.go 间接创建

10. 思考点

  1. 新 provider 接入 checklist:哪 3 行必须复制?测试必须 env 什么?
  2. Ollama 若未来走 HTTP base_url,应走 remote 还是单独 localTransport
  3. case-1774 删除引用检查 vs 本 PR URL 检查 — 还有哪些 构造期 invariant
  4. 设计竞赛题:证明恶意 URL 零 outbound TCP — 用 mock dialer 还是集成测?

章末自检


延伸阅读