feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
X2 C1-3(自排工具面与提示词): - request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门 (安全闸 + owner 判定:群聊/陌生人一律拒) - **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换) - 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴 - 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等) X3 收口(与 X2 共用 wire_services.go ⇒ 一并落): - 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份) - 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动) - a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点 - 5 个变异自检 顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
This commit is contained in:
1 parent
2bf3b536dd
commit
2d211c2d16
20 files changed
+1568
-176
No files matched your search
@@ -1,106 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// TestGuardURL_DeniesLoopbackAndPrivate 钉住守卫的**默认拒绝面**:回环、私网、
|
||||
@@ -88,3 +91,18 @@ func TestGuardURL_RejectsHostResolvingToPrivate(t *testing.T) {
|
||||
t.Errorf("httptest 地址 %s 应被拒(字面回环)", hostIP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardURL_KeepsBothErrorIdentities 钉住收口(切片 X3)之后的**双错误身份**:
|
||||
// 判定本体搬去了 `io.ValidateRequestURL`,但本包的契约没动 —— 组合根/工具层仍按
|
||||
// `ErrBlockedURL` 分类降级(`ReasonBlockedURL` 与它同源),而排障时能顺着链回溯到**唯一**
|
||||
// 判定源(`io.ErrSSRF`)。少任何一个身份,「收口只搬判定、不动契约」就只是句口号。
|
||||
func TestGuardURL_KeepsBothErrorIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := GuardURL(context.Background(), "http://169.254.169.254/latest/meta-data/", nil)
|
||||
if !errors.Is(err, ErrBlockedURL) {
|
||||
t.Errorf("拒绝丢了本包契约身份 ErrBlockedURL: %v", err)
|
||||
}
|
||||
if !errors.Is(err, iotools.ErrSSRF) {
|
||||
t.Errorf("拒绝丢了判定源身份 iotools.ErrSSRF: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// GuardURL 是浏览器侧访问 SSRF 判定的**唯一入口**,判定本体**只有一份**:
|
||||
// `plugins/tools/io.ValidateRequestURL`(design/28 §4:「复用既有 `io/ssrf` 白名单,
|
||||
// **不新增第三份实现**」)。
|
||||
//
|
||||
// **为什么这里只留一层薄门面(切片 X3 的收口)**:本包原先自带一份与 io 同策的实现
|
||||
// (`guard.go`:自己的 scheme 判定 + 网段表 + 白名单比对),只因为当时 io 侧的
|
||||
// `validateRequestURL` 没导出、该包又不在那一片的文件面里。两份同策代码的危险不在
|
||||
// 当下的结论,而在**以后只改一处** —— 改完两边静默分叉,浏览器侧的导航就成了绕过面。
|
||||
// 收口后本文件只做两件事:
|
||||
//
|
||||
// 1. 调 `io.ValidateRequestURL`(**唯一**判定源:scheme → 白名单短路 → 字面 IP 网段
|
||||
// 否决 → 解析后再按同一张表否决);
|
||||
// 2. 把它的错误翻成本包对外的 `ErrBlockedURL` —— 这是**契约**不是判定:
|
||||
// `ErrBlockedURL` / `ReasonBlockedURL` 是工具层与组合根(含后续 M3 面板入口)用来
|
||||
// 分类降级与记日志的标识,收口只搬判定,不动契约。
|
||||
//
|
||||
// 两个错误身份都保住(`%w:%w`):`errors.Is(err, ErrBlockedURL)` 成立(本包契约),
|
||||
// 网段/解析类拒绝上 `errors.Is(err, iotools.ErrSSRF)` 同样成立(判定源),io 侧的原因
|
||||
// 文案(哪个网段、解析到哪个地址)一字不丢地进日志。形状错误(空 URL、非 http(s))
|
||||
// 在 io 侧本就不带 ErrSSRF,那几档只有本包身份。
|
||||
//
|
||||
// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就
|
||||
// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入
|
||||
// (design/28 §4 第 1 条;M3 面板入口同样过闸)。
|
||||
func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) {
|
||||
u, err := iotools.ValidateRequestURL(ctx, rawURL, allowed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:%w", ErrBlockedURL, err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
Reference in new issue
Block a user