From 8058660537f1fba5b6190b7e9cc6b239beaed466 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 21:57:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(browser):=20M53=20=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BE=A7=E8=87=AA=E7=94=B1=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E6=94=B6=E6=95=9B=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 形状表。三条泄露断言 已验证「退回原样直吐即红」。 --- internal/capability/browser/errtext.go | 98 ++++++++++ internal/capability/browser/errtext_test.go | 200 ++++++++++++++++++++ internal/capability/browser/session.go | 7 +- 3 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 internal/capability/browser/errtext.go create mode 100644 internal/capability/browser/errtext_test.go diff --git a/internal/capability/browser/errtext.go b/internal/capability/browser/errtext.go new file mode 100644 index 0000000..e321f52 --- /dev/null +++ b/internal/capability/browser/errtext.go @@ -0,0 +1,98 @@ +package browser + +// 本文件是**页面侧自由文本出口**的收敛点:CDP 有两个字段会把页面/对端说的话原样 +// 交给我们,它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。 +// +// 两个字段**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go +// 的「按字段类别判定」): +// +// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码 +// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守: +// 像枚举就保留,散文一律丢弃; +// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以 +// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带 +// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断。 +// +// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段 +// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住 +// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。 + +import ( + "fmt" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" +) + +// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。 +// +// 为什么不复用既有的两个档位: +// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 —— +// `TypeError: Cannot read properties of undefined (reading 'addEventListener')` +// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索; +// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的; +// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 —— +// 而这条错误串同时进日志与模型可见视图。 +// +// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时 +// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。 +const maxExceptionTextRunes = 200 + +// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。 +// +// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含 +// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」 +// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议 +// 常量**,不是从对端抄来的文本,因此剥它是安全的。 +const netErrorNamespace = "net::" + +// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。 +// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定 +// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。 +const netErrorCodePrefix = "ERR_" + +// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。 +// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。 +func netErrorCode(raw string) string { + text := strings.TrimSpace(raw) + namespace := "" + if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok { + namespace, text = netErrorNamespace, rest + } + if !strings.HasPrefix(text, netErrorCodePrefix) { + return "" + } + token := trace.ErrorToken(text) + if token == "" { + return "" + } + return namespace + token +} + +// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。 +// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。 +// +// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」, +// 不带那句话(与 cdpError.Error 同一口径)。 +func navigateCause(raw string) string { + if code := netErrorCode(raw); code != "" { + return code + } + return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw)) +} + +// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行 +// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+ +// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。 +func pageExceptionText(raw string) string { + return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes) +} + +// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。 +func evaluateCause(raw string) string { + if text := pageExceptionText(raw); text != "" { + return text + } + // 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。 + return "页面异常(对端未给文本)" +} diff --git a/internal/capability/browser/errtext_test.go b/internal/capability/browser/errtext_test.go new file mode 100644 index 0000000..73cd76a --- /dev/null +++ b/internal/capability/browser/errtext_test.go @@ -0,0 +1,200 @@ +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) + } + } +} diff --git a/internal/capability/browser/session.go b/internal/capability/browser/session.go index 785a236..e634a78 100644 --- a/internal/capability/browser/session.go +++ b/internal/capability/browser/session.go @@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) { return pageState{}, err } if text := strings.TrimSpace(res.ErrorText); text != "" { - return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text) + // errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。 + return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text)) } if err := s.waitReady(ctx); err != nil { return pageState{}, err @@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error { return err } if res.ExceptionDetails != nil { - return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text) + // text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token + // 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。 + return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text)) } if out == nil || len(res.Result.Value) == 0 { return nil