Files
Cyrene_v2/internal/capability/browser/guard.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

107 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}