fix(browser): M53 两条页面侧自由文本出口收敛脱敏
- 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 形状表。三条泄露断言 已验证「退回原样直吐即红」。
This commit is contained in:
1 parent
d47185feb8
commit
8058660537
3 files changed
+303
-2
No files matched your search
@@ -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 "页面异常(对端未给文本)"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user