Files
Agent-Native/runtime/tools/fetch_test.go
weichen e4c48eb405 Wire search into the native backend and add fetch_url with SSRF guards
Why: tavily_search/web_search existed as tools but were never
registered on the default execution path (parity-matrix #5 leftover),
and the kernel had no URL fetch capability at all.

Evidence: go vet ./... clean; go test ./... all ok. Fetch cases cover
private/loopback/link-local rejection (127/8, 10/8, 192.168/16,
169.254 metadata endpoint, ::1), file:// scheme rejection, DNS
resolution landing on private space, per-hop redirect validation
rejecting a redirect to loopback, allowed-host policy, HTML stripping
(script/style removed, entities decoded), content-type gating, stub
end-to-end fetch, and absence without FetchConfig (IP normalization
fix included: IPv4-mapped ::ffff:0:0/96 must not swallow all IPv4).
Adapter tests assert Options.Search registers both search tools and
passes queries to the provider, and that neither registers when
unconfigured.

Impact: native.Options gains Search and Fetch; both tool families are
opt-in by the composition root (fail-closed), closing parity-matrix
#5 wiring.

Refs: docs/parity-matrix.md #5; product settings tools.tavily.*
injection point.
2026-10-01 05:35:38 +00:00

155 lines
5.0 KiB
Go

package tools
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeResolve 固定域名 → 地址(无 DNS 依赖的封闭测试)。
func fakeResolve(mapping map[string][]net.IP) func(string) ([]net.IP, error) {
return func(host string) ([]net.IP, error) {
if ips, ok := mapping[host]; ok {
return ips, nil
}
// IP 字面量直接解析。
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
return nil, &net.DNSError{Err: "no such host", Name: host}
}
}
func fetchReg(cfg FetchConfig) *Registry {
reg := NewRegistry("/")
RegisterFetch(reg, cfg)
return reg
}
func TestFetchReadableTextUnit(t *testing.T) {
out, ok := fetchReadableText("text/html; charset=utf-8", "<html><script>bad()</script><h1>Hi</h1><p>A &amp; B</p></html>")
if !ok || !strings.Contains(out, "Hi") || !strings.Contains(out, "A & B") || strings.Contains(out, "bad()") {
t.Fatalf("html strip wrong: %q ok=%v", out, ok)
}
out, ok = fetchReadableText("application/json", `{"x":1}`)
if !ok || out != `{"x":1}` {
t.Fatalf("json passthrough wrong: %q", out)
}
_, ok = fetchReadableText("image/png", "binary")
if ok {
t.Fatal("binary content type must be rejected")
}
out, ok = fetchReadableText("", "no content type")
if !ok || out != "no content type" {
t.Fatalf("empty content type should default to text, got %q %v", out, ok)
}
}
func TestFetchURLSSRDGuards(t *testing.T) {
reg := fetchReg(FetchConfig{Resolve: fakeResolve(nil)})
for _, target := range []string{
`{"url":"http://127.0.0.1/x"}`,
`{"url":"http://10.0.0.5/x"}`,
`{"url":"http://192.168.1.1/x"}`,
`{"url":"http://169.254.169.254/latest/meta-data"}`,
`{"url":"http://[::1]/x"}`,
`{"url":"file:///etc/passwd"}`,
} {
res := runTool(t, reg, "fetch_url", target)
if !res.IsError {
t.Fatalf("SSRF/scheme guard must reject %s, got %+v", target, res)
}
}
// 域名解析到私网同样拒绝。
reg2 := fetchReg(FetchConfig{Resolve: fakeResolve(map[string][]net.IP{
"internal.example": {net.ParseIP("192.168.0.10")},
})})
res := runTool(t, reg2, "fetch_url", `{"url":"http://internal.example/x"}`)
if !res.IsError || !strings.Contains(res.Content, "SSRF guard") {
t.Fatalf("private resolution must be rejected, got %+v", res)
}
}
func TestFetchURLAllowedHostsPolicy(t *testing.T) {
reg := fetchReg(FetchConfig{
AllowedHosts: []string{"api.example.com"},
Resolve: fakeResolve(map[string][]net.IP{"api.example.com": {net.ParseIP("93.184.216.34")}}),
})
res := runTool(t, reg, "fetch_url", `{"url":"http://other.example.com/x"}`)
if !res.IsError || !strings.Contains(res.Content, "not in allowed hosts") {
t.Fatalf("host policy must reject, got %+v", res)
}
// 允许的主机:校验必须放行(真实网络下可能成功取回,也可能因出口
// 受限连接失败——两者都不算策略拦截)。
res = runTool(t, reg, "fetch_url", `{"url":"http://api.example.com/x"}`)
if strings.Contains(res.Content, "not in allowed") || strings.Contains(res.Content, "SSRF") {
t.Fatalf("allowed host should pass validation, got %+v", res)
}
}
func TestFetchURLEndToEndWithStubTransport(t *testing.T) {
var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("fetched body"))
}
reg := fetchReg(FetchConfig{
Client: &http.Client{Transport: stubTransport{handler: handler}},
Resolve: fakeResolve(map[string][]net.IP{"ok.example": {net.ParseIP("93.184.216.34")}}),
})
res := runTool(t, reg, "fetch_url", `{"url":"http://ok.example/x"}`)
if res.IsError {
t.Fatal(res.Content)
}
if !strings.Contains(res.Content, "fetched body") || !strings.Contains(res.Content, "url: http://ok.example/x") {
t.Fatalf("stub fetch wrong: %+v", res)
}
}
type stubTransport struct{ handler http.HandlerFunc }
func (s stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
s.handler(rec, req)
resp := rec.Result()
resp.Request = req
return resp, nil
}
func TestFetchURLRedirectToPrivateRejected(t *testing.T) {
reg := fetchReg(FetchConfig{
Client: &http.Client{Transport: redirectTransport{to: "http://127.0.0.1/evil"}},
Resolve: fakeResolve(map[string][]net.IP{
"public.example": {net.ParseIP("93.184.216.34")},
"127.0.0.1": {net.ParseIP("127.0.0.1")},
}),
})
res := runTool(t, reg, "fetch_url", `{"url":"http://public.example/redirect"}`)
if !res.IsError {
t.Fatalf("redirect to private target must be rejected, got %+v", res)
}
}
type redirectTransport struct{ to string }
func (s redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.Header().Set("Location", s.to)
rec.Code = http.StatusFound
resp := rec.Result()
resp.Request = req
return resp, nil
}
func TestFetchNotRegisteredByDefault(t *testing.T) {
reg := WorkspaceTools(t.TempDir())
for _, d := range reg.Decls() {
if d.Name == "fetch_url" {
t.Fatal("fetch_url must not register without explicit FetchConfig")
}
}
}