Files
Cyrene_v2/plugins/channels/weixin_ilink/login_log_redaction_test.go
T
AskaEth 1b2576ecaf fix(weixin): 扫码上游字段过令牌守卫,errortoken 契约改写
复审 p2 两条:

① 第五个出口:get_qrcode_status 的 status 与 scaned_but_redirect 的
redirect_host 此前原样进 INFO;同一 status 经 setPhase 还从复用二维码那条
INFO(phase)二次出镜。三处一律过 trace.ErrorToken;**控制流仍用原值**
(状态机读 status.Status、路由读 status.RedirectHost、confirmingPhase 读
phase),用例同时钉住「日志变空串」与「路由/判定不变」两个方向。

② 顺带补扫出一处 URL 类:get_bot_qrcode 的 qrcode_img_content(上游给的
图片地址,可能是带签名的 URL 或 data: 内联图)原样进 INFO ⇒ 改走
trace.MaskQuery(凭据类 query 打码、保留路径、解析不出 host 回空串)。

③ errortoken.go 的契约改写:原「本包之外不应出现 ErrorToken 调用」已被
M50 的正当调用点证伪。新口径按**字段类别**判定:协议枚举字段(status/
code/type/request_id)可在渲染点调用;自由文本字段(message/description/
msg/wording)不得靠它出镜 —— 那类默认不带,要带必须 opt-in + RedactText。
dashscope 的 request_id 例外一并写入。

每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
2026-10-26 19:41:28 +08:00

