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

+6
View File
@@ -12,6 +12,12 @@ import "strings"
// 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。这道守卫
// 原先在 runtime/model(safeErrorToken)与 runtime/multimodal/voice
// (dashscopeErrorToken)各有一份逐字同形的实现,M46 把同一份收敛到这里。
//
// 🔴 M49 收口:守卫的**应用点**同样收进本包 —— UpstreamError 构造时对 error.code /
// error.type 各调一次(构造即安全,调用点不必也不该再守),构造后的注入走
// UpstreamErrorInfo.SetCode / SetType(同一道守卫)。因此本包之外的 Go 代码里
// **不应出现 ErrorToken 调用**:出现了就意味着又回到「调用方逐点补」的漂移形态
// (M48 复审的 finding 正是这么漏的:httpclient / vision / embedding / weixin 四处)。
// maxErrorTokenBytes 是 error.code / error.type / request_id 允许出镜的长度上限。
// 64 字节足够容纳任何真实的枚举与 UUID 形状,又远小于「一段话」。
+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 {
@@ -0,0 +1,96 @@
package trace
// 本文件是 M49 的核心断言:**守卫在构造函数里**。
//
// M46 把令牌守卫加在调用方(model / voice 各一份),M48 复审的中危 finding 证明那是
// 漂移形态 —— 构造函数自己不守,任何新调用点默认就不安全,四条出口(httpclient /
// vision / embedding / weixin)恰恰一直漏着。这里把断言钉在 UpstreamError 与
// SetCode / SetType 上:摘掉构造函数里的守卫,本文件与四条出口的用例同时变红。
//
// 每条都是「把它放宽就变红」的形状:断言的是真正进日志的两个出口(Error() / Attrs())。
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
)
// TestUpstreamError_ConstructorDropsFreeTextCodeAndType 报文里 error.code 与
// error.type 都是回显的散文 ⇒ 两个字段在构造时就变空串,而 status / body_bytes /
// body_sha256 仍在(诊断面不能一起丢)。
func TestUpstreamError_ConstructorDropsFreeTextCodeAndType(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)
err := UpstreamError("api.example.com", 400, "application/json", body)
info, ok := err.(*UpstreamErrorInfo)
if !ok {
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
}
if info.Code != "" || info.Type != "" {
t.Fatalf("info.Code/Type = %q/%q,want 空串(构造即安全)", info.Code, info.Type)
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String(), strings.Join(anyToStrings(info.Attrs()), " ")} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("散文经 code/type 进了出口:%s", surface)
}
if strings.Contains(surface, " code=") || strings.Contains(surface, " type=") {
t.Fatalf("非令牌形状的 code/type 没有被丢弃:%s", surface)
}
}
for _, want := range []string{"status=400", "body_bytes=", "body_sha256=", "api.example.com"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q,want 含 %q(诊断线索不能一起丢)", err.Error(), want)
}
}
if !errors.Is(err, ErrUpstream) {
t.Fatal("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
}
}
// TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType 真实的协议枚举必须原样
// 通过:丢了它们只是少一条分类提示,但那是**有价值的**提示,别顺手一起丢。
func TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType(t *testing.T) {
t.Parallel()
body := []byte(`{"error":{"code":"invalid_request_error","type":"Throttling.RateQuota"}}`)
err := UpstreamError("api.example.com", 429, "application/json", body)
info := err.(*UpstreamErrorInfo)
if info.Code != "invalid_request_error" || info.Type != "Throttling.RateQuota" {
t.Fatalf("info.Code/Type = %q/%q,want 原值(令牌形状必须保留)", info.Code, info.Type)
}
for _, want := range []string{"code=invalid_request_error", "type=Throttling.RateQuota"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q,want 含 %q", err.Error(), want)
}
}
}
// TestUpstreamError_SetCodeGuardsPostConstructionInjection 构造后注入这条窄路也必须
// 是守卫点:SetCode / SetType 是唯一合法通道,散文进去 = 空串出来(覆盖语义)。
func TestUpstreamError_SetCodeGuardsPostConstructionInjection(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
info := UpstreamError("dashscope.example.com", 400, "application/json", nil).(*UpstreamErrorInfo)
info.SetCode("Throttling.RateQuota")
info.SetType("rate-limit")
if info.Code != "Throttling.RateQuota" || info.Type != "rate-limit" {
t.Fatalf("info.Code/Type = %q/%q,want 令牌原值", info.Code, info.Type)
}
// 注入不受控的散文:两个字段都变空串(而不是保留上一次的值)。
info.SetCode(prose)
info.SetType(prose)
if info.Code != "" || info.Type != "" {
t.Fatalf("info.Code/Type = %q/%q,want 空串(SetXxx 必须过同一道守卫)", info.Code, info.Type)
}
if text := info.Error(); strings.Contains(text, "她刚才说的那句话") {
t.Fatalf("SetCode/SetType 让散文进了错误文本:%s", text)
}
}