fix(trace): UpstreamError 默认不带 error.message,要带必须显式 opt-in
复审 P2 收口。上一条留的漏法(不是理论,是构造得出的):
`{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}`
—— 默认保留前 128 rune,那 128 rune 就会**原样**进 error 与日志。此前的用例只钉了
input/echo 这类 sibling 字段,没钉到 message 这一面;而 M41(embedding input 是她的
记忆正文、DashScope TTS input 是她要念的话)与 M42(渠道响应体)都把这个 helper 当
模板,默认带 message 等于把洞复制到十几处。
改法(默认安全,放宽必须写出来):
- `UpstreamError(upstream, status, contentType, body, opts ...UpstreamOption)`:
最常用的四参调用**天然是最安全的那个**;默认只留 Upstream/Status/ContentType/
BodyLen/BodySHA256/Code/Type;
- 新增 `WithUpstreamMessage()` 作为显式开关:带 message 时**必须**过 RedactText
(凭据形状打码 + 压平空白)再截到 maxUpstreamMessageRunes,并在注释里写明
「只有在你确认该上游不会回显用户内容时才可用」;
- 文件头写清取舍:**宁可少一条人话提示,也不让她的原话进日志**;排障靠
status + code + type + bodyLen + sha256 通常足够分类(400 还是 429、是
model_not_found 还是 context_length_exceeded、是不是同一份报文),细节要看时
报文在调用点上下文里还在。
测试(可变异打红):
- 新增 TestUpstreamError_DefaultDropsMessage:message 里塞哨兵的 400 ⇒ 默认构造下
error 与 slog 输出都搜不到哨兵,Message 为空,而 status/code/type/bodyLen/sha256
仍可辨;Attrs() 也不旁路带出;
- 新增 TestUpstreamError_OptInMessageIsSanitized:opt-in 后 message 被截到 128 rune
且凭据被打码(超长 + 带 token 的 message);
- 既有 TestUpstreamError_DropsEchoedBody 补一条「默认 Message 为空」;
- 变异验证:把默认构造改回带 message ⇒ 前两条变红;让 opt-in 跳过 RedactText ⇒
opt-in 那条变红。
回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿(backthink 的两条
2s 定时用例在满并发下偶发超时,单元重跑与整仓重跑均绿,与本改动无关:该包不引用
trace);trace 包另跑 -race 绿。只动 upstream.go 与其测试,白名单/session/loop/
orchestration 未动。
This commit is contained in:
1 parent
6d0df6a825
commit
542bac677d
2 files changed
+136
-17
No files matched your search
@@ -228,6 +228,11 @@ func TestUpstreamError_DropsEchoedBody(t *testing.T) {
|
||||
if info.BodyLen != len(body) || info.ContentType != "application/json" || info.BodySHA256 == "" {
|
||||
t.Fatalf("info = %+v,want bodyLen/contentType/sha256 作为诊断线索", info)
|
||||
}
|
||||
// 默认构造连上游那句人话(error.message)也不带:它的措辞里就可能嵌着用户内容
|
||||
// (这个报文里是 'messages[0].content'),默认安全口径见上游文件头注释。
|
||||
if info.Message != "" {
|
||||
t.Fatalf("默认构造的 Message = %q,want 空", info.Message)
|
||||
}
|
||||
// Attrs() 是日志侧的第二出口,必须与 Error() 同源(同样不含正文)。
|
||||
attrs := info.Attrs()
|
||||
if joined := strings.TrimSpace(strings.Join(anyToStrings(attrs), " ")); strings.Contains(joined, echoedInput) {
|
||||
@@ -250,19 +255,78 @@ func TestUpstreamError_NonJSONBodyKeepsOnlyMeta(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamErrorMessageIsBounded 钉住 message 的上限:上游把一大段东西写进
|
||||
// error.message 时,进错误的只有前 128 rune。
|
||||
func TestUpstreamErrorMessageIsBounded(t *testing.T) {
|
||||
// TestUpstreamError_DefaultDropsMessage 是 P2 收口的行为钉(M41/M42 的模板):
|
||||
// 上游把**用户内容**拼进 error.message 时,**默认构造**必须一个字都不带出来。
|
||||
// 这不是理论担心 —— `{"error":{"message":"Invalid value: <用户原文>"}}` 是真实
|
||||
// 存在的写法,默认保留前 128 rune 就够把一段原话抄进日志。
|
||||
//
|
||||
// 断言同时要求「诊断仍可辨」:status / code / type / bodyLen / bodySHA256 都还在,
|
||||
// 它们才是分类与降级的依据。
|
||||
func TestUpstreamError_DefaultDropsMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := []byte(`{"error":{"code":"bad","message":"` + strings.Repeat("详", 400) + `"}}`)
|
||||
err := UpstreamError("p", 400, "application/json", body)
|
||||
const sentinel = "SENTINEL-USER-INPUT-IN-MESSAGE-3c9f-她在输入框里写的话"
|
||||
body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` +
|
||||
`"message":"Invalid value: ` + sentinel + `"}}`)
|
||||
|
||||
err := UpstreamError("api.example.com", 400, "application/json", body)
|
||||
var logBuf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||
log.Error("upstream call failed", "err", err)
|
||||
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, sentinel) {
|
||||
t.Fatalf("默认构造把 error.message 里的用户内容带出来了:%s", surface)
|
||||
}
|
||||
}
|
||||
info := err.(*UpstreamErrorInfo)
|
||||
if info.Message != "" {
|
||||
t.Fatalf("默认构造的 Message = %q,want 空(message 必须显式 opt-in)", info.Message)
|
||||
}
|
||||
for _, want := range []string{"status=400", "invalid_request_error", "type=invalid_request", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q,want 含 %q(丢掉 message 不等于丢掉诊断)", err.Error(), want)
|
||||
}
|
||||
}
|
||||
if info.BodyLen != len(body) || info.BodySHA256 == "" {
|
||||
t.Fatalf("info = %+v,want bodyLen/bodySHA256 仍用于相关性比对", info)
|
||||
}
|
||||
// Attrs() 同样不得旁路带出 message。
|
||||
if joined := strings.Join(anyToStrings(info.Attrs()), " "); strings.Contains(joined, sentinel) {
|
||||
t.Fatalf("Attrs() 带出了 message 里的用户内容:%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_OptInMessageIsSanitized 钉住 opt-in 侧的边界:显式
|
||||
// WithUpstreamMessage 之后拿到的 message 仍**必须**过 RedactText(凭据打码)并
|
||||
// 截断到 maxUpstreamMessageRunes —— opt-in 放宽的是「带不带人话」,不是「带多脏」。
|
||||
func TestUpstreamError_OptInMessageIsSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "abcdef1234567890"
|
||||
// 凭据放在开头(截断窗口内),后面接一大段正文,验证「打码 + 截断」两件事。
|
||||
message := "lookup failed: token=" + secret + " " + strings.Repeat("详", 400)
|
||||
body := []byte(`{"error":{"code":"bad","type":"invalid_request","message":"` + message + `"}}`)
|
||||
|
||||
err := UpstreamError("p", 400, "application/json", body, WithUpstreamMessage())
|
||||
info := err.(*UpstreamErrorInfo)
|
||||
if info.Message == "" {
|
||||
t.Fatal("opt-in 之后 Message 仍为空")
|
||||
}
|
||||
if strings.Contains(info.Message, secret) {
|
||||
t.Fatalf("opt-in 的 message 带着凭据原文:%q", info.Message)
|
||||
}
|
||||
if !strings.Contains(info.Message, redactedMarker) {
|
||||
t.Fatalf("opt-in 的 message = %q,want 含 %s(必须走 RedactText)", info.Message, redactedMarker)
|
||||
}
|
||||
if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 {
|
||||
t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes)
|
||||
}
|
||||
if !strings.HasPrefix(info.Message, "详") {
|
||||
if !strings.HasPrefix(info.Message, "lookup failed:") {
|
||||
t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message)
|
||||
}
|
||||
// opt-in 只放宽 message:凭据不许旁路进 Error() 的其它字段。
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("opt-in 后 Error() 带着凭据原文:%s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostOf_DropsPathQueryAndUserInfo 钉住「日志里的 URL 只有来源」。
|
||||
|
||||
Reference in new issue
Block a user