当 User-Agent 被改写:一次 OAuth 封号的完整排查

目录

作者:litianc

时间:2026年3月4日

阅读时长:7分钟

前言

用过共享模型账号的同学应该知道,封号这事儿就像开盲盒——有时候用了几个月相安无事,有时候第一个请求发出去账号就没了。

前两篇文章(并发控制排查LiteLLM Session 注入)解决了会话计数累加的问题,整条链路跑得好好的。结果就在我以为可以收工的时候,一个 OAuth 账户在发出第一个请求后瞬间被封禁了。

一个请求,秒封。

排查后发现,问题出在一个极其不起眼的地方:请求经过中间网关时,User-Agentclaude-cli/2.1.22 (external, cli) 被改写成了 Anthropic/JS 0.73.0。就这一个 header 的变化,像推倒了多米诺骨牌一样,让模型调度平台内部精心设计的三重身份伪装防线同时失效——最终,我的真实账户信息原封不动地暴露在了上游的请求体里。

Token 说的是账户 A,metadata.user_id 说的是账户 B。上游一看,身份冲突,封禁。

这篇文章完整还原这条事故链:从请求如何被转发,到身份伪装机制如何运作,再到三重防线为何同时失效。

一、请求链路全貌

先交代一下我的使用架构。我用真实的 Claude Code 客户端发起请求,中间经过一层反向代理网关,再转发到模型调度平台,最后由平台转发至上游 Anthropic API:

真实 Claude Code 客户端
  → 中间网关(反向代理)
  → 模型调度平台
  → 上游 Anthropic API

模型调度平台的核心职责之一,是把多个用户的请求通过少量 OAuth 账户转发给上游。由于上游会检测请求是否来自合法的 Claude CLI 客户端,平台内置了一套”伪装机制”(mimic)——对非 Claude Code 客户端发来的请求,自动化妆成 Claude Code 的样子再转发。

二、三重防线

要理解为什么一个小小的 header 改写能搞出这么大动静,得先看看平台内部的伪装机制长什么样。我把它总结为三重防线,每一重都有各自的职责。

第一重:客户端识别——isClaudeCodeClient

平台首先要判断:这个请求是不是来自真正的 Claude Code 客户端?

claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)

func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
    if metadataUserID == "" {
        return false
    }
    return claudeCliUserAgentRe.MatchString(userAgent)
}

两个条件,AND 关系:User-Agent 匹配 ^claude-cli/ 的正则,且请求体中存在 metadata.user_id

在我的场景中,UA 被中间网关改成了 Anthropic/JS 0.73.0,正则不匹配。于是:

  • isClaudeCode = false(误判!明明是真的 Claude Code)
  • shouldMimicClaudeCode = true(触发伪装路径)

一个真正的 Claude Code 请求被错误地归类为”需要伪装”的非 CC 请求。

第二重:Metadata 注入——buildOAuthMetadataUserID

进入伪装路径后,平台会尝试为请求生成一个与 OAuth Token 匹配的 metadata.user_id

func (s *GatewayService) buildOAuthMetadataUserID(
    parsed *ParsedRequest, account *Account, fp *Fingerprint,
) string {
    if parsed.MetadataUserID != "" {
        return ""   // 客户端已经带了 user_id → 不生成新的
    }
    // ... 生成新的 user_id 的逻辑
}

问题来了:我的请求来自真实的 Claude Code,天然携带了 metadata.user_id(包含我的真实账户信息)。函数检测到已有值,直接返回空——不注入新的。

设计意图是合理的:如果客户端已经有了 user_id,就不覆盖它,留给后续的重写逻辑处理。但这个”留给后续处理”的假设,很快就会被打脸。

第三重:UserID 重写——RewriteUserID

最后一道防线,对所有 OAuth 账户的请求都会执行。它试图把 metadata.user_id 中的身份信息替换为与当前 OAuth Token 匹配的值:

userIDRegex = regexp.MustCompile(
    `^user_[a-f0-9]{64}_account__session_([a-f0-9-]{36})$`,
)

func (s *IdentityService) RewriteUserID(body []byte, ...) ([]byte, error) {
    // ... 解析出 metadata.user_id ...
    matches := userIDRegex.FindStringSubmatch(userID)
    if matches == nil {
        return body, nil  // 匹配失败,原样返回
    }
    // ... 重写逻辑 ...
}

注意正则中的 _account__session_——account 和 session 之间是两个下划线直接相连,也就是说 account 部分为空。这个正则只能匹配平台自身在没有 account_uuid 时生成的旧格式:

user_{64位hex}_account__session_{uuid}
                       ↑↑
                  这里是空的

而真实 Claude Code 客户端发送的格式是:

user_{64位hex}_account_{真实UUID}_session_{uuid}
                       ↑↑↑↑↑↑↑↑↑↑↑↑
                  这里有真实的账户 UUID

正则不匹配,重写直接跳过。我的真实 metadata.user_id 原封不动地传给了上游。

三、事故还原

把三重防线串起来,完整的事故链条是这样的:

真实 Claude Code 客户端
  │  发送请求,包含:
  │  - User-Agent: claude-cli/2.1.22 (external, cli)
  │  - metadata.user_id: user_{我的hex}_account_{我的UUID}_session_{...}
  │
  ▼
