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,57 @@
package vision
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
// 本适配器一行没改也应当自动安全 —— 视觉上游把散文塞进 error.code / error.type
// 时,错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
//
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
// 视觉这一处尤其不能漏 —— 送上游的图像可能是她的照片,回显它的散文同样不能进日志。
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"testing"
)
// TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode 4xx 报文的 error.code /
// error.type 里带散文 ⇒ 两个字段整体消失,status 与体积摘要仍在。
func TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}`
ad := newAdapter("https://vision.example.com")
ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
}, nil
})}
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
if err == nil {
t.Fatal("400 却报成功")
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("vision call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
}
}
for _, want := range []string{"status=400", "body_bytes="} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("错误里缺少 %q:%v", want, err)
}
}
}
@@ -41,7 +41,13 @@ func classifyDashscopeStatus(endpoint string, status int, contentType string, bo
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
return fmt.Errorf("voice: %w", sentinel)
}
info.Code = trace.ErrorToken(fields.code)
// 🔴 DashScope 把错误码放在**顶层** code 上(不是 error.code),构造函数看不见它,
// 因此这里属于「构造后注入」:走 SetCode(同一道令牌守卫)。这不是重复守卫 ——
// 去掉了它,散文形态的顶层 code 就会直接出镜(TestDashscopeTTS_Non200DropsFreeTextCode
// 会变红)。
info.SetCode(fields.code)
// request_id 不进 UpstreamErrorInfo,也不由构造函数解析:它是被**拼进错误文本**的
// 第三个自由文本面,所以它的守卫只能留在这里(这不是「同一件事守两遍」)。
if rid := trace.ErrorToken(fields.requestID); rid != "" {
return fmt.Errorf("voice: %w: request_id=%s: %w", sentinel, rid, info)
}
@@ -98,15 +104,14 @@ func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields {
// message 原文一律不进 error:它是上游自由文本,而这条请求的 input.url 是参考
// 音频的公开地址(可能是预签名 URL,带凭据),上游报错经常把它整段回显出来。
// 病因分类保留在 ErrProviderFailed(再试也不会好,与既有的熔断口径一致);
// 确实带回来的 code/type 也只以令牌形态出镜(过 trace.ErrorToken)。
// 确实带回来的 code/type 由 trace.UpstreamError **构造时**过令牌守卫(M49 上移),
// 这里不再守第二遍。
func dashscopeOKBodyError(endpoint string, body []byte) error {
info, ok := trace.UpstreamError(trace.HostOf(endpoint), http.StatusOK, "", body).(*trace.UpstreamErrorInfo)
if !ok {
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response", ErrProviderFailed)
}
info.Code = trace.ErrorToken(info.Code)
info.Type = trace.ErrorToken(info.Type)
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response: %w",
ErrProviderFailed, info)
}
@@ -0,0 +1,41 @@
package voice
// 本文件是 M49 的**出口回归探针**:DashScope 的「HTTP 200 但业务失败」收口里,
// error.code / error.type 的守卫已经上移进 trace.UpstreamError —— 本包那处调用点的
// 重复守卫已删除,因此这里的断言直接钉在**构造函数**上(摘掉构造函数的守卫就变红)。
//
// 与它相邻的一条(TestDashscopeTTS_Non200DropsFreeTextCode)钉的是另一件事:
// DashScope 的 4xx 把错误码放在**顶层** code(构造函数看不见),那一处靠
// UpstreamErrorInfo.SetCode 注入,守卫仍在。两条合起来覆盖这一族的两个面。
import (
"errors"
"strings"
"testing"
)
// TestDashscopeOKBodyError_DropsFreeTextErrorCode 200 报文的 error.code / error.type
// 里带散文(TTS 场景下可能是她要念的台词被回显)⇒ 两个字段整体消失,而 status=200
// 与 body_bytes(体积摘要)仍在。
func TestDashscopeOKBodyError_DropsFreeTextErrorCode(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)
err := dashscopeOKBodyError("https://dashscope.example.com/api/v1/services/audio/tts", body)
if !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed(熔断口径不变)", err)
}
msg := err.Error()
if strings.Contains(msg, "她刚才说的那句话") {
t.Fatalf("上游 error.code/type 里的散文进了错误:%v", msg)
}
if strings.Contains(msg, " code=") || strings.Contains(msg, " type=") {
t.Fatalf("非令牌形状的 code/type 没有被丢弃:%v", msg)
}
for _, want := range []string{"status=200", "body_bytes="} {
if !strings.Contains(msg, want) {
t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg)
}
}
}