Files
Cyrene_v2/plugins/channels/telegram/botapi_leak_test.go
T
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00

169 lines
7.1 KiB
Go
Raw 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 telegram
// 本文件钉住 Bot API 出站调用的三条出镜边界(M41 #12/#13 那一族,M50 补 description):
//
// - **非 200 的响应体原文不进错误**:旧实现把 512B 体原文当 description 拼进
// error,而 `ReadMessages` 会以 `long poll failed` 落日志 —— 上游在体里回显什么,
// 日志里就有什么。现在只解 error_code 与 HTTP status。
// - **description 默认不带**:它是上游自由文本,Telegram 会回显我们发过去的参数
// (里面就有她的话)。要带必须显式 opt-in;本适配器不开。
// - **地址里的 bot token 不进错误**:Bot API 的地址是 /bot<token>/<method>,
// 传输层失败时 net/http 的 *url.Error 会把整条地址拼进错误串。
//
// 三条都是「改回去就变红」的形状:断言的是错误文本 —— 也就是真正被写进日志的字符串。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"strings"
"testing"
)
// failingTransport 让 a.client.Do 以传输层错误收场(只有它会让 net/http 包出带完整
// 地址的 *url.Error,那正是要钉住的泄漏形态)。
type failingTransport struct{}
func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp: connection refused")
}
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
//
// 哨兵同时放在 description 之外与 description 之内:description 是上游自由文本面,
// **默认不带**(M50),因此两个位置的哨兵都必须搜不到;留下的诊断面是 status 与
// error_code。
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-TG-BODY"
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities: ` + sentinel + `",` +
`"echoed_params":{"text":"` + sentinel + `"}}`))
}))
_, err := a.doJSON(context.Background(), "sendMessage", map[string]any{"chat_id": "999"})
if err == nil {
t.Fatal("400 却报成功")
}
if !errors.Is(err, ErrBadResponse) {
t.Fatalf("err = %v, want ErrBadResponse", err)
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("long poll failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, sentinel) {
t.Fatalf("响应体原文(含 description 回显)进了错误或日志:%s", surface)
}
}
// 变异:把 description 拼回 parts ⇒ 上面那条立刻红。
if !strings.Contains(err.Error(), "error_code 400") {
t.Fatalf("error_code 丢了(诊断面不能一起丢):%v", err)
}
if !strings.Contains(err.Error(), "status 400") {
t.Fatalf("HTTP 状态码丢了:%v", err)
}
}
// TestDoJSON_StatusErrorRedactsDescriptionSecrets 上一族里的凭据形态:description 现在
// 整段不带,因此 setWebhook 回显的地址(query 常带凭据)也一并不能出镜。这条与
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 是同一处代码的两个变异方向 —— 一个钉
// 「正文不进来」,一个钉「凭据不出去」。
func TestDoJSON_StatusErrorRedactsDescriptionSecrets(t *testing.T) {
t.Parallel()
const secret = "WEBHOOK-SECRET-VALUE"
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"bad webhook ` +
`https://example.com/hook?access_token=` + secret + `"}`))
}))
_, err := a.doJSON(context.Background(), "setWebhook", map[string]any{"url": "https://x"})
if err == nil {
t.Fatal("400 却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("description 里的凭据原样进了错误:%v", err)
}
if !strings.Contains(err.Error(), "error_code 400") || !strings.Contains(err.Error(), "status 400") {
t.Fatalf("剥掉 description 时把分类信息一起丢了:%v", err)
}
}
// TestDoJSON_TransportErrorOmitsBotToken 传输层失败时,错误里不得出现 bot token ——
// 它就写在 Bot API 的地址路径里。
func TestDoJSON_TransportErrorOmitsBotToken(t *testing.T) {
t.Parallel()
const token = "123456:AAH-SECRET-BOT-TOKEN"
a := New(
Config{BotToken: token},
WithBaseURL("http://api.example.com"),
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
WithLogger(discardLogger()),
)
_, err := a.doJSON(context.Background(), "getUpdates", nil)
if err == nil {
t.Fatal("传输层失败却报成功")
}
if strings.Contains(err.Error(), token) {
t.Fatalf("bot token 随地址进了错误(会随 long poll failed 落日志):%v", err)
}
if !strings.Contains(err.Error(), "api.example.com") || !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("剥掉地址时把来源或原因一起丢了:%v", err)
}
}
// TestDoJSON_BuildErrorOmitsBotToken 请求**构造**失败也是同一族:地址解析失败的
// err 同样是 *url.Error,里面就是整条 /bot<token>/… 地址,而 poll.go 会原样落日志。
// 与传输层分支同形:只留来源,原因保留。
func TestDoJSON_BuildErrorOmitsBotToken(t *testing.T) {
t.Parallel()
const token = "424242:PROBE-BUILD-TOKEN"
a := New(
Config{BotToken: token},
// 控制字符让 url.Parse 失败 —— 这是「构造请求」分支唯一现实的触发方式。
WithBaseURL("http://api.example.com/\x7f"),
WithLogger(discardLogger()),
)
_, err := a.doJSON(context.Background(), "getUpdates", nil)
if err == nil {
t.Fatal("构造失败却报成功")
}
if strings.Contains(err.Error(), token) {
t.Fatalf("bot token 随地址进了错误(会随 long poll failed 落日志):%v", err)
}
if !strings.Contains(err.Error(), "build getUpdates request") {
t.Fatalf("错误没说是哪个方法的构造失败:%v", err)
}
if !strings.Contains(err.Error(), "net/url") && !strings.Contains(err.Error(), "invalid") {
t.Fatalf("剥掉地址时把原因一起丢了:%v", err)
}
}
// TestDoJSON_DescriptionIsDropped 钉住「description 默认不带」:无论它多长、写的是什么,
// 错误里都只剩 status + error_code。变异:把 description 拼回 badResponseError 的
// parts ⇒ 本用例立刻红(`aaaa…` 会出现)。
func TestDoJSON_DescriptionIsDropped(t *testing.T) {
t.Parallel()
long := strings.Repeat("a", 300)
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"` + long + `"}`))
}))
_, err := a.doJSON(context.Background(), "getMe", nil)
if err == nil {
t.Fatal("400 却报成功")
}
if strings.Contains(err.Error(), "aaaa") {
t.Fatalf("description 进了错误(M50 口径是默认不带):%v", err)
}
if !strings.Contains(err.Error(), "status 400") || !strings.Contains(err.Error(), "error_code 400") {
t.Fatalf("剥掉 description 时把分类信息一起丢了:%v", err)
}
}