Files
AskaEth fe3872ee28 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 旧用例同时变红。
2026-10-26 19:13:38 +08:00

324 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package trace
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// 本文件是**上游错误体的唯一收敛点**(M40 地基)。推理侧(M41/M42 的 provider
// 适配、模型路由降级、工具/渠道的上游调用)不许各写一份「截断一下响应体再
// 拼进 error」的实现 —— 那样的实现会漂移,而漂移的方向总是「某一家 provider
// 开始把响应体原文吐进错误与日志」。
//
// 🔴 契约(改本文件前先读):
// - 只保留:upstream 名 / status / contentType / bodyLen / body sha256 前缀 /
// 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() 是**唯二**的出口,两者都只渲染上面那些字段,
// 因此把 err 直接丢进 slog(或任何日志)是安全的。
//
// 🔴 为什么 message 默认不带(这条取舍是**构造得出的**,不是理论担心):
// 上游把用户内容拼进 message 是真实存在的写法 ——
// `{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}`
// 若默认保留前 128 rune,那 128 rune 就会**原样**出现在 error 与日志里。M41 的
// embedding input 是**她的记忆正文**、DashScope TTS 的 input 是**她要念的话**,
// M42 的渠道响应体同样可能回显消息内容 —— 一个「默认带 message」的模板会被复制
// 到十几处调用点,等于把同一个洞铺开。
//
// 因此本文件的默认口径是:**宁可少一条人话提示,也不让她的原话进日志**。
// 排障通常不需要那句话:`status` + `code` + `type` + `body_len` + `body_sha256`
// 足够分类(400 还是 429、是 model_not_found 还是 context_length_exceeded、
// 是不是同一份报文),而真正要看细节时,报文在**调用点**的上下文里还在。
// maxUpstreamMessageRunes 是上游 error.message 允许保留的 rune 上限。128 rune
// 够看清「上游为什么拒绝」,又短到不足以把一段用户正文整段带出来;超出部分
// 截断并标省略号(宁可丢信息,不可丢边界)。
const maxUpstreamMessageRunes = 128
// maxUpstreamLabelRunes 是 upstream 名保留的 rune 上限(它是代码里写死的名字
// 或主机名,本来就很短;上界只是防御)。
const maxUpstreamLabelRunes = 80
// ErrUpstream 是「上游返回了错误」这一类错误的哨兵,供调用方
// errors.Is(err, trace.ErrUpstream) 分类降级(主 → 备 → 本地)。
var ErrUpstream = errors.New("trace: upstream error")
// UpstreamErrorInfo 是一次上游失败**可安全出镜**的全部信息。它不含响应体原文,
// 也不含任何请求侧内容 —— 字段本身就是脱敏后的口径。
type UpstreamErrorInfo struct {
// Upstream 是上游标识(provider 名或主机,见 HostOf)。
Upstream string
// Status 是 HTTP 状态码(0 表示没有拿到状态码,例如连接中途断开)。
Status int
// ContentType 是响应的媒体类型(已去掉 charset 等参数)。
ContentType string
// BodyLen 是响应体的字节数(体积可诊断,内容不留)。
BodyLen int
// BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能
// 认出来,正文拿不到)。
BodySHA256 string
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染),**构造时已过
// 令牌守卫**(与 SetCode 同一道):不像协议令牌的值在这里恒为空串。空串是预期
// 结果 —— 宁可不显示一条分类提示,也不让正文借道字段出镜;调用方不得按「是否为空」
// 推断「上游没给码」(两者在构造后不可区分)。
Code string
// Type 是上游 JSON 里 error.type,**构造时已过令牌守卫**,口径与 Code 逐字相同。
Type string
// Message 是上游 JSON 里 error.message,**只有显式 opt-in(WithUpstreamMessage)
// 才非空**:那一刻它已压平空白、清洗凭据形状并截断到
// maxUpstreamMessageRunes。默认构造下恒为空 —— 上游把用户内容拼进 message
// 时,带它等于把她的原话抄进日志(见文件头注释的漏法演示)。
Message string
}
// Error 渲染一句**只含上述字段**的单行诊断。可诊断性来自 status/code/type/
// bodyLen/bodySHA256,绝不来自响应体原文。
func (e *UpstreamErrorInfo) Error() string {
var b strings.Builder
b.WriteString("upstream error")
if e.Upstream != "" {
b.WriteString(" from ")
b.WriteString(e.Upstream)
}
if e.Status != 0 {
fmt.Fprintf(&b, ": status=%d", e.Status)
} else {
b.WriteString(": status=unknown")
}
if e.ContentType != "" {
b.WriteString(" content_type=")
b.WriteString(e.ContentType)
}
fmt.Fprintf(&b, " body_bytes=%d", e.BodyLen)
if e.BodySHA256 != "" {
b.WriteString(" body_sha256=")
b.WriteString(e.BodySHA256)
}
if e.Code != "" {
b.WriteString(" code=")
b.WriteString(e.Code)
}
if e.Type != "" {
b.WriteString(" type=")
b.WriteString(e.Type)
}
if e.Message != "" {
b.WriteString(" message=")
b.WriteString(strconv.Quote(e.Message))
}
return b.String()
}
// Unwrap 让 errors.Is(err, ErrUpstream) 成立,调用方据此走降级链而不必解析文本。
func (e *UpstreamErrorInfo) Unwrap() error { return ErrUpstream }
// Attrs 把同一份脱敏信息渲染成 slog 的键值对,供结构化日志直接展开
// (s.log.Error("provider call failed", info.Attrs()...))。它与 Error() 同源,
// 因此不存在「文本里干净、日志里脏」的第二条路径。
func (e *UpstreamErrorInfo) Attrs() []any {
attrs := []any{
"upstream", e.Upstream,
"status", e.Status,
"content_type", e.ContentType,
"body_bytes", e.BodyLen,
"body_sha256", e.BodySHA256,
}
if e.Code != "" {
attrs = append(attrs, "code", e.Code)
}
if e.Type != "" {
attrs = append(attrs, "type", e.Type)
}
if e.Message != "" {
attrs = append(attrs, "message", e.Message)
}
return attrs
}
// upstreamOptions 是 UpstreamError 的可选开关集合。零值 = **全部关闭**,
// 也就是「默认安全」:不带 message。
type upstreamOptions struct {
keepMessage bool
}
// UpstreamOption 是 UpstreamError 的可选开关。函数式选项的形状让
// `UpstreamError(a, b, c, d)` 这个最常用的调用**天然是最安全的那个**——
// 想放宽任何一项,都必须在这里显式写出来,代码评审时一眼可见。
type UpstreamOption func(*upstreamOptions)
// WithUpstreamMessage 显式要求保留上游 JSON 的 error.message(过 RedactText
// 清洗凭据形状、压平空白,再截断到 maxUpstreamMessageRunes)。
//
// 🔴 只有在你**确认该上游不会把用户内容拼进 message** 时才可用。默认不带它是
// 刻意的:`{"error":{"message":"Invalid value: <用户原文>"}}` 这种写法真实存在,
// 128 rune 会原样进 error 与日志;M41(embedding input = 她的记忆正文、
// DashScope TTS input = 她要念的话)与 M42(渠道响应体)尤其不能默认打开。
//
// 用法:
//
// err := trace.UpstreamError(host, status, ct, body, trace.WithUpstreamMessage())
func WithUpstreamMessage() UpstreamOption {
return func(o *upstreamOptions) { o.keepMessage = true }
}
// UpstreamError 把一次上游失败收敛成一个**绝不携带响应体原文**的错误。
//
// 用法(M41/M42 的用法模板):
//
// err := trace.UpstreamError(trace.HostOf(reqURL), resp.StatusCode,
// resp.Header.Get("Content-Type"), body)
// if errors.Is(err, trace.ErrUpstream) { ...降级到备用 provider... }
// s.log.Error("upstream call failed", err) // 安全:Error() 只渲染脱敏字段
//
// body 可以是任意字节(JSON、HTML、代理页、二进制):它只被用来数长度、取
// sha256 前缀,并在看起来是 JSON 时抽取 error.code / error.type(error.message
// **不抽**,除非显式 WithUpstreamMessage)。其余字段、以及 body 的任何一个字节
// 都不会进入返回的错误。
//
// 🔴 默认口径:**不带 error.message**。见文件头「为什么 message 默认不带」——
// 宁可少一条人话提示,也不让她的原话进日志;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 {
if opt != nil {
opt(&cfg)
}
}
info := &UpstreamErrorInfo{
Upstream: sanitizeUpstreamLabel(upstream),
Status: status,
ContentType: sanitizeContentType(contentType),
BodyLen: len(body),
}
if len(body) > 0 {
info.BodySHA256 = DigestOf(string(body)).SHA256
}
if parsed, ok := parseUpstreamErrorBody(body); ok {
// 🔴 构造即安全(M49):code / type 在这里过令牌守卫,调用点不需要也不应该
// 再守一遍 —— 守卫留在调用方的形态必然漏点(新调用点默认不安全)。
info.Code = ErrorToken(parsed.Code)
info.Type = ErrorToken(parsed.Type)
if cfg.keepMessage {
info.Message = sanitizeUpstreamMessage(parsed.Message)
}
}
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 {
Code string
Type string
Message string
}
// parseUpstreamErrorBody 尽力从 JSON 响应体里抽出 error.code / error.type /
// error.message。不是 JSON(HTML 错误页、纯文本、被截断的报文)⇒ ok=false,
// 此时错误里只有 status/contentType/bodyLen/bodySHA256 —— 正文一个字节都不留。
//
// message 虽然在这里被解析出来,但**只有 opt-in(WithUpstreamMessage)才会往
// 外走**;默认构造把它原样丢掉(见 UpstreamError 的默认口径)。
func parseUpstreamErrorBody(body []byte) (upstreamErrorBody, bool) {
if len(body) == 0 {
return upstreamErrorBody{}, false
}
var envelope struct {
Error struct {
Code json.RawMessage `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return upstreamErrorBody{}, false
}
parsed := upstreamErrorBody{
Code: normalizeUpstreamCode(envelope.Error.Code),
Type: sanitizeUpstreamMessage(envelope.Error.Type),
Message: envelope.Error.Message,
}
if parsed.Code == "" && parsed.Type == "" && parsed.Message == "" {
return upstreamErrorBody{}, false
}
return parsed, true
}
// normalizeUpstreamCode 把 error.code 归一成短字符串:JSON 字符串取原值,
// JSON 数字取十进制文本(Google 风格用 int code),其它形态一律丢弃。
func normalizeUpstreamCode(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return sanitizeUpstreamMessage(s)
}
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
return sanitizeUpstreamMessage(n.String())
}
return ""
}
// sanitizeUpstreamMessage 是上游**自由文本**的唯一清洗路径:压平空白(一条诊断
// 只占一行,也顺带挡掉换行注入),清洗凭据形状,再截断到
// maxUpstreamMessageRunes。
func sanitizeUpstreamMessage(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.Join(strings.Fields(s), " ")
return truncate(redactSecrets(s), maxUpstreamMessageRunes)
}
// sanitizeUpstreamLabel 清洗 upstream 名/主机:不允许它成为第二正文通道。
func sanitizeUpstreamLabel(s string) string {
return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes)
}
// sanitizeContentType 只保留媒体类型本体(丢掉 charset/boundary 等参数),
// 并清洗凭据形状 —— Content-Type 的参数偶尔会带 provider 自造的 token。
func sanitizeContentType(s string) string {
if i := strings.IndexByte(s, ';'); i >= 0 {
s = s[:i]
}
return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes)
}