mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* fix(providerhealth,tools): let the configured forward proxy through the loopback guard With HTTPS_PROXY pointing at a local forward proxy, the transport dials the proxy first and tunnels the request through it. The SSRF-guarded dialer in providerhealth refused that dial as a loopback address, so `zero doctor --connectivity` failed with "proxyconnect tcp: provider connectivity URL is unsafe: loopback hosts are blocked" for a public provider that had already been validated, and web_fetch had no proxy support at all (#569). Each dialer is now handed its transport's own Proxy function and lets a dial through unpinned only when the address is exactly the proxy endpoint that function names for the request scheme: same host, same port, with a missing proxy port taken as the scheme default. A sibling loopback port is still refused. The shared check lives in internal/proxydial since providerhealth and tools otherwise share nothing. web_fetch honours http.ProxyFromEnvironment again. The guarded fetch had set Proxy to nil as part of its SSRF hardening; the target is still validated before the request, and what a forward proxy removes is only the dial-time rebind pin, which is inherent to using any forward proxy. The pre-permission URL validation stays in place. Started from #594 by baoyu0. This version keeps the pre-permission check, matches the proxy port exactly instead of any port when the proxy URL has none, and injects the transport's Proxy function instead of reading the environment a second time. Closes #569 Co-authored-by: baoyu0 <[email protected]> * fix(proxydial): have the dialer ask its transport for the proxy at dial time Copying transport.Proxy into the dialer at construction left the wiring between the two unpinned: a dialer handed nil would pass every unit test while a proxied request still died as loopback. The dialer now consults the transport it is installed on, and each package drives a real request through a loopback listener acting as the proxy and checks that the CONNECT arrives. The proxy is substituted on the transport rather than through HTTPS_PROXY, since http.ProxyFromEnvironment reads the environment once per process and an env-based test would depend on test order. * fix(proxydial): bind the proxy exemption to the route the transport chose Asking the Proxy function a question of our own invention answered for a request the transport never made. With NO_PROXY excluding a host, the real request to that host goes direct while a synthetic probe still comes back proxied, so a direct dial whose address happened to equal the configured proxy endpoint skipped the guard and reached whatever the name resolved to at connect time. A probe on the previous head dials rebind.example:80 with zero safety lookups; the guard now runs and refuses its private answer. Host-dependent proxy callbacks make the mismatch general, and no spelling of the probe host closes it, because the two calls are about different requests. The decision is now recorded where it is made. proxydial.Transport wraps the transport, asks its Proxy function about the actual request, and puts the answer in that request's context; the transport derives the dial context from the request context, so the dialer reads back the route this very connection is being opened for. Per-request, so concurrent requests cannot overwrite each other, and the context key is unexported so nothing outside the package can plant one. Address matching is gone with it. A transport opens one connection dial, and when a proxy is selected that dial goes to the proxy, so the recorded route already identifies the dial. Comparing the address on top could only disagree with the transport, which is what a Unicode proxy hostname did: Go dials xn--bro-hoa.example while the URL still spells it as configured, and the proxy was refused as an ordinary private address. Both spellings are now one endpoint with no comparison to get wrong. Documents where destination enforcement ends: with a proxy configured, the target hostname is sent to the proxy and the proxy resolves it, so the local pre-request checks no longer bind the address that is reached. A forward proxy already sees and can rewrite every request through it, so that trust comes with configuring one; the alternative is refusing to use proxies at all, which is the outage in #569. --------- Co-authored-by: baoyu0 <[email protected]>
558 lines
20 KiB
Go
558 lines
20 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/proxydial"
|
|
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
return fn(request)
|
|
}
|
|
|
|
type webFetchResolverFunc func(context.Context, string, string) ([]netip.Addr, error)
|
|
|
|
func (fn webFetchResolverFunc) LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) {
|
|
return fn(ctx, network, host)
|
|
}
|
|
|
|
type webFetchDialFunc func(context.Context, string, string) (net.Conn, error)
|
|
|
|
func (fn webFetchDialFunc) DialContext(ctx context.Context, network string, address string) (net.Conn, error) {
|
|
return fn(ctx, network, address)
|
|
}
|
|
|
|
func TestWebFetchToolSafetyAndSchema(t *testing.T) {
|
|
tool := NewWebFetchTool()
|
|
|
|
if tool.Name() != "web_fetch" {
|
|
t.Fatalf("Name = %q, want web_fetch", tool.Name())
|
|
}
|
|
if tool.Description() == "" {
|
|
t.Fatal("Description is empty")
|
|
}
|
|
safety := tool.Safety()
|
|
if safety.SideEffect != SideEffectNetwork || safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
|
|
t.Fatalf("unexpected safety metadata: %#v", safety)
|
|
}
|
|
if safety.Reason == "" {
|
|
t.Fatal("Safety reason is empty")
|
|
}
|
|
|
|
schema := tool.Parameters()
|
|
if schema.Type != "object" || schema.AdditionalProperties {
|
|
t.Fatalf("unexpected schema envelope: %#v", schema)
|
|
}
|
|
if len(schema.Required) != 1 || schema.Required[0] != "url" {
|
|
t.Fatalf("required fields = %#v, want url only", schema.Required)
|
|
}
|
|
if schema.Properties["url"].Type != "string" {
|
|
t.Fatalf("url schema = %#v, want string", schema.Properties["url"])
|
|
}
|
|
maxBytes := schema.Properties["max_bytes"]
|
|
if maxBytes.Type != "integer" || maxBytes.Minimum == nil || *maxBytes.Minimum != 1 || maxBytes.Maximum == nil {
|
|
t.Fatalf("max_bytes schema = %#v, want bounded integer", maxBytes)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolFetchesHTTPText(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
if request.Method != http.MethodGet {
|
|
t.Fatalf("method = %s, want GET", request.Method)
|
|
}
|
|
if request.Header.Get("User-Agent") == "" {
|
|
t.Fatal("expected User-Agent header")
|
|
}
|
|
return webFetchTestResponse(request, http.StatusOK, "text/plain; charset=utf-8", "hello zero"), nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{
|
|
"url": "https://example.com/guide?token=secret-token",
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
for _, want := range []string{"URL: https://example.com/guide?token=[REDACTED]", "Status: 200 OK", "Content-Type: text/plain; charset=utf-8", "hello zero"} {
|
|
if !strings.Contains(result.Output, want) {
|
|
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
|
}
|
|
}
|
|
if strings.Contains(result.Output, "secret-token") {
|
|
t.Fatalf("expected URL secrets to be redacted, got %q", result.Output)
|
|
}
|
|
if result.Meta["status_code"] != "200" || result.Meta["content_type"] != "text/plain; charset=utf-8" || result.Meta["truncated"] != "false" {
|
|
t.Fatalf("unexpected metadata: %#v", result.Meta)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolTruncatesAtMaxBytes(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
return webFetchTestResponse(request, http.StatusOK, "text/plain", "abcdefg"), nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{
|
|
"url": "https://example.com/long",
|
|
"max_bytes": 4,
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !result.Truncated || result.Meta["truncated"] != "true" || result.Meta["bytes"] != "4" {
|
|
t.Fatalf("expected truncation metadata, got truncated=%v meta=%#v", result.Truncated, result.Meta)
|
|
}
|
|
if !strings.Contains(result.Output, "abcd") || strings.Contains(result.Output, "efg") {
|
|
t.Fatalf("expected output to contain only truncated body, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolRejectsUnsafeURLsBeforeNetwork(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(*http.Request) (*http.Response, error) {
|
|
t.Fatal("unsafe URL should be rejected before network transport")
|
|
return nil, nil
|
|
}))
|
|
|
|
for _, rawURL := range []string{
|
|
"file:///tmp/secret",
|
|
"ftp://example.com/file",
|
|
"http://127.0.0.1/admin",
|
|
"http://localhost/status",
|
|
"http://169.254.169.254/latest/meta-data",
|
|
"http://100.64.0.1/internal",
|
|
"http://0.1.2.3/internal",
|
|
"http://255.255.255.255/internal",
|
|
"http://[64:ff9b::7f00:1]/internal",
|
|
"http://[64:ff9b::a9fe:a9fe]/latest/meta-data",
|
|
"http://[64:ff9b:1:7f00:1::]/internal",
|
|
"http://[2002:7f00:1::]/internal",
|
|
"http://[::7f00:1]/internal",
|
|
"http://user:[email protected]/private",
|
|
} {
|
|
t.Run(rawURL, func(t *testing.T) {
|
|
result := tool.Run(context.Background(), map[string]any{"url": rawURL})
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected unsafe URL error, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "Unsafe URL") {
|
|
t.Fatalf("expected unsafe URL message, got %q", result.Output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebFetchRejectBeforePermissionClassifiesURLs(t *testing.T) {
|
|
tool, ok := NewWebFetchTool().(PrePermissionRejecter)
|
|
if !ok {
|
|
t.Fatalf("NewWebFetchTool returned %T, want PrePermissionRejecter", NewWebFetchTool())
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
args map[string]any
|
|
wantRejected bool
|
|
wantContains string
|
|
}{
|
|
{
|
|
name: "public remote",
|
|
args: map[string]any{"url": "https://example.com/docs"},
|
|
},
|
|
{
|
|
name: "hostname needs resolver later",
|
|
args: map[string]any{"url": "https://private.example/status"},
|
|
},
|
|
{
|
|
name: "localhost custom port",
|
|
args: map[string]any{"url": "http://localhost:8000/status"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "loopback ipv4",
|
|
args: map[string]any{"url": "http://127.0.0.1/admin"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "loopback ipv6",
|
|
args: map[string]any{"url": "http://[::1]/admin"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "private ipv4",
|
|
args: map[string]any{"url": "http://10.0.0.1/status"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "private ipv6",
|
|
args: map[string]any{"url": "http://[fc00::1]/status"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "localhost suffix",
|
|
args: map[string]any{"url": "http://app.localhost/status"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "local suffix",
|
|
args: map[string]any{"url": "http://printer.local/status"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "metadata host",
|
|
args: map[string]any{"url": "http://metadata.google.internal/latest"},
|
|
wantRejected: true,
|
|
wantContains: "bash with curl",
|
|
},
|
|
{
|
|
name: "public non-default port",
|
|
args: map[string]any{"url": "https://example.com:8443/docs"},
|
|
wantRejected: true,
|
|
wantContains: "default ports",
|
|
},
|
|
{
|
|
name: "unsupported scheme",
|
|
args: map[string]any{"url": "ftp://example.com/file"},
|
|
wantRejected: true,
|
|
wantContains: "only http and https",
|
|
},
|
|
{
|
|
name: "bad max bytes",
|
|
args: map[string]any{"url": "https://example.com/docs", "max_bytes": 0},
|
|
wantRejected: true,
|
|
wantContains: "Invalid arguments",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result, rejected := tool.RejectBeforePermission(tc.args)
|
|
if rejected != tc.wantRejected {
|
|
t.Fatalf("rejected = %v, want %v; result=%#v", rejected, tc.wantRejected, result)
|
|
}
|
|
if tc.wantContains != "" && !strings.Contains(result.Output, tc.wantContains) {
|
|
t.Fatalf("expected output to contain %q, got %q", tc.wantContains, result.Output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolRejectsLocalhostBeforeDefaultPort(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(*http.Request) (*http.Response, error) {
|
|
t.Fatal("localhost URL should be rejected before network transport")
|
|
return nil, nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "http://localhost:8000/status"})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "bash with curl") {
|
|
t.Fatalf("expected curl guidance, got %q", result.Output)
|
|
}
|
|
if strings.Contains(result.Output, "default port") {
|
|
t.Fatalf("localhost should be classified before port validation, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolRejectsNonDefaultPorts(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(*http.Request) (*http.Response, error) {
|
|
t.Fatal("non-default port should be rejected before network transport")
|
|
return nil, nil
|
|
}))
|
|
|
|
for _, rawURL := range []string{
|
|
"http://example.com:22/",
|
|
"https://example.com:80/",
|
|
"https://example.com:6379/",
|
|
} {
|
|
t.Run(rawURL, func(t *testing.T) {
|
|
result := tool.Run(context.Background(), map[string]any{"url": rawURL})
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected unsafe URL error, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "default port") {
|
|
t.Fatalf("expected default-port message, got %q", result.Output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolRejectsHostnamesResolvingToPrivateAddresses(t *testing.T) {
|
|
tool := newWebFetchToolWithClientAndResolver(
|
|
webFetchTestClient(func(*http.Request) (*http.Response, error) {
|
|
t.Fatal("private resolved host should be rejected before network transport")
|
|
return nil, nil
|
|
}),
|
|
webFetchResolverFunc(func(_ context.Context, network string, host string) ([]netip.Addr, error) {
|
|
if network != "ip" || host != "private.example" {
|
|
t.Fatalf("unexpected lookup network=%q host=%q", network, host)
|
|
}
|
|
return []netip.Addr{netip.MustParseAddr("10.0.0.12")}, nil
|
|
}),
|
|
)
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://private.example/status"})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected private resolved host error, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "private network hosts are blocked") {
|
|
t.Fatalf("expected private host message, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolConfiguresDialTimeSafetyForDefaultTransport(t *testing.T) {
|
|
tool, ok := NewWebFetchTool().(webFetchTool)
|
|
if !ok {
|
|
t.Fatalf("NewWebFetchTool returned %T, want webFetchTool", NewWebFetchTool())
|
|
}
|
|
|
|
client := tool.clientForRun()
|
|
// The round tripper is the route-recording wrapper, not the bare transport:
|
|
// it is what tells the dialer which dial is the one to the proxy the
|
|
// transport picked for a given request. The guarded transport it wraps
|
|
// stays reachable, and is what the rest of this test inspects.
|
|
transport, ok := client.Transport.(*proxydial.Transport)
|
|
if !ok {
|
|
t.Fatalf("client transport = %T, want *proxydial.Transport", client.Transport)
|
|
}
|
|
if transport.DialContext == nil {
|
|
t.Fatal("expected web_fetch transport to install a safe DialContext")
|
|
}
|
|
// REVERSED ON PURPOSE. The guarded fetch used to pin Proxy to nil as part of
|
|
// the SSRF hardening, and this assertion held it there. #569 is what that
|
|
// cost: on a machine that reaches the network only through a configured
|
|
// forward proxy, every fetch failed outright. The proxy is honoured now, and
|
|
// the wrapper hands the dialer the route chosen for each request so the one
|
|
// dial it lets past the guard is the one to that proxy. The target is still
|
|
// validated before the request, which the tests below pin.
|
|
if transport.Proxy == nil {
|
|
t.Fatal("expected web_fetch transport to honour the environment proxy; a machine behind a forward proxy cannot fetch at all without it (#569)")
|
|
}
|
|
}
|
|
|
|
func TestWebFetchSafeDialRejectsPrivateRebindAddress(t *testing.T) {
|
|
dialCalled := false
|
|
dial := webFetchSafeDialContext(
|
|
webFetchResolverFunc(func(_ context.Context, network string, host string) ([]netip.Addr, error) {
|
|
if network != "ip" || host != "rebind.example" {
|
|
t.Fatalf("unexpected lookup network=%q host=%q", network, host)
|
|
}
|
|
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
|
}),
|
|
webFetchDialFunc(func(context.Context, string, string) (net.Conn, error) {
|
|
dialCalled = true
|
|
return nil, errors.New("dial should not run")
|
|
}),
|
|
)
|
|
|
|
_, err := dial(context.Background(), "tcp", "rebind.example:443")
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "loopback hosts are blocked") {
|
|
t.Fatalf("expected loopback rejection, got %v", err)
|
|
}
|
|
if dialCalled {
|
|
t.Fatal("dialer was called after unsafe DNS result")
|
|
}
|
|
}
|
|
|
|
func TestWebFetchSafeDialPinsResolvedPublicAddress(t *testing.T) {
|
|
var dialedAddress string
|
|
stop := errors.New("stop after address capture")
|
|
dial := webFetchSafeDialContext(
|
|
webFetchResolverFunc(func(_ context.Context, network string, host string) ([]netip.Addr, error) {
|
|
if network != "ip" || host != "public.example" {
|
|
t.Fatalf("unexpected lookup network=%q host=%q", network, host)
|
|
}
|
|
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
|
|
}),
|
|
webFetchDialFunc(func(_ context.Context, _ string, address string) (net.Conn, error) {
|
|
dialedAddress = address
|
|
return nil, stop
|
|
}),
|
|
)
|
|
|
|
_, err := dial(context.Background(), "tcp", "public.example:443")
|
|
|
|
if !errors.Is(err, stop) {
|
|
t.Fatalf("expected captured dial error, got %v", err)
|
|
}
|
|
if dialedAddress != "8.8.8.8:443" {
|
|
t.Fatalf("dialed address = %q, want resolved IP address", dialedAddress)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolRejectsUnsafeRedirects(t *testing.T) {
|
|
for _, location := range []string{
|
|
"http://127.0.0.1/private",
|
|
"http://100.64.0.1/internal",
|
|
} {
|
|
t.Run(location, func(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
response := webFetchTestResponse(request, http.StatusFound, "text/plain", "redirect")
|
|
response.Header.Set("Location", location)
|
|
return response, nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/start"})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected redirect safety error, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "unsafe redirect URL") {
|
|
t.Fatalf("expected unsafe redirect message, got %q", result.Output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolRedactsContentType(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
return webFetchTestResponse(request, http.StatusOK, "text/plain; token=secret-token", "hello"), nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/redact"})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if strings.Contains(result.Output, "secret-token") || strings.Contains(result.Meta["content_type"], "secret-token") {
|
|
t.Fatalf("expected content type to be redacted, output=%q meta=%#v", result.Output, result.Meta)
|
|
}
|
|
if !strings.Contains(result.Output, "token=[REDACTED]") || !strings.Contains(result.Meta["content_type"], "token=[REDACTED]") {
|
|
t.Fatalf("expected redacted token in content type, output=%q meta=%#v", result.Output, result.Meta)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchToolRejectsNonSuccessStatus(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
return webFetchTestResponse(request, http.StatusNotFound, "text/plain", "missing page"), nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/missing"})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected status error, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "HTTP 404 Not Found") || !strings.Contains(result.Output, "missing page") {
|
|
t.Fatalf("unexpected non-success output: %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func webFetchTestClient(handler func(*http.Request) (*http.Response, error)) *http.Client {
|
|
return &http.Client{Transport: roundTripFunc(handler)}
|
|
}
|
|
|
|
func webFetchTestResponse(request *http.Request, statusCode int, contentType string, body string) *http.Response {
|
|
response := &http.Response{
|
|
StatusCode: statusCode,
|
|
Status: httpStatusLine(statusCode),
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Request: request,
|
|
}
|
|
if contentType != "" {
|
|
response.Header.Set("Content-Type", contentType)
|
|
}
|
|
return response
|
|
}
|
|
|
|
func httpStatusLine(statusCode int) string {
|
|
return strings.TrimSpace(strings.Join([]string{strconv.Itoa(statusCode), http.StatusText(statusCode)}, " "))
|
|
}
|
|
|
|
func TestWebFetchRunWithSandboxAllowsUnderShellNetworkDeny(t *testing.T) {
|
|
called := false
|
|
client := webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
called = true
|
|
return webFetchTestResponse(request, http.StatusOK, "text/plain", "ok"), nil
|
|
})
|
|
tool, ok := newWebFetchToolWithClientAndResolver(client, publicWebFetchResolver(t, "example.com")).(webFetchTool)
|
|
if !ok {
|
|
t.Fatalf("newWebFetchToolWithClientAndResolver returned %T", newWebFetchToolWithClientAndResolver(client, nil))
|
|
}
|
|
|
|
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
|
Policy: zeroSandbox.Policy{Mode: zeroSandbox.ModeEnforce, Network: zeroSandbox.NetworkDeny},
|
|
})
|
|
result := tool.RunWithSandbox(context.Background(), map[string]any{"url": "https://example.com"}, engine)
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("web_fetch must run under shell network deny, got %q: %s", result.Status, result.Output)
|
|
}
|
|
if !called {
|
|
t.Fatal("web_fetch transport must be called")
|
|
}
|
|
}
|
|
|
|
func TestWebFetchRunWithSandboxAllowsUnderShellNetworkAllow(t *testing.T) {
|
|
called := false
|
|
client := webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
called = true
|
|
return webFetchTestResponse(request, http.StatusOK, "text/plain", "ok"), nil
|
|
})
|
|
tool := newWebFetchToolWithClientAndResolver(client, publicWebFetchResolver(t, "evil.test")).(webFetchTool)
|
|
|
|
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
|
Policy: zeroSandbox.Policy{Mode: zeroSandbox.ModeEnforce, Network: zeroSandbox.NetworkAllow},
|
|
})
|
|
result := tool.RunWithSandbox(context.Background(), map[string]any{"url": "https://evil.test"}, engine)
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("web_fetch must run under shell network allow, got %q: %s", result.Status, result.Output)
|
|
}
|
|
if !called {
|
|
t.Fatal("web_fetch transport must be called")
|
|
}
|
|
}
|
|
|
|
func TestRegistryRoutesWebFetchThroughSandboxPath(t *testing.T) {
|
|
called := false
|
|
client := webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
called = true
|
|
return webFetchTestResponse(request, http.StatusOK, "text/plain", "ok"), nil
|
|
})
|
|
registry := NewRegistry()
|
|
registry.Register(newWebFetchToolWithClientAndResolver(client, publicWebFetchResolver(t, "evil.test")))
|
|
|
|
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
|
Policy: zeroSandbox.Policy{Mode: zeroSandbox.ModeEnforce, Network: zeroSandbox.NetworkDeny},
|
|
})
|
|
res := registry.RunWithOptions(context.Background(), "web_fetch", map[string]any{"url": "https://evil.test"}, RunOptions{
|
|
Sandbox: engine,
|
|
PermissionGranted: true,
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("registry must route web_fetch through RunWithSandbox, got %q: %s", res.Status, res.Output)
|
|
}
|
|
if !called {
|
|
t.Fatal("web_fetch transport must be called")
|
|
}
|
|
}
|
|
|
|
func publicWebFetchResolver(t *testing.T, expectedHost string) webFetchResolver {
|
|
t.Helper()
|
|
return webFetchResolverFunc(func(_ context.Context, network string, host string) ([]netip.Addr, error) {
|
|
if network != "ip" || host != expectedHost {
|
|
t.Fatalf("unexpected lookup network=%q host=%q", network, host)
|
|
}
|
|
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
|
})
|
|
}
|