Files
Cyrene_v2/plugins/channels/telegram/botapi_leak_test.go
T
AskaEth 4b4173dd42 fix(channel): M41 r1 复审三条 —— telegram 构造分支、QQ 改 MaskQuery、删两处死上限
P2-1 telegram doJSON 的**请求构造**失败分支:http.NewRequestWithContext 解析失败的
err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),而 poll.go 的 long poll
failed 会原样落日志。改成与传输层分支同形:trace.HostOf(endpoint) + callCause。

P2-2 qq_obv11 connectClient 的成功 INFO 从 maskAccessTokenQuery 换成
trace.MaskQuery:后者认得 access%5Ftoken / token / api_key / 签名参数 / userinfo,
且保留 EscapedPath(路径可读)与不截断。maskAccessTokenQuery 仍被
sanitizeConnectReason 使用,未成死代码。

P2-3 删掉 maxEnvelopeMessageRunes / maxDescriptionRunes(各 200):trace.RedactText
内部已 truncate 到 maxSummaryRunes=80,这两层永不命中,注释口径与实际不符。改为
直接 return trace.RedactText(压平空白),注释写明 80 rune 由 RedactText 负责。

用例(全部变异打红,逐条验证):telegram 构造分支 token、QQ 八形态凭据矩阵
(含四种 maskAccessTokenQuery 漏掉的形态 + 路径可读反向断言)、两处 80 rune 上限。
2026-10-26 13:58:19 +08:00

155 lines
6.4 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 那一族):
//
// - **非 200 的响应体原文不进错误**:旧实现把 512B 体原文当 description 拼进
// error,而 `ReadMessages` 会以 `long poll failed` 落日志 —— 上游在体里回显什么,
// 日志里就有什么。现在只解 error_code 与 description(清洗、截断后)。
// - **地址里的 bot token 不进错误**:Bot API 的地址是 /bot<token>/<method>,
// 传输层失败时 net/http 的 *url.Error 会把整条地址拼进错误串。
//
// 两条都是「改回去就变红」的形状:断言的是错误文本 —— 也就是真正被写进日志的字符串。
import (
"context"
"errors"
"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 按设计要保留(那是 Telegram 对
// 这次请求的说明),而「体里其余字节一个字都不拼」才是要钉住的口径。
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: chat not found",` +
`"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)
}
if strings.Contains(err.Error(), sentinel) {
t.Fatalf("响应体其余字段进了错误(会随 long poll failed 落日志):%v", err)
}
if !strings.Contains(err.Error(), "error_code 400") || !strings.Contains(err.Error(), "chat not found") {
t.Fatalf("error_code/description 丢了(诊断面不能一起丢):%v", err)
}
if !strings.Contains(err.Error(), "status 400") {
t.Fatalf("HTTP 状态码丢了:%v", err)
}
}
// TestDoJSON_StatusErrorRedactsDescriptionSecrets description 是上游自由文本,而
// setWebhook 失败时 Telegram 会把我们给的地址回显在里面 —— 地址的 query 常带凭据。
// 这类赋值形状必须被清洗。改回原样透传就变红。
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)
}
}
// 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_DescriptionIsBoundedByRedactText 钉住长度口径:description 只有一道
// 清洗(trace.RedactText,80 rune + 省略号)。本片曾按 200 rune 自己再截一刀,那层
// 永远命中不了 —— 这条用例让「到底哪一层在截」变成可断言的事实。
func TestDoJSON_DescriptionIsBoundedByRedactText(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(), strings.Repeat("a", 80)+"…") {
t.Fatalf("description 没有按 80 rune 截断:%v", err)
}
if strings.Contains(err.Error(), strings.Repeat("a", 81)) {
t.Fatalf("description 超过了 80 rune 上限:%v", err)
}
}