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

@@ -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)
}
}