中间网关
  │  改写 User-Agent → Anthropic/JS 0.73.0
  │
  ▼
模型调度平台
  │
  ├─ 第一重防线 isClaudeCodeClient():
  │    UA 不匹配正则 → isClaudeCode = false → 走伪装路径
  │    ❌ 误判
  │
  ├─ 第二重防线 buildOAuthMetadataUserID():
  │    检测到客户端已带 user_id → 不注入新的
  │    ❌ 放弃了主动修正的机会
  │
  ├─ 第三重防线 RewriteUserID():
  │    正则只匹配 account__session_(空account),
  │    不匹配 account_{真实UUID}_session_
  │    ❌ 重写失败,原样放行
  │
  ├─ Header 伪装 applyClaudeCodeMimicHeaders():
  │    强制覆写 User-Agent 为 claude-cli/2.1.22
  │    ✅ Header 看起来没问题
  │
  ▼
上游 Anthropic API 收到:
  - OAuth Token:属于池中的账户 A ✅
  - User-Agent:claude-cli/2.1.22 ✅
  - metadata.user_id:user_{我的}_account_{我的UUID}_session_{...} ❌
  → Token 是账户 A,user_id 是账户 B → 身份不匹配 → 封禁

Header 层面伪装得很完美,但 body 里的身份信息把一切出卖了——像是换了一身西装走进酒店大堂,口袋里却还揣着另一个人的身份证。

四、为什么平时不出问题

搞清楚了 bug 怎么发生的,下一个自然的问题是:这玩意儿藏了多久?为什么之前没人踩到?答案是,日常使用中大多数请求走的是”安全”路径。

最常见的场景:普通 SDK 客户端,不带 metadata.user_id。

客户端不带 user_id
→ buildOAuthMetadataUserID() 主动生成新的
→ 格式与 OAuth Token 匹配 → 安全 ✅

这是绝大多数用户的使用方式。客户端不携带 metadata.user_id,平台为它生成一个与 OAuth Token 配套的值,身份一致,一切正常。

我遇到的场景:真实 Claude Code 请求,UA 被改写。

UA 被改 → 误判为非 CC → 走伪装路径
→ 但客户端已带 user_id → 不注入新的
→ 正则不匹配 → 不重写
→ 真实 user_id 泄露 → 封禁 ❌

这是三重防线同时失效的结果。每一层单独看都有合理的设计意图:

  • 客户端识别层假设 UA 不会被篡改
  • Metadata 注入层假设已有的 user_id 会被后续正确重写
  • RewriteUserID 层的正则只适配了旧格式,没跟上新格式的演进

每一层都把希望寄托在下一层身上,结果下一层也有自己的盲区。(严格来说,即使 UA 没被改写,只要真实 CC 客户端的账户与 OAuth Token 的账户不一致,RewriteUserID 的正则同样无法匹配——只是这个场景在实际使用中更少见。)

五、修复思路

定位到根因后,修复方向就比较清晰了。核心原则:在伪装路径下,不应该信任客户端携带的任何身份信息。

两个方案并行推进,互不排斥。一是在 buildOAuthMetadataUserID 中增加 forceMimic 参数,当请求走伪装路径时强制生成新的 metadata.user_id,不再尊重客户端已有的值;二是扩展 RewriteUserID 的正则,让它同时匹配 _account__session_(旧格式,account 为空)和 _account_{uuid}_session_(新格式,account 非空),作为最后一道兜底。前者从根本上解决问题,后者确保即使判断逻辑出错,最后一道防线也能正确重写。

六、总结

回顾一下这三篇文章的脉络。第一篇发现会话数累加的根因是客户端不带 metadata.user_id,第二篇用 LiteLLM 代理在中间层注入了 session 标识,第三篇(本文)则揭开了一个更深层的安全问题——当 User-Agent 被中间网关改写时,模型调度平台的身份伪装机制会被完全穿透。

这次排查最大的启示是关于”纵深防御”的一个常见误区。我们设计了三重防线——客户端识别、metadata 注入、user_id 重写——看起来层层把关,万无一失。但实际上每一层都在假设前一层正常工作:注入层假设识别层判断正确,重写层的正则假设上游格式不变。当一个意外条件(UA 被改写)从第一层滑过去时,后面的每一层都没能独立拦住它。

真正的纵深防御,每一层都应该假设前置层可能失效。这个原则说起来简单,做起来需要刻意对抗”前面已经处理过了”的思维惯性。

从实操角度,如果你也在用类似的多租户网关架构,有两点值得注意:一是请求链路上任何中间层对 header 的改写都可能影响下游的判断逻辑,上线前最好做一次端到端的 header 审计;二是身份相关的字段(无论在 header 还是 body 里)在跨信任边界时必须重新校验,不能假设上游传来的值是安全的。

这个系列暂时告一段落。后续打算整理一份请求链路的端到端 header 审计清单,作为类似架构的排查参考——如果有新的发现,再来更新。

打赏一个呗

取消

感谢您的支持,我会继续努力的!

扫码支持
扫码打赏,你说多少就多少

打开微信扫一扫,即可进行扫码打赏哦