feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
This commit is contained in:
1 parent
20f82c9679
commit
c2e413ec38
6 files changed
+687
-41
No files matched your search
@@ -0,0 +1,311 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBodyRenderer 是 BodyRenderer 的测试替身:它**记录**自己被调了几次、拿到的
|
||||
// URL 是什么 —— 「直连成功时绝不碰浏览器」「被拒的目标不换路」这类要求只能靠
|
||||
// calls 计数钉住,光看返回体看不出来。
|
||||
type fakeBodyRenderer struct {
|
||||
body RenderedBody
|
||||
err error
|
||||
calls int
|
||||
urls []string
|
||||
}
|
||||
|
||||
func (f *fakeBodyRenderer) RenderBody(_ context.Context, rawURL string) (RenderedBody, error) {
|
||||
f.calls++
|
||||
f.urls = append(f.urls, rawURL)
|
||||
if f.err != nil {
|
||||
return RenderedBody{}, f.err
|
||||
}
|
||||
return f.body, nil
|
||||
}
|
||||
|
||||
// serveHTML 起一个恒返回这段 HTML 的测试服务(Content-Type 为 text/html)。
|
||||
func serveHTML(t *testing.T, doc string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, doc)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// serveStatus 起一个恒返回指定状态码的测试服务(用于造「直连失败」)。
|
||||
func serveStatus(t *testing.T, code int) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", code)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// normalDoc 是一篇「正文够长、不像动态页」的普通页面(> dynamicTextMinChars 字)。
|
||||
func normalDoc() string {
|
||||
return `<html><body><h1>Hello</h1><p>` + strings.Repeat("正文句子。", 60) + `</p></body></html>`
|
||||
}
|
||||
|
||||
// skeletonDoc 是一个只有前端框架挂载点、没有任何可见正文的**骨架页**。
|
||||
func skeletonDoc() string {
|
||||
return `<html><head><script>window.__NUXT__={}</script></head>` +
|
||||
`<body><div id="app"></div><script src="/x.js"></script></body></html>`
|
||||
}
|
||||
|
||||
// rendererTool 造一把带渲染器的 web-fetch(可选注入),并把服务主机放进白名单。
|
||||
func rendererTool(t *testing.T, srv *httptest.Server, r BodyRenderer) *webFetchTool {
|
||||
t.Helper()
|
||||
opts := []Option{WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client())}
|
||||
if r != nil {
|
||||
opts = append(opts, WithBrowserFallback(r))
|
||||
}
|
||||
return webFetchToolForTest(t, opts...)
|
||||
}
|
||||
|
||||
// ① 直连成功 + 不像动态页 ⇒ **绝不**走浏览器(calls 必为 0),且标注 direct。
|
||||
func TestFetchBodyChain_DirectSuccessNeverTouchesBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveHTML(t, normalDoc())
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByDirect)
|
||||
}
|
||||
if r.calls != 0 {
|
||||
t.Errorf("直连成功却起了浏览器:calls = %d, urls = %v", r.calls, r.urls)
|
||||
}
|
||||
if !strings.Contains(m["text"].(string), "Hello") {
|
||||
t.Errorf("text 不是直连的正文: %q", m["text"])
|
||||
}
|
||||
// 没有发生过的事,不许出现在结果里(空字段一律不写)。
|
||||
for _, k := range []string{"directError", "browserSource", "browserError", "dynamicSuspect"} {
|
||||
if _, exists := m[k]; exists {
|
||||
t.Errorf("结果里不该有 %q(本次没有发生)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 直连失败 + 浏览器可用 ⇒ 走浏览器、标注 browser,并把直连的失败照实留痕。
|
||||
func TestFetchBodyChain_BrowserRendersAfterDirectFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "浏览器渲染出来的正文", Source: "本机有头 Chrome"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByBrowser {
|
||||
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
|
||||
}
|
||||
if m["text"] != "浏览器渲染出来的正文" {
|
||||
t.Errorf("text = %q", m["text"])
|
||||
}
|
||||
if m["browserSource"] != "本机有头 Chrome" {
|
||||
t.Errorf("browserSource = %v(来源必须如实带出)", m["browserSource"])
|
||||
}
|
||||
if de, _ := m["directError"].(string); !strings.Contains(de, "status 500") {
|
||||
t.Errorf("directError = %q,必须留下直连为什么没成", m["directError"])
|
||||
}
|
||||
// 渲染器拿到的必须是**原始 URL**(它自己要再过一遍 SSRF 判定)。
|
||||
if r.calls != 1 || len(r.urls) != 1 || r.urls[0] != srv.URL {
|
||||
t.Errorf("渲染器入参错: calls = %d, urls = %v, want [%s]", r.calls, r.urls, srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 浏览器不可用(这一档没打开)+ 直连失败 ⇒ 两条都照实报,绝不只报一条、绝不冒充成功。
|
||||
func TestFetchBodyChain_BrowserUnavailableReportsBothFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 默认关
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err == nil {
|
||||
t.Fatal("两条档都没成,必须有错误,不许冒充成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 500") {
|
||||
t.Errorf("错误里丢了直连的真实失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
|
||||
t.Errorf("错误里没说清浏览器这一档为什么没走: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 浏览器这一档走了但失败 + 直连也失败 ⇒ 两条失败都出现在错误里(谁也别替谁背锅)。
|
||||
func TestFetchBodyChain_BrowserRenderFailureKeepsBothFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{err: errors.New("CDP 连不上")}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err == nil {
|
||||
t.Fatal("两条档都没成,必须有错误")
|
||||
}
|
||||
for _, want := range []string{"status 500", "CDP 连不上"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误里缺 %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 直连成功但**像动态页** + 浏览器关着 ⇒ 交出直连正文,但把「页面可疑 + 浏览器
|
||||
// 这一档为什么没走」如实标出来(不掩盖「本可以核一遍」)。
|
||||
func TestFetchBodyChain_DynamicPageWithoutRendererIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveHTML(t, skeletonDoc())
|
||||
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 这一档关着
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByDirect {
|
||||
t.Errorf("fetchedBy = %v, want %q(浏览器没走成,来源仍是直连)", m["fetchedBy"], FetchedByDirect)
|
||||
}
|
||||
if m["dynamicSuspect"] != true {
|
||||
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
|
||||
}
|
||||
if m["browserError"] != reasonNoBrowserRenderer {
|
||||
t.Errorf("browserError = %v,必须说明这一档为什么没走", m["browserError"])
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 直连成功但像动态页 + 浏览器可用 ⇒ 改用浏览器渲染,并同时标注「页面可疑」。
|
||||
func TestFetchBodyChain_DynamicPageUsesBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveHTML(t, skeletonDoc())
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "渲染后的正文", Source: "本机有头 Chrome"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["fetchedBy"] != FetchedByBrowser {
|
||||
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
|
||||
}
|
||||
if m["text"] != "渲染后的正文" {
|
||||
t.Errorf("text = %q", m["text"])
|
||||
}
|
||||
if m["dynamicSuspect"] != true {
|
||||
t.Errorf("dynamicSuspect = %v,判定为动态页这件事必须留痕", m["dynamicSuspect"])
|
||||
}
|
||||
if _, exists := m["directError"]; exists {
|
||||
t.Errorf("直连其实成功了,不该有 directError: %v", m["directError"])
|
||||
}
|
||||
if r.calls != 1 {
|
||||
t.Errorf("渲染器调用次数 = %d, want 1", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 被 SSRF 判定拒绝的目标**不换路**:降级档绝不能变成绕过判定的通道。
|
||||
func TestFetchBodyChain_SSRFDeniedNeverFallsBackToBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
|
||||
wf := webFetchToolForTest(t, WithBrowserFallback(r))
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": "http://localhost:9/"})
|
||||
if !errors.Is(err, ErrSSRF) {
|
||||
t.Fatalf("期望 ErrSSRF,实得 %v", err)
|
||||
}
|
||||
if r.calls != 0 {
|
||||
t.Errorf("被拒的目标居然起了浏览器(calls = %d)—— 那是绕过判定,不是降级", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑧ 渲染成功但一个字都没取到 ⇒ 按失败处理(空正文不许冒充「取到了」)。
|
||||
func TestFetchBodyChain_EmptyRenderedBodyIsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: " \n "}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if !errors.Is(err, ErrEmptyRenderedBody) {
|
||||
t.Fatalf("期望 ErrEmptyRenderedBody,实得 %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 500") {
|
||||
t.Errorf("错误里还得有直连的真实失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑨ 渲染出来的正文同样受字节上限约束,且截断不切断 UTF-8 字符。
|
||||
func TestFetchBodyChain_RenderedTextRespectsMaxBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
r := &fakeBodyRenderer{body: RenderedBody{Text: strings.Repeat("正", 50), Source: "fake"}}
|
||||
wf := rendererTool(t, srv, r)
|
||||
|
||||
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL, "max_bytes": 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if len(m["text"].(string)) != 9 {
|
||||
t.Errorf("按 10 字节截断后应为 3 个汉字 = 9 字节,实得 %d", len(m["text"].(string)))
|
||||
}
|
||||
if m["truncated"] != true {
|
||||
t.Errorf("truncated = %v, want true", m["truncated"])
|
||||
}
|
||||
}
|
||||
|
||||
// ⑩ 开关默认关:不传选项、或显式传 nil,渲染器都是 nil(= 这一档关着)。
|
||||
func TestWithBrowserFallback_DefaultsAndNilAreOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := serveStatus(t, http.StatusInternalServerError)
|
||||
if wf := webFetchToolForTest(t); wf.renderer != nil {
|
||||
t.Error("默认(不传选项)必须是关:renderer 应为 nil")
|
||||
}
|
||||
wf := webFetchToolForTest(t, WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client()),
|
||||
WithBrowserFallback(nil))
|
||||
if wf.renderer != nil {
|
||||
t.Fatal("WithBrowserFallback(nil) 必须是关")
|
||||
}
|
||||
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
|
||||
if err == nil || !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
|
||||
t.Fatalf("关着的时候直连失败必须如实报「这一档没打开」: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑪ 动态页判定的三条边界:非 HTML 不算、正文够长不算、没有框架标记不算。
|
||||
func TestLooksDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
mt string
|
||||
text string
|
||||
rawHTML string
|
||||
want bool
|
||||
}{
|
||||
{"骨架页 + 框架标记", "text/html", "", skeletonDoc(), true},
|
||||
{"骨架页但没有任何框架标记", "text/html", "", `<html><body></body></html>`, false},
|
||||
{"正文够长", "text/html", strings.Repeat("字", dynamicTextMinChars), skeletonDoc(), false},
|
||||
{"非 HTML(纯文本)", "text/plain", "", `id="app"`, false},
|
||||
{"非 HTML(JSON)", "application/json", "", `{"mount":"id=\"app\"","x":"id=\"root\""}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := looksDynamic(tc.mt, tc.text, tc.rawHTML); got != tc.want {
|
||||
t.Errorf("%s: looksDynamic = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user