feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
package browser
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestGuardURL_DeniesLoopbackAndPrivate 钉住守卫的**默认拒绝面**:回环、私网、
// 链路本地、非 http(s) 协议、云元数据端点一律拒绝 —— 这是 design/28 §4 第 1 条
// (服务端代取任意 URL 必须过闸)的可执行判据。
func TestGuardURL_DeniesLoopbackAndPrivate(t *testing.T) {
t.Parallel()
denied := []string{
"http://127.0.0.1/",
"http://localhost/",
"http://[::1]/",
"http://192.168.1.10/admin",
"http://10.0.0.5/",
"http://172.16.3.4/",
"http://169.254.169.254/latest/meta-data/",
"http://0.0.0.0/",
"file:///etc/passwd",
"ftp://example.com/",
"http:///no-host",
"",
}
for _, raw := range denied {
if _, err := GuardURL(context.Background(), raw, nil); err == nil {
t.Errorf("GuardURL(%q) = nil,应当拒绝", raw)
} else if !strings.Contains(err.Error(), "安全守卫拒绝") && !strings.Contains(err.Error(), "URL 被安全守卫拒绝") {
t.Errorf("GuardURL(%q) 的错误不像守卫拒绝: %v", raw, err)
}
}
}
// TestGuardURL_AllowsPublicAndAllowlisted 钉住**放行面**:公网地址放行;白名单里的
// 主机(同源自取:本地 searxng / 本机 upload 路由)即使落在回环也放行。
func TestGuardURL_AllowsPublicAndAllowlisted(t *testing.T) {
t.Parallel()
if _, err := GuardURL(context.Background(), "https://example.com/a?b=1", nil); err != nil {
t.Fatalf("公网地址被拒: %v", err)
}
if _, err := GuardURL(context.Background(), "http://93.184.216.34/", nil); err != nil {
t.Fatalf("公网字面 IP 被拒: %v", err)
}
// 白名单是**按主机名字符串**比较的(与 io/ssrf 的 isHostAllowed 同口径):
// 允许 `localhost` 不等于允许 `127.0.0.1` —— 两者是两个主机名,要各写一条。
exempt := []struct{ entry, raw string }{
{"127.0.0.1", "http://127.0.0.1:8080/x"},
{"http://localhost:8080", "http://localhost/x"},
{"LocalHost", "http://localhost:9999/x"},
}
for _, tc := range exempt {
if _, err := GuardURL(context.Background(), tc.raw, []string{tc.entry}); err != nil {
t.Errorf("白名单 %q 未放行 %q: %v", tc.entry, tc.raw, err)
}
}
}
// TestGuardURL_AllowlistEntryForms 钉住白名单条目的三种写法(裸主机 / host:port / 完整 URL)
// 都按主机名匹配 —— 少一种,面板里写的白名单就会静默失效。
func TestGuardURL_AllowlistEntryForms(t *testing.T) {
t.Parallel()
for _, entry := range []string{"127.0.0.1", "127.0.0.1:9", "http://127.0.0.1:9999/"} {
if _, err := GuardURL(context.Background(), "http://127.0.0.1:7777/", []string{entry}); err != nil {
t.Errorf("条目 %q 未命中: %v", entry, err)
}
}
}
// TestGuardURL_RejectsHostResolvingToPrivate 钉住 DNS 侧的判定:一个解析到私网的主机名
// 也要被拒(用 httptest 起在本机、把它的主机名当目标;`localhost` 已由字面量覆盖,
// 这里覆盖「解析后才发现是私网」这条路径)。
func TestGuardURL_RejectsHostResolvingToPrivate(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
// httptest 的 URL 形如 http://127.0.0.1:<port>,字面 IP 路径已由上面的用例覆盖;
// 这里钉住「解析结果里含私网地址」的判定函数本身。
host := strings.TrimPrefix(srv.URL, "http://")
hostIP, _, _ := strings.Cut(host, ":")
if _, err := GuardURL(context.Background(), srv.URL, nil); err == nil {
t.Errorf("httptest 地址 %s 应被拒(字面回环)", hostIP)
}
}