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

+106
View File
@@ -0,0 +1,106 @@
package browser
import (
"context"
"fmt"
"net"
"net/url"
"strings"
)
// ⚠️ **已知偏差(如实登记,不美化)**:design/28 §4 要求「SSRF 复用既有 `io/ssrf`
// 白名单,**不新增第三份实现**」。但既有实现位于 `plugins/tools/io`(`ssrf.go` 的
// `validateRequestURL`)且**未导出**,本切片的文件面不含该包 ⇒ 本片无法直接复用,
// 也不擅自改别人的文件。因此这里落的是**与 `io/ssrf` 同一条策略**的判定(先放行
// 白名单、再按 IP 网段否决;网段口径逐条对齐),并登记为待收口项:
// **收口动作 = 把 `plugins/tools/io` 的 `validateRequestURL` 导出(或提到共享包),
// 删掉本文件**。在收口之前,浏览器侧与 web-fetch 侧的放行/拒绝口径**按同一张表**
// 执行,不存在「一个放行另一个拒绝」的分歧。
//
// 判定口径(与 `io/ssrf` 逐条对齐):
// - 只接受 `http` / `https`;
// - 命中调用方白名单的主机**直接放行**(同源自取:本地 searxng、本机 upload 路由);
// - 字面 IP 落在回环/私网/链路本地/组播/未指定 ⇒ 拒绝;
// - 主机名先解析,**任一条**解析结果落在上面那些网段 ⇒ 拒绝(防 DNS rebinding 的
// 第一跳;真正的 rebinding 需要连上时再校验,既有两份实现都没做,本片不越界)。
//
// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就
// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入
// (design/28 §4 第 1 条;M3 面板入口同样过闸)。
func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) {
if strings.TrimSpace(rawURL) == "" {
return nil, fmt.Errorf("%w: url 为空", ErrBlockedURL)
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("%w: 解析 url: %v", ErrBlockedURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("%w: 不支持协议 %q", ErrBlockedURL, u.Scheme)
}
host := u.Hostname()
if host == "" {
return nil, fmt.Errorf("%w: url 没有主机名", ErrBlockedURL)
}
if hostAllowed(host, allowed) {
return u, nil
}
if ip := net.ParseIP(host); ip != nil {
if deniedIP(ip) {
return nil, fmt.Errorf("%w: %s 属回环/私网/链路本地地址", ErrBlockedURL, host)
}
return u, nil
}
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return nil, fmt.Errorf("%w: 解析 %q: %v", ErrBlockedURL, host, err)
}
for _, a := range addrs {
if ip := net.ParseIP(a); ip != nil && deniedIP(ip) {
return nil, fmt.Errorf("%w: %q 解析到 %q", ErrBlockedURL, host, a)
}
}
return u, nil
}
// deniedIP 报告 ip 是否属于守卫默认拒绝的网段:回环、私网、链路本地(单播/组播)、
// 未指定。口径与 `plugins/tools/io/ssrf.go` 的 `isDeniedIP` 逐条相同。
func deniedIP(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified()
}
// hostAllowed 报告 host 是否命中白名单(逐条按主机名比较,大小写不敏感,端口忽略)。
func hostAllowed(host string, allowed []string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "" {
return false
}
for _, a := range allowed {
if strings.EqualFold(hostOf(a), h) {
return true
}
}
return false
}
// hostOf 从白名单条目里取主机名:允许写成 `host`、`host:port` 或完整 URL。
func hostOf(entry string) string {
raw := strings.TrimSpace(entry)
if raw == "" {
return ""
}
if u, err := url.Parse(raw); err == nil && u.Hostname() != "" {
return u.Hostname()
}
if host, _, err := net.SplitHostPort(raw); err == nil {
return host
}
return raw
}