219 lines
8.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 weixin_ilink
// 本文件钉住扫码登录期**上游字段进日志**的边界(M50 复审的第五个出口):
//
// - `get_qrcode_status` 的 `status` 与 `scaned_but_redirect` 的 `redirect_host` 此前
// 原样写进 INFO —— 两个都是上游字段,此前不过 `trace.ErrorToken`、不过
// `RedactText`、也没有长度上限。现在两者都过令牌守卫后才出镜。
// - 🔴 **控制流仍用原值**:状态机读 `status.Status`、路由读 `status.RedirectHost`。
// 这条比「日志干净」更容易被修坏 —— 一个图省事的修法是把守卫后的值喂给
// `setPollBaseURL`,那会让带端口的合法重定向主机整体失效。
//
// 两条用例的变异方向各一:把守卫从日志里去掉 ⇒ 哨兵出现(红);把守卫**挪进**控制流
// ⇒ 第二条用例的重定向落不到上游给的主机上(红)。
import (
"bytes"
"context"
"log/slog"
"net/http"
"strings"
"sync"
"testing"
"time"
)
// loginProse 是一段「像人话、不像协议令牌」的上游字段值:它若出现在日志里,就说明这一栏
// 又是一个不受控的正文通道。
const loginProse = "她刚才说的那句话"
// syncBuffer 让后台登录 goroutine 的日志写入与测试的读取不打架(-race 下也成立)。
type syncBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
// newLoggingScriptedAdapter 与 newScriptedAdapter 相同,但把日志收进 buf 供断言。
func newLoggingScriptedAdapter(t *testing.T, cfg Config, tr http.RoundTripper, buf *syncBuffer) *Adapter {
t.Helper()
ad, err := New(cfg, &http.Client{Transport: tr}, slog.New(slog.NewJSONHandler(buf, nil)))
if err != nil {
t.Fatalf("New: %v", err)
}
return ad
}
// TestStartLogin_ProseStatusNeverReachesLog status 喂散文 ⇒ 日志里那一栏是空串,
// 哨兵一个字都搜不到;而控制流照旧(散文不匹配任何 case ⇒ 继续轮询,直到窗口结束)。
func TestStartLogin_ProseStatusNeverReachesLog(t *testing.T) {
t.Parallel()
var buf syncBuffer
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"` + loginProse + `"}`) },
}
ad := newLoggingScriptedAdapter(t, Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 300 * time.Millisecond,
}, tr, &buf)
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusFailed
})
line := buf.String()
if line == "" {
t.Fatal("扫码轮询一条日志都没有(静默即缺陷)")
}
if strings.Contains(line, loginProse) {
t.Fatalf("上游 status 原文进了日志:%s", line)
}
// 守卫的实际效果:散文 ⇒ 空串(不是「截断」也不是「保留前几个字」)。
if !strings.Contains(line, `"status":""`) {
t.Fatalf("散文 status 没有收敛成空串(令牌守卫没生效?):%s", line)
}
// 控制流不受影响:散文不匹配任何 case ⇒ 一直轮询到窗口结束(≥2 次),而不是
// 被当成某个阶段直接失败/确认。
if n := len(tr.hosts()); n < 2 {
t.Fatalf("status 轮询 %d 次, want >= 2(散文 status 不该改变控制流)", n)
}
if ad.IsConnected() {
t.Fatal("散文 status 被当成了成功(控制流被渲染口径污染)")
}
}
// TestStartLogin_ProseRedirectHostNeverReachesLog redirect_host 喂散文 ⇒ 日志里没有它,
// 而路由**照旧**切到上游给的那个主机(证明守卫只作用于渲染,没有混进控制流)。
func TestStartLogin_ProseRedirectHostNeverReachesLog(t *testing.T) {
t.Parallel()
var buf syncBuffer
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(n int) []byte {
if n == 1 {
return []byte(`{"status":"scaned_but_redirect","redirect_host":"` + loginProse + `"}`)
}
return []byte(`{"status":"confirmed","bot_token":"tok-x","ilink_bot_id":"bot-x",` +
`"ilink_user_id":"o-1","baseurl":"https://ilinkai.weixin.qq.com"}`)
},
}
ad := newLoggingScriptedAdapter(t, Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second,
}, tr, &buf)
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusConfirmed
})
line := buf.String()
if strings.Contains(line, loginProse) {
t.Fatalf("上游 redirect_host 原文进了日志:%s", line)
}
if !strings.Contains(line, `"host":""`) {
t.Fatalf("散文 redirect_host 没有收敛成空串(令牌守卫没生效?):%s", line)
}
// 🔴 控制流仍用原值:第二次轮询必须落在上游给的那个主机上。
hosts := tr.hosts()
if len(hosts) < 2 {
t.Fatalf("status 轮询 %d 次, want >= 2", len(hosts))
}
if hosts[1] != loginProse {
t.Fatalf("第二次轮询的主机 = %q, want 上游给的 redirect_host 原值(控制流被守卫污染)", hosts[1])
}
}
// TestStartLogin_ProsePhaseNeverReachesLog 同一个上游 status 还有**第二个日志出口**:
// 复用二维码时那条 INFO 打的是 `sess.currentPhase()`(setPhase 存下的上游原值)。它现在
// 过同一道令牌守卫。变异:把守卫从这一行去掉 ⇒ 本用例红。
func TestStartLogin_ProsePhaseNeverReachesLog(t *testing.T) {
t.Parallel()
var buf syncBuffer
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"` + loginProse + `"}`) },
}
ad := newLoggingScriptedAdapter(t, Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 300 * time.Millisecond,
}, tr, &buf)
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
// 等后台轮询把 phase 填成上游原值,再点第二次「扫码登录」走复用分支。
waitFor(t, func() bool { return len(tr.hosts()) >= 1 })
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin (reuse): %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusFailed
})
line := buf.String()
if strings.Contains(line, loginProse) {
t.Fatalf("上游 status 经 currentPhase() 进了第二条日志:%s", line)
}
if !strings.Contains(line, `"phase":""`) {
t.Fatalf("复用二维码那条 INFO 的 phase 没有过令牌守卫:%s", line)
}
// 控制流不受影响:散文 phase 不满足 confirmingPhase,复用走的是「码仍新鲜」那一支,
// 返回的仍是同一个码。
if qr := ad.loginSession; qr != nil && qr.qrcode != "qr-r" {
t.Fatalf("复用分支返回的 qrcode = %q, want qr-r", qr.qrcode)
}
}
// TestStartLogin_QrcodeURLLogIsMasked get_bot_qrcode 的 `qrcode_img_content` 也是
// **上游给的串**(可能是带签名的图片地址,也可能整段是 data: 内联图)。它进日志前走
// trace.MaskQuery:凭据类 query 值打码、路径保留;解析不出 host 时回空串(data: URI
// 就是这么被整体挡掉的)。变异:改回原串 ⇒ 本用例立刻红。
func TestStartLogin_QrcodeURLLogIsMasked(t *testing.T) {
t.Parallel()
const sig = "SIG-SECRET-123"
var buf syncBuffer
tr := &scriptedTransport{
qr: []byte(`{"qrcode":"qr-1","qrcode_img_content":"https://img.example/qr.png?sig=` + sig + `&size=200"}`),
statusFn: func(int) []byte {
return []byte(`{"status":"confirmed","bot_token":"tok-x","ilink_bot_id":"bot-x","ilink_user_id":"o-1"}`)
},
}
ad := newLoggingScriptedAdapter(t, Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second,
}, tr, &buf)
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusConfirmed
})
line := buf.String()
if strings.Contains(line, sig) {
t.Fatalf("二维码图片地址的签名参数原样进了日志:%s", line)
}
if !strings.Contains(line, "https://img.example/qr.png") || !strings.Contains(line, "sig=***") {
t.Fatalf("打码后把可读的路径也丢了(诊断面):%s", line)
}
}