- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
107 lines
3.8 KiB
Go
107 lines
3.8 KiB
Go
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
|
||
}
|