fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全

M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
This commit is contained in:
AskaEth committed 2026-10-26 19:13:38 +08:00
1 parent 4e9cd17fbd
commit fe3872ee28
10 files changed
+419 -17

No files matched your search

+36 -4
View File
@@ -18,6 +18,12 @@ import (
// JSON 里的 error.code / error.type;**error.message 默认不带**,只有显式
// opt-in(WithUpstreamMessage)才保留,且必须过 RedactText 再截到
// maxUpstreamMessageRunes;
// - 🔴 **code / type 构造时就过令牌守卫**(ErrorToken):只有像协议令牌的短串才
// 出镜,散文一律变空串。守卫**在本构造函数里**,不在调用点 —— 因此任何新调用点
// **默认就是安全的**。M46 曾把它加在调用方(model / voice 各一份),M48 复审的
// 中危 finding 证明那正是漂移形态:调用方逐点补,四条出口(httpclient / vision /
// embedding / weixin)就一直漏。M49 把守卫上移到这里,并把「构造后再注入」这一
// 条窄路收进 SetCode / SetType(同样守卫)。
// - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的
// param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志;
// - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段,
@@ -63,9 +69,12 @@ type UpstreamErrorInfo struct {
// BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能
// 认出来,正文拿不到)。
BodySHA256 string
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染)。
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染),**构造时已过
// 令牌守卫**(与 SetCode 同一道):不像协议令牌的值在这里恒为空串。空串是预期
// 结果 —— 宁可不显示一条分类提示,也不让正文借道字段出镜;调用方不得按「是否为空」
// 推断「上游没给码」(两者在构造后不可区分)。
Code string
// Type 是上游 JSON 里 error.type。
// Type 是上游 JSON 里 error.type,**构造时已过令牌守卫**,口径与 Code 逐字相同。
Type string
// Message 是上游 JSON 里 error.message,**只有显式 opt-in(WithUpstreamMessage)
// 才非空**:那一刻它已压平空白、清洗凭据形状并截断到
@@ -182,6 +191,12 @@ func WithUpstreamMessage() UpstreamOption {
// 宁可少一条人话提示,也不让她的原话进日志;status/code/type/bodyLen/sha256
// 足够分类与相关性比对。确实要看那句话时,在**确认该上游不回显用户内容**的调用点
// 加 WithUpstreamMessage(那时它仍会被 RedactText 清洗并截到 128 rune)。
//
// 🔴 同样默认的还有 **code / type 的令牌守卫**:它们不是「可选加固」,而是本构造函数
// 的固定行为 —— 报文里的 error.code / error.type 只有像协议令牌(≤64 字节、仅
// [A-Za-z0-9_.-])才出镜,其余变空串。调用方**不需要**、也**不要**再守一遍:守卫
// 加在调用点意味着新调用点默认不安全(M46 → M49 的教训)。构造后要注入这两个字段
// 时走 SetCode / SetType,别直接赋值。
func UpstreamError(upstream string, status int, contentType string, body []byte, opts ...UpstreamOption) error {
var cfg upstreamOptions
for _, opt := range opts {
@@ -199,8 +214,10 @@ func UpstreamError(upstream string, status int, contentType string, body []byte,
info.BodySHA256 = DigestOf(string(body)).SHA256
}
if parsed, ok := parseUpstreamErrorBody(body); ok {
info.Code = parsed.Code
info.Type = parsed.Type
// 🔴 构造即安全(M49):code / type 在这里过令牌守卫,调用点不需要也不应该
// 再守一遍 —— 守卫留在调用方的形态必然漏点(新调用点默认不安全)。
info.Code = ErrorToken(parsed.Code)
info.Type = ErrorToken(parsed.Type)
if cfg.keepMessage {
info.Message = sanitizeUpstreamMessage(parsed.Message)
}
@@ -208,6 +225,21 @@ func UpstreamError(upstream string, status int, contentType string, body []byte,
return info
}
// SetCode 是**构造之后**改写 Code 的唯一合法通道:它过的是与构造函数同一道令牌守卫
// (ErrorToken),所以「写入即安全」。
//
// 为什么还需要这条窄路:有些上游把错误码放在**顶层** code(DashScope 的 4xx 报文)、
// 或者把失败放进一个已经 200 的**流内事件**里(Anthropic SSE 的 error.type)——
// 这两种报文都不在 UpstreamError 参数的视野里,只能构造后注入。注入点也是守卫点,
// 但守卫代码留在本包:调用点只写 `info.SetCode(raw)`,不再各抄一份 ErrorToken
// (M46 的两份实现就是这么漂移出来的)。
//
// 语义是**覆盖**:注入的值不像令牌时 Code 变空串,而不是保留构造函数解出来的旧值。
func (e *UpstreamErrorInfo) SetCode(code string) { e.Code = ErrorToken(code) }
// SetType 是 Type 版的 SetCode,口径逐字相同。
func (e *UpstreamErrorInfo) SetType(typ string) { e.Type = ErrorToken(typ) }
// upstreamErrorBody 是**唯一**从响应体里读出的结构:只有 error 的三个字段,
// 其余字段(param / input / code_id / 请求回显 …)根本不会被解码到任何地方。
type upstreamErrorBody struct {