Files
Cyrene_v2/plugins/tools/io/ssrf.go
T
AskaEth 2d211c2d16 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)。
2026-10-06 21:16:23 +08:00

148 lines
4.8 KiB
Go

package io
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strings"
)
// ErrSSRF is returned when a requested URL targets a loopback, private or
// link-local address or a localhost hostname that is not on the allowlist.
// 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")
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("io: parse url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("io: unsupported scheme %q", u.Scheme)
}
host := hostOnly(u.Host)
if host == "" {
return nil, fmt.Errorf("io: url has no host")
}
if isHostAllowed(host, allowed) {
return u, nil
}
if ip := net.ParseIP(host); ip != nil {
if isDeniedIP(ip) {
return nil, fmt.Errorf("%w: %s", ErrSSRF, host)
}
return u, nil
}
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return nil, fmt.Errorf("io: resolve %q: %w", host, err)
}
for _, a := range addrs {
if ip := net.ParseIP(a); ip != nil && isDeniedIP(ip) {
return nil, fmt.Errorf("%w: %q resolves to %q", ErrSSRF, host, a)
}
}
return u, nil
}
// isDeniedIP reports whether ip is a loopback, private, link-local, multicast or
// unspecified address — the targets SSRF protection refuses by default.
func isDeniedIP(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified()
}
// isHostAllowed reports whether the host (with any port stripped) matches an
// entry on the allowlist.
func isHostAllowed(host string, allowed []string) bool {
h := strings.ToLower(hostOnly(host))
if h == "" {
return false
}
for _, a := range allowed {
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 == "" {
return ""
}
if strings.HasPrefix(h, "[") {
if j := strings.Index(h, "]"); j >= 0 {
return h[1:j]
}
return h
}
if net.ParseIP(h) != nil {
return h
}
if i := strings.LastIndex(h, ":"); i >= 0 {
return h[:i]
}
return h
}