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
@@ -96,11 +96,13 @@ func (t *Tool) Name() string { return ToolName }
|
||||
|
||||
// Close 释放驱动占用的资源(本机浏览器进程)。驱动实现 io.Closer 时生效,否则空操作。
|
||||
//
|
||||
// ⚠️ **当前没有生产调用者**(如实登记):组合根的关闭链在 `a.close`(`wire_services.go`)
|
||||
// 与 `app` 结构体(`wire.go`)里,两者都不属本切片的文件面 ⇒ 加不上「关掉它」这一行。
|
||||
// 本方法先把**挂点**摆出来,供主会话在关闭链上接一行;在被接上之前,残留的浏览器由
|
||||
// 驱动层的两道机制兜住:① 空闲回收时对会发 `Browser.close` 优雅关停;② 下一次会话
|
||||
// **认领**同一个 profile 上还活着的实例(不再起第二个、也不会被 profile 锁卡死)。
|
||||
// **生产调用者**(切片 X3 接上):组合根的关闭链 `a.close`(`wire_services.go`)按工具名
|
||||
// 从能力面取回本工具并关闭 —— 它持有进程外资源(我们起的真 Chromium 子进程、CDP 连接、
|
||||
// profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程),因此必须显式关。
|
||||
//
|
||||
// 驱动层另有两道兜底,覆盖「Close 没被调用 / 关闭失败」的情形:① 空闲回收时会发
|
||||
// `Browser.close` 优雅关停;② 下一次会话**认领**同一个 profile 上还活着的实例
|
||||
// (不再起第二个、也不会被 profile 锁卡死)。
|
||||
func (t *Tool) Close() error {
|
||||
if t == nil {
|
||||
return nil
|
||||
|
||||
@@ -14,10 +14,27 @@ import (
|
||||
// Use errors.Is to test for it.
|
||||
var ErrSSRF = errors.New("io: disallowed url")
|
||||
|
||||
// ValidateRequestURL is the exported form of validateRequestURL: the single SSRF
|
||||
// policy for every code path that fetches a caller-supplied URL on the server's
|
||||
// behalf. It is exported because a second implementation of the same policy grew
|
||||
// outside this package — `internal/capability/browser` carried its own copy
|
||||
// (written when this function was unexported and that capability was unreachable
|
||||
// from the slice being implemented), which is what design/28 §4 forbids
|
||||
// ("reuse the existing io/ssrf allowlist, do not add a third implementation").
|
||||
// That copy is gone and the browser capability calls this function instead, so
|
||||
// the policy table (scheme check → allowlist → literal-IP deny →
|
||||
// resolve-and-deny) lives in one place again. Call sites inside this package
|
||||
// keep using the unexported name.
|
||||
func ValidateRequestURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) {
|
||||
return validateRequestURL(ctx, rawURL, allowed)
|
||||
}
|
||||
|
||||
// validateRequestURL parses rawURL and refuses SSRF-prone targets: it returns an
|
||||
// error wrapping ErrSSRF for loopback/private/link-local/unspecified addresses
|
||||
// and for localhost hostnames, unless the host (or hostname) is on the explicit
|
||||
// allowlist. A hostname that resolves to any denied address is also refused.
|
||||
//
|
||||
// 内部调用点沿用未导出名;对外只有 ValidateRequestURL 一个出口。
|
||||
func validateRequestURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return nil, fmt.Errorf("io: url is required")
|
||||
@@ -75,14 +92,40 @@ func isHostAllowed(host string, allowed []string) bool {
|
||||
return false
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if strings.EqualFold(hostOnly(a), h) {
|
||||
if strings.EqualFold(allowEntryHost(a), h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allowEntryHost returns the hostname an allowlist entry stands for. Entries are
|
||||
// written by hand (panel config, tool options), so all three natural spellings
|
||||
// are accepted: `host`, `host:port` and a full URL.
|
||||
//
|
||||
// URL entries used to be honoured only by the browser-side copy of this policy
|
||||
// (now deleted); folding that capability in here is what keeps the collapse into
|
||||
// one implementation from silently weakening an existing allowlist — an entry
|
||||
// spelled as a URL would otherwise stop matching after the merge. A bare IPv6
|
||||
// literal (`::1`) is also returned as-is: the host:port split must not cut it.
|
||||
func allowEntryHost(entry string) string {
|
||||
raw := strings.TrimSpace(entry)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Host != "" {
|
||||
if h := u.Hostname(); h != "" {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return hostOnly(raw)
|
||||
}
|
||||
|
||||
// hostOnly strips the port (and any IPv6 brackets) from a host:port string.
|
||||
//
|
||||
// A **bare** IPv6 literal (`::1`, no brackets) is returned unchanged: the port
|
||||
// split would otherwise cut it at its last colon, and callers do pass
|
||||
// already-hostname-only strings (isHostAllowed normalizes its argument too).
|
||||
func hostOnly(hostport string) string {
|
||||
h := strings.TrimSpace(hostport)
|
||||
if h == "" {
|
||||
@@ -94,6 +137,9 @@ func hostOnly(hostport string) string {
|
||||
}
|
||||
return h
|
||||
}
|
||||
if net.ParseIP(h) != nil {
|
||||
return h
|
||||
}
|
||||
if i := strings.LastIndex(h, ":"); i >= 0 {
|
||||
return h[:i]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateRequestURL_DeniedByDefault 钉住**共享判定本体**的默认拒绝面(收口 X3):
|
||||
// 收口之前浏览器侧另有一份同策实现,两份各自被自己的用例覆盖;收口之后浏览器侧
|
||||
// (`internal/capability/browser` 的守卫用例)与本用例打的是**同一张表、同一份代码**。
|
||||
func TestValidateRequestURL_DeniedByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
denied := []string{
|
||||
"http://127.0.0.1/",
|
||||
"http://[::1]/",
|
||||
"http://192.168.1.10/admin",
|
||||
"http://10.0.0.5/",
|
||||
"http://172.16.3.4/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://0.0.0.0/",
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/",
|
||||
"http:///no-host",
|
||||
"",
|
||||
}
|
||||
for _, raw := range denied {
|
||||
if _, err := ValidateRequestURL(context.Background(), raw, nil); err == nil {
|
||||
t.Errorf("ValidateRequestURL(%q) = nil,应当拒绝", raw)
|
||||
}
|
||||
}
|
||||
// 网段拒绝必须**保住 ErrSSRF 身份**:调用方(含浏览器侧的门面)按 errors.Is 分类降级。
|
||||
if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", nil); !errors.Is(err, ErrSSRF) {
|
||||
t.Errorf("回环目标的错误丢了 ErrSSRF 身份: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRequestURL_AllowlistEntryForms 钉住白名单条目的**全部写法**都按主机名匹配。
|
||||
// 这是收口 X3 的回归网:完整 URL 写法(以及裸 IPv6 字面量)原先只有被删掉的那份
|
||||
// 浏览器侧实现认;合并成一份时若丢掉它,面板里写 `http://host:port` 的白名单会**静默
|
||||
// 失效**(改前生效、改后不生效,没有一条日志会说出这件事)。
|
||||
func TestValidateRequestURL_AllowlistEntryForms(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ entry, raw string }{
|
||||
{"127.0.0.1", "http://127.0.0.1:7777/x"},
|
||||
{"127.0.0.1:9", "http://127.0.0.1:7777/x"},
|
||||
{"http://127.0.0.1:9999/", "http://127.0.0.1:7777/x"},
|
||||
{"localhost", "http://localhost:8080/x"},
|
||||
{"LocalHost:8080", "http://LOCALHOST/x"},
|
||||
{"http://localhost:8080", "http://localhost/x"},
|
||||
{"[::1]", "http://[::1]:9999/x"},
|
||||
{"::1", "http://[::1]:9999/x"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if _, err := ValidateRequestURL(context.Background(), tc.raw, []string{tc.entry}); err != nil {
|
||||
t.Errorf("条目 %q 未放行 %q: %v", tc.entry, tc.raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRequestURL_AllowlistDoesNotOverreach 钉住**放行的边界**:白名单只放行它
|
||||
// 自己那条主机名,且不依赖 DNS 的放行面照旧(公网字面 IP)。
|
||||
func TestValidateRequestURL_AllowlistDoesNotOverreach(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := ValidateRequestURL(context.Background(), "http://93.184.216.34/", nil); err != nil {
|
||||
t.Fatalf("公网字面 IP 被拒: %v", err)
|
||||
}
|
||||
// 白名单按主机名字符串比较:写 `localhost` 不等于放行 `127.0.0.1`(是两个名字)。
|
||||
if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", []string{"localhost"}); !errors.Is(err, ErrSSRF) {
|
||||
t.Errorf("条目 localhost 不该放行 127.0.0.1(实得 %v)", err)
|
||||
}
|
||||
// 无关的 URL 条目(主机是别的名字)不能被「看谁像 URL」放过。
|
||||
if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", []string{"http://other:1/"}); !errors.Is(err, ErrSSRF) {
|
||||
t.Errorf("无关的 URL 条目不该放行 127.0.0.1(实得 %v)", err)
|
||||
}
|
||||
// 空条目不能让任何主机命中(面板里删空一行 = 不放行任何东西)。
|
||||
if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", []string{"", " "}); !errors.Is(err, ErrSSRF) {
|
||||
t.Errorf("空白条目不该放行任何主机(实得 %v)", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user