Files
zero/internal/tools/web_fetch_proxy_test.go
Vasanthdev2004 221fb93041 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.
2026-09-09 11:03:14 +05:30

164 lines
5.9 KiB
Go

package tools
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/proxydial"
)
// webFetchTransportWithProxy builds the guarded web_fetch round tripper and
// swaps in a proxy function, which is how the transport learns its route. The
// environment is left alone deliberately: http.ProxyFromEnvironment reads it
// once per process, so an environment-driven test would depend on test order.
func webFetchTransportWithProxy(t *testing.T, resolver webFetchResolver, proxyFor func(*http.Request) (*url.URL, error)) *proxydial.Transport {
t.Helper()
roundTripper := webFetchSafeTransport(nil, resolver)
wrapped, ok := roundTripper.(*proxydial.Transport)
if !ok {
t.Fatalf("transport = %T, want the route-recording wrapper; without it no dial can be recognised as the proxy dial", roundTripper)
}
wrapped.Proxy = proxyFor
return wrapped
}
// THE PROXY IS NOT THE TARGET. With HTTPS_PROXY pointing at a local forward
// proxy, the transport dials the proxy and tunnels the request through it. The
// dialer used to refuse that dial as a loopback address, so every fetch on a
// machine behind such a proxy failed with "loopback hosts are blocked" for a
// public target that had already been validated (#569).
func TestWebFetchTunnelsThroughTheConfiguredProxy(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
arrived := make(chan string, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
buffer := make([]byte, 256)
n, _ := conn.Read(buffer)
arrived <- string(buffer[:n])
}()
proxyURL, err := url.Parse("http://" + listener.Addr().String())
if err != nil {
t.Fatal(err)
}
// A proxied fetch never resolves the target on this side; the CONNECT
// carries the hostname and the proxy resolves it.
transport := webFetchTransportWithProxy(t,
webFetchResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) {
return nil, errors.New("resolver must not be called for a proxied fetch")
}),
func(*http.Request) (*url.URL, error) { return proxyURL, nil })
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
_, err = client.Get("https://public.example/resource")
select {
case first := <-arrived:
if line := strings.SplitN(first, "\r\n", 2)[0]; line != "CONNECT public.example:443 HTTP/1.1" {
t.Fatalf("proxy received %q, want a CONNECT for the target", line)
}
case <-time.After(5 * time.Second):
t.Fatalf("the proxy never received a connection; request error: %v", err)
}
}
// AND A DIRECT FETCH STAYS PINNED, EVEN WHEN ITS ORIGIN IS THE PROXY'S OWN
// ADDRESS.
//
// This is the NO_PROXY shape: the proxy function sends one host direct. An
// exemption derived from asking that function about a request of our own
// invention would answer "proxied" for the excluded host too, and this dial
// would skip the pin and reach whatever the name resolves to at connect time.
func TestWebFetchDirectRequestToTheProxyEndpointIsStillPinned(t *testing.T) {
proxyURL, err := url.Parse("http://rebind.example:80")
if err != nil {
t.Fatal(err)
}
lookups := 0
transport := webFetchTransportWithProxy(t,
webFetchResolverFunc(func(_ context.Context, _ string, host string) ([]netip.Addr, error) {
lookups++
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
}),
func(request *http.Request) (*url.URL, error) {
if request.URL.Hostname() == "rebind.example" {
return nil, nil
}
return proxyURL, nil
})
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
_, err = client.Get("http://rebind.example/data")
if lookups == 0 {
t.Fatal("the direct dial skipped the address pin because its address matched the configured proxy endpoint")
}
if err == nil || !strings.Contains(err.Error(), "loopback hosts are blocked") {
t.Fatalf("err = %v, want the private answer to be refused", err)
}
}
// A proxy configured with a Unicode hostname is dialed by its IDNA-encoded
// name, and it is still the proxy.
func TestWebFetchUnicodeProxyHostnameIsStillTheProxy(t *testing.T) {
dialed := ""
transport := webFetchTransportWithProxy(t,
webFetchResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) {
return nil, errors.New("resolver must not be called for a proxied fetch")
}),
func(*http.Request) (*url.URL, error) { return url.Parse("http://büro.example:3067") })
guarded := transport.DialContext
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = address
return guarded(ctx, network, address)
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
_, err := client.Get("https://public.example/resource")
if dialed != "xn--bro-hoa.example:3067" {
t.Fatalf("SETUP INVALID: dialed %q, want the IDNA-encoded proxy endpoint", dialed)
}
if err != nil && strings.Contains(err.Error(), "hosts are blocked") {
t.Fatalf("the configured proxy was refused by the guard: %v", err)
}
}
// Without a proxy the guard is exactly what it was: loopback is refused and
// the dialer never runs.
func TestWebFetchSafeDialStillRefusesLoopbackWithoutAProxy(t *testing.T) {
dialCalled := false
dial := webFetchSafeDialContext(
webFetchResolverFunc(func(_ context.Context, network string, host string) ([]netip.Addr, error) {
t.Fatalf("resolver consulted for a loopback literal: network=%q host=%q", network, host)
return nil, 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", "127.0.0.1:3067")
if err == nil || !strings.Contains(err.Error(), "loopback hosts are blocked") {
t.Fatalf("expected loopback rejection, got %v", err)
}
if dialCalled {
t.Fatal("loopback was dialed with no proxy configured")
}
}