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:
1 parent
4e9cd17fbd
commit
fe3872ee28
10 files changed
+419
-17
No files matched your search
@@ -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 形状,又远小于「一段话」。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user