Files
AskaEth 0f9251b983 fix(channel): 渠道适配器与出站工具的上游响应体/带凭据 URL 不再进日志与错误(M41)
接 M38 的 18 条真泄漏里「渠道适配器 + 出站工具」这一族(#10 #11 #12 #13 #15 #16)。
只动日志与 error 文字:发送/接收、解析口径、重试次数一个都没改。

- httpclient.do:非 2xx 不再把 512B 响应体原文拼进 error,改 trace.UpstreamError
  (状态/体积/sha256 前缀)+ 信封 code/message(RedactText + 200 rune);error 里的
  target 改 trace.HostOf,并剥掉 *url.Error 外壳(它带整条地址)
- telegram doJSON:4xx 只解 error_code/description(清洗 + 截断),体其余字段不拼;
  传输错误只留来源 —— Bot API 把 token 写在地址路径里
- weixin_ilink doJSON:4096B 体 ⇒ trace.UpstreamError,rawURL ⇒ mediafetch.HostOf;
  uploadOnce 剥掉预签名上传地址(encrypted_query_param),重试日志补来源主机
- qq_obv11 connectClient 成功 INFO:改走同包已有的 maskAccessTokenQuery
  (OneBot 惯例把 ?access_token= 写进 remote_url)
- plugins/tools/io http 拦截日志:改记 trace.HostOf + 原因码,不记原 URL、也不记错误
  原文(解析失败时它包着 *url.Error)

每条都有能变异打红的用例:QQ/iLink 预签名地址的哨兵、三处 4xx 体哨兵、
user:pass@ 与 query 凭据;八处变异逐条验证变红。
2026-10-26 13:47:56 +08:00

160 lines
6.1 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 weixin_ilink
// 本文件钉住 iLink 出站侧的两条出镜边界(M41 #10/#11 那一族):
//
// - **预签名上传地址不进日志与错误**:uploadURL 由 getuploadurl 签发,
// `encrypted_query_param` 就是下载凭据;而 net/http 的 *url.Error 会把整条地址
// 拼进错误串,那条错误要经 `cdn upload attempt failed, retrying` 与 send.go 的
// `outbound media item skipped` 两处落日志。
// - **非 2xx 的响应体原文不进错误**:iLink 在 errmsg 里回显请求内容,旧实现把 4096B
// 体原文拼进 error。
//
// 两条都是「改回去就变红」的形状:断言的是错误文本与日志文本本身。
import (
"bytes"
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// closedLoopbackAddr 返回一个**刚被关掉**的 127.0.0.1 端口地址。用它而不是拨一个
// 域名:这里要的是「连接被拒」这条确定的传输层失败 —— 只有传输层失败才会让
// net/http 包出带完整地址的 *url.Error,那正是要钉住的泄漏形态(不需要 DNS、不需要
// 外网、失败是立刻的)。
func closedLoopbackAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
if cerr := ln.Close(); cerr != nil {
t.Fatalf("close listener: %v", cerr)
}
return addr
}
// newLeakTestAdapter 构造一个只用来发起出站调用的适配器:日志写进返回的 buffer,
// 便于断言「日志里搜不到哨兵」。
func newLeakTestAdapter(t *testing.T) (*Adapter, *bytes.Buffer) {
t.Helper()
buf := &bytes.Buffer{}
ad, err := New(
Config{BaseURL: "http://127.0.0.1:9/", BotToken: "tok"},
nil,
logger.New(logger.Options{Out: buf, Level: "debug"}),
)
if err != nil {
t.Fatalf("New: %v", err)
}
return ad, buf
}
// TestUploadCiphertext_PresignedURLStaysOutOfLogs 重试日志与最终错误里都不得出现
// 预签名地址的签名参数。
func TestUploadCiphertext_PresignedURLStaysOutOfLogs(t *testing.T) {
t.Parallel()
const secret = "PRESIGNED-ENCRYPTED-PARAM-SECRET"
ad, logs := newLeakTestAdapter(t)
uploadURL := "http://" + closedLoopbackAddr(t) + "/upload?encrypted_query_param=" + secret + "&filekey=abc"
_, err := ad.uploadCiphertext(context.Background(), uploadURL, []byte("cipher"))
if err == nil {
t.Fatal("连接被拒却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("预签名地址进了错误(会被 send.go 落日志):%v", err)
}
if !strings.Contains(err.Error(), "dial tcp") {
t.Fatalf("剥掉地址时把原因一起丢了:%v", err)
}
logged := logs.String()
if strings.Contains(logged, secret) {
t.Fatalf("预签名地址进了日志:\n%s", logged)
}
// 日志确实被写到了(否则上面的断言是空跑):重试行必须出现,且带来源主机。
if !strings.Contains(logged, "cdn upload attempt failed") {
t.Fatalf("没有重试日志,测试没覆盖到目标路径:\n%s", logged)
}
if !strings.Contains(logged, "host=http://127.0.0.1") {
t.Fatalf("重试日志缺少来源主机(诊断面不能一起丢):\n%s", logged)
}
}
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 非 2xx 的体原文不得进错误。
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-ILINK-4XX"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"ret":-1,"errcode":-14,"errmsg":"` + sentinel + `"}`))
}))
defer srv.Close()
ad, _ := New(Config{BaseURL: srv.URL, BotToken: "tok"}, srv.Client(),
logger.New(logger.Options{Out: &bytes.Buffer{}, Level: "debug"}))
var out map[string]any
err := ad.doJSON(context.Background(), http.MethodPost, ad.apiURL("/ilink/bot/getupdates"), nil, &out)
if err == nil {
t.Fatal("500 却报成功")
}
if strings.Contains(err.Error(), sentinel) {
t.Fatalf("响应体原文进了错误:%v", err)
}
if !strings.Contains(err.Error(), "status=500") || !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("状态码/体积丢了(诊断面不能一起丢):%v", err)
}
if !strings.Contains(err.Error(), "127.0.0.1") {
t.Fatalf("错误里没有来源主机:%v", err)
}
}
// TestDoJSON_TransportErrorOmitsRequestURL 传输层失败时,错误里不得出现请求地址。
func TestDoJSON_TransportErrorOmitsRequestURL(t *testing.T) {
t.Parallel()
const secret = "QUERY-ENCRYPTED-PARAM-SECRET"
ad, _ := newLeakTestAdapter(t)
rawURL := "http://" + closedLoopbackAddr(t) + "/ilink/bot/getupdates?encrypted_query_param=" + secret
err := ad.doJSON(context.Background(), http.MethodPost, rawURL, nil, nil)
if err == nil {
t.Fatal("连接被拒却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("请求地址的签名参数进了错误:%v", err)
}
if !strings.Contains(err.Error(), "dial tcp") {
t.Fatalf("剥掉地址时把原因一起丢了:%v", err)
}
}
// TestUploadCiphertext_LogLevelIsWarn 钉住级别:重试行是 WARN(默认 INFO 级别下可见),
// 不是只有 debug 才看得到的取证行 —— 这条线要在生产默认级别下就安全。
func TestUploadCiphertext_RetryLogIsWarnAndCarriesHost(t *testing.T) {
t.Parallel()
const secret = "PRESIGNED-SECRET-2"
buf := &bytes.Buffer{}
ad, err := New(Config{BaseURL: "http://127.0.0.1:9/", BotToken: "tok"}, nil,
logger.New(logger.Options{Out: buf, Level: "info"}))
if err != nil {
t.Fatalf("New: %v", err)
}
uploadURL := "http://" + closedLoopbackAddr(t) + "/upload?encrypted_query_param=" + secret
if _, uerr := ad.uploadCiphertext(context.Background(), uploadURL, []byte("x")); uerr == nil {
t.Fatal("连接被拒却报成功")
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") {
t.Fatalf("重试行不是 WARN 级(默认 INFO 下会看不见):\n%s", logged)
}
if strings.Contains(logged, secret) {
t.Fatalf("预签名地址进了日志:\n%s", logged)
}
}