mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 05:01:36 -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]>
164 lines
5.9 KiB
Go
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")
|
|
}
|
|
}
|