Files
zero/internal/tools/web_fetch_test.go
Vasanth Tandbaoyu0 1e4db7c664 fix: let the configured forward proxy through the loopback guard (#1025)
* 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]>
2026-09-10 21:50:26 +05:30

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
})
}