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 凭据;八处变异逐条验证变红。
This commit is contained in:
AskaEth committed 2026-10-26 13:47:56 +08:00
1 parent 46a6da13e0
commit 0f9251b983
11 files changed
+726 -29

No files matched your search

+36 -7
View File
@@ -10,17 +10,25 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// ilinkAuthType 是 iLink 请求头 `AuthorizationType` 的协议定值,仅此一处定义。
const ilinkAuthType = "ilink_bot_token"
// maxErrorBodyBytes 是 iLink 非 2xx 响应里被读取的字节上限:只用来数体积与取
// sha256 前缀(trace.UpstreamError 的口径),正文不进错误。
const maxErrorBodyBytes = 4096
// authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer
// token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。
func (a *Adapter) authorizationHeader(req *http.Request) {
@@ -61,34 +69,55 @@ func randomUIN() string {
// doJSON 发起一次 HTTP 请求并把 JSON 响应解码进 out。它关闭响应体,并用 %w
// 包裹网络/解码错误。out 为 nil 时跳过解码(用于响应体是不透明字节的 PUT)。
//
// 地址只以来源形态出镜(mediafetch.HostOf):rawURL 由 base+path 拼成,而 pollBase
// 可能是对端重定向给的主机,完整串没有进日志的必要。非 2xx 的响应体同样不拼 ——
// iLink 会在 errmsg 里回显请求内容,这里只经 trace.UpstreamError 留下
// 状态码/体积/sha256 前缀(同 M40 口径)。
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
host := mediafetch.HostOf(rawURL)
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, rawURL, err)
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, host, urlFreeCause(err))
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: %s %s: %w", method, rawURL, err)
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host, urlFreeCause(err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("weixin_ilink: %s %s: status %d: %s",
method, rawURL, resp.StatusCode, strings.TrimSpace(string(msg)))
msg, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host,
trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), msg))
}
if out == nil {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, rawURL, err)
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, host, urlFreeCause(err))
}
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, rawURL, err)
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, host, err)
}
return nil
}
// urlFreeCause 剥掉 net/http 的 *url.Error 外壳:它会把**整条地址**拼进错误串 ——
// doJSON 的地址、uploadCiphertext 的预签名上传地址(`encrypted_query_param` 就是下载
// 凭据)都会跟着进错误与日志。剥壳只丢地址,内层哨兵链(context 取消、*net.DNSError
// 等)原样保留,因此 fetchErrCategory 的归类不受影响。
//
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径:那份是未导出符号,且只服务
// 于入站取回路径(mediafetch 不感知本包的发送侧),因此这里保留一份同形的三行实现。
func urlFreeCause(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}
// apiURL 把路径与已配置的基础 URL 拼接起来。
func (a *Adapter) apiURL(path string) string {
return joinURL(a.cfg.BaseURL, path)
+13 -3
View File
@@ -31,6 +31,7 @@ import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
)
const (
@@ -200,6 +201,9 @@ func (a *Adapter) uploadURLFromParam(param, fileKey string) string {
// - `src/cdn/cdn-upload.ts:40-83`:4xx 立即放弃(附服务端 `x-error-message`),
// 其余失败最多重试 3 次(无退避);
// - `src/cdn/cdn-upload.ts:62-65`:200 但没有该响应头同样是失败(不得当成成功)。
//
// 日志只带**来源主机**:uploadURL 是服务端签发的预签名地址(`encrypted_query_param`
// 就是下载凭据),整条写进日志等于把凭据写进日志。
func (a *Adapter) uploadCiphertext(ctx context.Context, uploadURL string, data []byte) (string, error) {
var lastErr error
for attempt := 1; attempt <= uploadMaxAttempts; attempt++ {
@@ -212,22 +216,28 @@ func (a *Adapter) uploadCiphertext(ctx context.Context, uploadURL string, data [
return "", lastErr
}
a.log.Warn("cdn upload attempt failed, retrying",
"attempt", attempt, "of", uploadMaxAttempts, "err", err)
"attempt", attempt, "of", uploadMaxAttempts,
"host", mediafetch.HostOf(uploadURL), "err", err)
}
return "", fmt.Errorf("weixin_ilink: upload media: %d 次尝试均失败: %w", uploadMaxAttempts, lastErr)
}
// uploadOnce 做一次上传尝试,返回下载凭据。retryable 报告这次失败是否值得重试:
// 4xx 是客户端错误(请求本身不对),重试只是徒劳;网络/5xx/缺头都可能瞬时。
//
// 错误里只留来源主机:上传地址是服务端签发的预签名 URL,而 `*url.Error` 会把整条
// 地址(含 `encrypted_query_param`)拼进错误串 —— 这条错误还要经 send.go 的
// `outbound media item skipped` 落日志,因此必须在**产生错误的那一步**剥掉。
func (a *Adapter) uploadOnce(ctx context.Context, uploadURL string, data []byte) (string, bool, error) {
host := mediafetch.HostOf(uploadURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(data))
if err != nil {
return "", false, fmt.Errorf("weixin_ilink: build upload: %w", err)
return "", false, fmt.Errorf("weixin_ilink: build upload to %s: %w", host, urlFreeCause(err))
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := a.httpClient.Do(req)
if err != nil {
return "", true, fmt.Errorf("weixin_ilink: upload media: %w", err)
return "", true, fmt.Errorf("weixin_ilink: upload media to %s: %w", host, urlFreeCause(err))
}
defer resp.Body.Close()
@@ -0,0 +1,159 @@
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)
}
}