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 }