- Page.navigate 的 errorText 是**协议枚举**面(net error 码):先剥 `net::` 命名空间再交给 trace.ErrorToken,像枚举就原样保留、散文一律丢弃只留字节数; - Runtime.evaluate 的 exceptionDetails.text 是**页面自由文本**面:压平空白 (防日志行伪造)+ trace.RedactTextLimited 按 200 rune 截断(档位理由见 errtext.go:80 会截断真实异常消息,4096 等于允许页面塞 4KB 正文); - 两类守卫不互换(口径见 trace/errortoken.go 的按字段类别判定),清洗实现 仍只有 trace 那一份,本片只做「选哪一道 + 补形状前提」。 测试:出口 A/B 各一条经 RenderText 的真实路径断言(含 URL/凭据形状的假文本), 外加 net::ERR_* 正向保留、上限截断/换行压平、netErrorCode 形状表。三条泄露断言 已验证「退回原样直吐即红」。
201 lines
8.4 KiB
Go
201 lines
8.4 KiB
Go
package browser
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable
|
||
// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。
|
||
func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP {
|
||
t.Helper()
|
||
return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
|
||
switch method {
|
||
case "Page.enable":
|
||
return map[string]any{}, nil
|
||
case "Page.navigate":
|
||
return map[string]any{"errorText": errorText}, nil
|
||
}
|
||
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
|
||
})
|
||
}
|
||
|
||
// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP:
|
||
// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。
|
||
func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP {
|
||
t.Helper()
|
||
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
|
||
switch method {
|
||
case "Page.enable", "Page.navigate":
|
||
return map[string]any{}, nil
|
||
case "Page.getFrameTree":
|
||
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
|
||
case "Page.createIsolatedWorld":
|
||
return map[string]any{"executionContextId": 1}, nil
|
||
case "Runtime.evaluate":
|
||
expr, _ := params["expression"].(string)
|
||
if strings.Contains(expr, "readyState") {
|
||
return map[string]any{"result": map[string]any{"value": map[string]any{
|
||
"url": "https://example.com/", "title": "t", "readyState": "complete",
|
||
}}}, nil
|
||
}
|
||
return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil
|
||
}
|
||
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
|
||
})
|
||
}
|
||
|
||
// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关
|
||
// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度
|
||
// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。
|
||
//
|
||
// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。
|
||
func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) {
|
||
t.Parallel()
|
||
const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)"
|
||
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw)))
|
||
|
||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||
if !errors.Is(err, ErrNavigate) {
|
||
t.Fatalf("err = %v, want ErrNavigate", err)
|
||
}
|
||
msg := err.Error()
|
||
for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} {
|
||
if strings.Contains(msg, leak) {
|
||
t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg)
|
||
}
|
||
}
|
||
if !strings.Contains(msg, "已隐去") {
|
||
t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg)
|
||
}
|
||
if !strings.Contains(msg, strconv.Itoa(len(raw))) {
|
||
t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg)
|
||
}
|
||
}
|
||
|
||
// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的
|
||
// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给
|
||
// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。
|
||
func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) {
|
||
t.Parallel()
|
||
for _, code := range []string{
|
||
"net::ERR_NAME_NOT_RESOLVED",
|
||
"net::ERR_CONNECTION_REFUSED",
|
||
"net::ERR_TIMED_OUT",
|
||
"ERR_ABORTED",
|
||
} {
|
||
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code)))
|
||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||
if !errors.Is(err, ErrNavigate) {
|
||
t.Fatalf("%s: err = %v, want ErrNavigate", code, err)
|
||
}
|
||
if !strings.Contains(err.Error(), code) {
|
||
t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error())
|
||
}
|
||
}
|
||
}
|
||
|
||
// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西,
|
||
// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由
|
||
// 文本的处置口径是「带上限地带」,见 errtext.go)。
|
||
//
|
||
// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。
|
||
func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) {
|
||
t.Parallel()
|
||
const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345"
|
||
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
|
||
|
||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||
if !errors.Is(err, ErrEvaluate) {
|
||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||
}
|
||
msg := err.Error()
|
||
for _, leak := range []string{"ABCDEFGH12345", "sk-live"} {
|
||
if strings.Contains(msg, leak) {
|
||
t.Errorf("错误串泄露了凭据 %q: %q", leak, msg)
|
||
}
|
||
}
|
||
if !strings.Contains(msg, "[redacted]") {
|
||
t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg)
|
||
}
|
||
if !strings.Contains(msg, "TypeError") {
|
||
t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg)
|
||
}
|
||
}
|
||
|
||
// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。
|
||
// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行
|
||
// 就能在按行聚合的日志里伪造出另一条记录)。
|
||
func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) {
|
||
t.Parallel()
|
||
const tail = "TAILMARKER"
|
||
raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail
|
||
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
|
||
|
||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||
if !errors.Is(err, ErrEvaluate) {
|
||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||
}
|
||
msg := err.Error()
|
||
if strings.Contains(msg, tail) {
|
||
t.Errorf("超出上限的尾部不该出镜: %q", msg)
|
||
}
|
||
if !strings.HasSuffix(msg, "…") {
|
||
t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg)
|
||
}
|
||
if strings.ContainsAny(msg, "\r\n") {
|
||
t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg)
|
||
}
|
||
}
|
||
|
||
// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个
|
||
// rune 加一个省略号,一个不多。
|
||
func TestPageExceptionTextLimitsRunes(t *testing.T) {
|
||
t.Parallel()
|
||
got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50))
|
||
if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want {
|
||
t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes)
|
||
}
|
||
if !strings.HasSuffix(got, "…") {
|
||
t.Errorf("截断应补省略号: %q", got)
|
||
}
|
||
// 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。
|
||
short := pageExceptionText("TypeError: body is gone")
|
||
if short != "TypeError: body is gone" {
|
||
t.Errorf("未超限不该改动文本: %q", short)
|
||
}
|
||
// 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。
|
||
if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" {
|
||
t.Errorf("空白异常文本的处理 = %q", got)
|
||
}
|
||
}
|
||
|
||
// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本
|
||
// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌
|
||
// 的自由文本」(`hunter2`)不会被误当成错误码保留。
|
||
func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
in string
|
||
want string
|
||
}{
|
||
{"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"},
|
||
{" net::ERR_ABORTED ", "net::ERR_ABORTED"},
|
||
{"ERR_FAILED", "ERR_FAILED"},
|
||
{"hunter2", ""},
|
||
{"Failed loading https://example.com/", ""},
|
||
{"net::ERR_NOT_A_CODE because spaces", ""},
|
||
{"net::hunter2", ""},
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
if got := netErrorCode(c.in); got != c.want {
|
||
t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|