From 252ee71efdcd0804db2f9517f892aa1e5cd84f07 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 17 Sep 2026 17:49:28 +0200 Subject: [PATCH] feat(httpclient): route guarded browser requests through trusted egress Assisted-By: claude-opus-4-5 --- pkg/httpclient/desktop_transport.go | 8 +- pkg/httpclient/egress.go | 189 +++++++++++++++ pkg/httpclient/egress_js.go | 34 +++ pkg/httpclient/egress_js_test.go | 55 +++++ pkg/httpclient/egress_other.go | 11 + pkg/httpclient/egress_other_test.go | 33 +++ pkg/httpclient/egress_test.go | 349 ++++++++++++++++++++++++++++ 7 files changed, 676 insertions(+), 3 deletions(-) create mode 100644 pkg/httpclient/egress.go create mode 100644 pkg/httpclient/egress_js.go create mode 100644 pkg/httpclient/egress_js_test.go create mode 100644 pkg/httpclient/egress_other.go create mode 100644 pkg/httpclient/egress_other_test.go create mode 100644 pkg/httpclient/egress_test.go diff --git a/pkg/httpclient/desktop_transport.go b/pkg/httpclient/desktop_transport.go index 4f80e5639..81097f39c 100644 --- a/pkg/httpclient/desktop_transport.go +++ b/pkg/httpclient/desktop_transport.go @@ -52,7 +52,7 @@ func newDesktopAwareTransport(guarded bool) http.RoundTripper { } else { direct = cloneDefaultTransport(environmentProxyFunc()) } - return &desktopAwareTransport{ + return wrapBrowserEgress(&desktopAwareTransport{ direct: direct, guarded: guarded, resolver: func(ctx context.Context, host string) ([]net.IP, error) { @@ -62,7 +62,7 @@ func newDesktopAwareTransport(guarded bool) http.RoundTripper { return desktoptransport.NewDesktopTransport(direct) }, proxySafeLookupTimeout: defaultProxySafeLookupTimeout, - } + }, guarded) } // NewDesktopAwareSSRFSafeTransport returns a guarded transport that routes @@ -71,7 +71,9 @@ func newDesktopAwareTransport(guarded bool) http.RoundTripper { // the direct SSRF-guarded transport (dial-time enforcement, defeats DNS // rebinding). Docker Desktop is optional: absent, disabled, or when the target // host is outside the allowlist, requests fall back to direct with the same -// SSRF protection as NewSSRFSafeTransport. +// SSRF protection as NewSSRFSafeTransport. On browser (js/wasm) builds the +// guard cannot run at dial time, so the transport instead requires an egress +// proxy from [WithEgressProxy] and fails closed without one. func NewDesktopAwareSSRFSafeTransport() http.RoundTripper { return newDesktopAwareTransport(true) } diff --git a/pkg/httpclient/egress.go b/pkg/httpclient/egress.go new file mode 100644 index 000000000..9fc13a8bb --- /dev/null +++ b/pkg/httpclient/egress.go @@ -0,0 +1,189 @@ +package httpclient + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" +) + +// EgressProxyURLParam is the query parameter under which the trusted egress +// proxy receives the original absolute target URL. +const EgressProxyURLParam = "url" + +// EgressProxyLocationHeader carries the target's Location header back from +// the trusted egress proxy on redirect responses, in place of a real Location +// the browser would act on. See [WithEgressProxy]. +const EgressProxyLocationHeader = "X-Docker-Agent-Location" + +// ErrBrowserEgressUnsupported is returned by guarded transports on browser +// (js/wasm) builds when no egress proxy is configured on the request context. +// The browser's fetch API gives us no dial hook, so the SSRF guard cannot be +// enforced locally and the request fails closed. +var ErrBrowserEgressUnsupported = errors.New("httpclient: SSRF-guarded HTTP is not supported in the browser: " + + "configure a trusted HTTPS egress proxy (toolProxy) or opt in with allow_private_ips: true") + +type egressProxyKey struct{} + +// WithEgressProxy returns a context that routes SSRF-guarded requests through +// the trusted HTTPS egress proxy at proxyURL. Only browser (js/wasm) guarded +// transports consult it; native transports keep dial-time enforcement and +// ignore the value. +// +// Protocol: the proxy receives the original absolute target URL in the +// [EgressProxyURLParam] query parameter, with the same method, body and +// headers as the original request. The proxy is trusted to resolve the +// target and enforce the destination IP policy. It must not follow the +// target's redirects: it relays a 301/302/303/307/308 with the same status, +// no Location header, and the target's Location (absolute, or relative to +// the target) in [EgressProxyLocationHeader], which it must list in +// Access-Control-Expose-Headers. The transport restores it as Location so +// the caller's http.Client.CheckRedirect sees real target URLs, strips +// credentials across hosts and bounds the chain exactly as for a direct +// request; each hop is a fresh request to the proxy. A redirect response +// that still carries a real Location is rejected. +// +// Request headers — including Authorization and other credentials meant for +// the target — are forwarded to the proxy intentionally, so proxyURL must +// point at infrastructure the operator controls. The fetch runs with +// credentials "omit", so the page's cookies never ride along; the proxy must +// still strip what the browser adds on its own — Cookie, Origin, Referer and +// Host — before forwarding, so the target sees only the request Go built. The +// URL must be https:// with a host and without userinfo or fragment; an +// existing query string is kept but must not already set [EgressProxyURLParam]. +// +// Callers building their own transport from [NewSSRFSafeTransport] are not +// covered: only the shared transports behind [TransportForAllowPrivateIPs] +// and [ClientForAllowPrivateIPs] (MCP, fetch, api, openapi, a2a) honour it. +func WithEgressProxy(ctx context.Context, proxyURL string) (context.Context, error) { + u, err := parseEgressProxyURL(proxyURL) + if err != nil { + return nil, err + } + return context.WithValue(ctx, egressProxyKey{}, u), nil +} + +// EgressProxyFromContext returns the egress proxy URL set by +// [WithEgressProxy], or nil when none is configured. +func EgressProxyFromContext(ctx context.Context) *url.URL { + u, _ := ctx.Value(egressProxyKey{}).(*url.URL) + if u == nil { + return nil + } + clone := *u + return &clone +} + +func parseEgressProxyURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid egress proxy URL: %w", err) + } + switch { + case u.Scheme != "https": + return nil, fmt.Errorf("egress proxy URL %q must use https", u.Redacted()) + case u.Host == "" || u.Hostname() == "": + return nil, fmt.Errorf("egress proxy URL %q must have a host", u.Redacted()) + case u.User != nil: + return nil, fmt.Errorf("egress proxy URL %q must not contain userinfo", u.Redacted()) + case u.Fragment != "" || u.RawFragment != "": + return nil, fmt.Errorf("egress proxy URL %q must not contain a fragment", u.Redacted()) + case u.Query().Has(EgressProxyURLParam): + return nil, fmt.Errorf("egress proxy URL %q must not set the %q query parameter", u.Redacted(), EgressProxyURLParam) + } + return u, nil +} + +// egressProxyTransport is the guarded transport on browser builds. It +// rewrites every request to target the egress proxy from the request context, +// carrying the original absolute URL in [EgressProxyURLParam], and fails +// closed with [ErrBrowserEgressUnsupported] when no proxy is configured. +// base performs the rewritten request. +type egressProxyTransport struct { + base http.RoundTripper +} + +func (t *egressProxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + proxy := EgressProxyFromContext(req.Context()) + if proxy == nil { + return nil, ErrBrowserEgressUnsupported + } + if req.URL == nil || (req.URL.Scheme != "http" && req.URL.Scheme != "https") || req.URL.Host == "" { + return nil, fmt.Errorf("egress proxy requires an absolute http(s) target URL, got %q", redacted(req.URL)) + } + // Like a direct request, the fragment is never sent on the wire. + target := *req.URL + target.Fragment, target.RawFragment = "", "" + + query := proxy.Query() + query.Set(EgressProxyURLParam, target.String()) + proxy.RawQuery = query.Encode() + + out := req.Clone(req.Context()) + out.URL = proxy + // The Host header must name the proxy, not the target. + out.Host = "" + // Consumed by net/http's fetch path: the browser must never follow a + // redirect off the proxy, which would bypass its destination checks. + // Redirects come back as data in EgressProxyLocationHeader instead. + out.Header.Set("js.fetch:redirect", "error") + // The only credential is the explicit Authorization header; the page's + // cookies for the proxy origin must not be attached, nor Set-Cookie honoured. + out.Header.Set("js.fetch:credentials", "omit") + + resp, err := t.base.RoundTrip(out) + if err != nil { + return nil, err + } + // Callers resolve relative links and report the final URL from + // resp.Request; it must name the target, not the proxy. + resp.Request = req + if err := restoreEgressLocation(resp); err != nil { + if resp.Body != nil { + resp.Body.Close() + } + return nil, err + } + return resp, nil +} + +// restoreEgressLocation moves the proxy's relayed Location back into the +// Location header on redirect responses. The metadata header is always +// removed so it cannot leak to callers; on non-redirect statuses it is +// ignored, so the proxy cannot turn a 200 into a redirect. +func restoreEgressLocation(resp *http.Response) error { + loc := resp.Header.Get(EgressProxyLocationHeader) + resp.Header.Del(EgressProxyLocationHeader) + if !isRedirectStatus(resp.StatusCode) { + return nil + } + if location := resp.Header.Get("Location"); location != "" { + // Browsers already fail such a fetch under redirect: "error"; keep + // Node and native mocks equally strict. + return fmt.Errorf("egress proxy returned %d with a Location header %q; it must relay redirects in %s", + resp.StatusCode, location, EgressProxyLocationHeader) + } + if loc != "" { + resp.Header.Set("Location", loc) + } + return nil +} + +// isRedirectStatus matches the statuses http.Client and fetch treat as +// redirects. +func isRedirectStatus(code int) bool { + switch code { + case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, + http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + return true + } + return false +} + +func redacted(u *url.URL) string { + if u == nil { + return "" + } + return u.Redacted() +} diff --git a/pkg/httpclient/egress_js.go b/pkg/httpclient/egress_js.go new file mode 100644 index 000000000..598627c42 --- /dev/null +++ b/pkg/httpclient/egress_js.go @@ -0,0 +1,34 @@ +//go:build js + +package httpclient + +import ( + "net/http" + "strings" + "syscall/js" +) + +// wrapBrowserEgress replaces guarded transports with the egress-proxy +// transport in the browser, where net/http serves requests through fetch and +// the dial-time SSRF guard never runs. Unguarded transports (explicit +// allow_private_ips opt-ins) and Node processes — whose net/http uses the +// regular dialing round trip — keep rt. +func wrapBrowserEgress(rt http.RoundTripper, guarded bool) http.RoundTripper { + return wrapBrowserEgressWith(rt, guarded, isNodeProcess(), &http.Transport{}) +} + +// wrapBrowserEgressWith is the injectable core of wrapBrowserEgress. fetch +// must be a transport without Dial funcs so net/http routes it through the +// browser's fetch API. +func wrapBrowserEgressWith(rt http.RoundTripper, guarded, node bool, fetch http.RoundTripper) http.RoundTripper { + if !guarded || node { + return rt + } + return &egressProxyTransport{base: fetch} +} + +// isNodeProcess mirrors net/http's own Node detection (jsFetchDisabled). +func isNodeProcess() bool { + process := js.Global().Get("process") + return process.Type() == js.TypeObject && strings.HasPrefix(process.Get("argv0").String(), "node") +} diff --git a/pkg/httpclient/egress_js_test.go b/pkg/httpclient/egress_js_test.go new file mode 100644 index 000000000..7ca406348 --- /dev/null +++ b/pkg/httpclient/egress_js_test.go @@ -0,0 +1,55 @@ +//go:build js + +package httpclient + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWrapBrowserEgressKeepsTransportOnNodeAndUnguarded(t *testing.T) { + assert.True(t, isNodeProcess(), "tests run under Node") + + base := &countingTransport{} + fetch := &countingTransport{} + assert.Same(t, base, wrapBrowserEgressWith(base, true, true, fetch), "Node keeps the dialing guarded transport") + assert.Same(t, base, wrapBrowserEgressWith(base, false, false, fetch), "allow_private_ips is never rerouted") + assert.Same(t, base, wrapBrowserEgress(base, true)) + assert.Same(t, base, wrapBrowserEgress(base, false)) + _, ok := newDesktopAwareTransport(true).(*desktopAwareTransport) + assert.True(t, ok) +} + +func TestWrapBrowserEgressRoutesGuardedThroughProxy(t *testing.T) { + base := roundTripperFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("guarded dialing transport must not be used in the browser") + return nil, nil + }) + var seen *http.Request + fetch := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req + return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil + }) + transport := wrapBrowserEgressWith(base, true, false, fetch) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://api.example.com/v1", http.NoBody) + require.NoError(t, err) + resp, err := transport.RoundTrip(req) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } + require.ErrorIs(t, err, ErrBrowserEgressUnsupported) + assert.Nil(t, seen) + + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + resp, err = transport.RoundTrip(req.WithContext(ctx)) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.NotNil(t, seen) + assert.Equal(t, "egress.example", seen.URL.Host) + assert.Equal(t, "https://api.example.com/v1", seen.URL.Query().Get(EgressProxyURLParam)) +} diff --git a/pkg/httpclient/egress_other.go b/pkg/httpclient/egress_other.go new file mode 100644 index 000000000..692391b65 --- /dev/null +++ b/pkg/httpclient/egress_other.go @@ -0,0 +1,11 @@ +//go:build !js + +package httpclient + +import "net/http" + +// wrapBrowserEgress is the identity on native builds: the direct transport +// enforces the SSRF guard at dial time. +func wrapBrowserEgress(rt http.RoundTripper, _ bool) http.RoundTripper { + return rt +} diff --git a/pkg/httpclient/egress_other_test.go b/pkg/httpclient/egress_other_test.go new file mode 100644 index 000000000..1163b18a4 --- /dev/null +++ b/pkg/httpclient/egress_other_test.go @@ -0,0 +1,33 @@ +//go:build !js + +package httpclient + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNativeTransportsIgnoreEgressProxy(t *testing.T) { + t.Parallel() + + for _, guarded := range []bool{true, false} { + transport := newDesktopAwareTransport(guarded) + _, ok := transport.(*desktopAwareTransport) + assert.True(t, ok, "guarded=%v: native transports are never wrapped", guarded) + } + + // With a proxy in the context the guarded transport still enforces the + // SSRF guard locally instead of rerouting to the proxy. + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1/", http.NoBody) + require.NoError(t, err) + resp, err := NewDesktopAwareSSRFSafeTransport().RoundTrip(req) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } + require.ErrorContains(t, err, "non-public address") +} diff --git a/pkg/httpclient/egress_test.go b/pkg/httpclient/egress_test.go new file mode 100644 index 000000000..76e05ff9b --- /dev/null +++ b/pkg/httpclient/egress_test.go @@ -0,0 +1,349 @@ +package httpclient + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithEgressProxyValidatesURL(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + url string + wantErr string + }{ + {name: "https with path", url: "https://egress.example/proxy"}, + {name: "https with unrelated query", url: "https://egress.example/proxy?tenant=a"}, + {name: "http", url: "http://egress.example/proxy", wantErr: "must use https"}, + {name: "empty", url: "", wantErr: "must use https"}, + {name: "no host", url: "https:///proxy", wantErr: "must have a host"}, + {name: "userinfo", url: "https://user:secret@egress.example/proxy", wantErr: "must not contain userinfo"}, + {name: "fragment", url: "https://egress.example/proxy#frag", wantErr: "must not contain a fragment"}, + {name: "url param", url: "https://egress.example/proxy?url=x", wantErr: `must not set the "url" query parameter`}, + {name: "malformed", url: "https://egress.example/%zz", wantErr: "invalid egress proxy URL"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx, err := WithEgressProxy(t.Context(), tc.url) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + assert.NotContains(t, err.Error(), "secret") + assert.Nil(t, ctx) + return + } + require.NoError(t, err) + require.NotNil(t, EgressProxyFromContext(ctx)) + assert.Equal(t, tc.url, EgressProxyFromContext(ctx).String()) + }) + } + + assert.Nil(t, EgressProxyFromContext(t.Context())) +} + +func TestEgressProxyFromContextReturnsCopy(t *testing.T) { + t.Parallel() + + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + EgressProxyFromContext(ctx).RawQuery = "url=tampered" + assert.Equal(t, "https://egress.example/proxy", EgressProxyFromContext(ctx).String()) +} + +func TestEgressProxyTransportRewritesToProxy(t *testing.T) { + t.Parallel() + + var seen *http.Request + var seenBody string + transport := &egressProxyTransport{base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seen = req + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + seenBody = string(body) + return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil + })} + + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy?tenant=a") + require.NoError(t, err) + target := "https://api.example.com/v1/items?q=a%20b&x=1#frag" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader(`{"k":"v"}`)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer token") + req.Header.Set("Content-Type", "application/json") + req.Host = "override.example" + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.NotNil(t, seen) + assert.Equal(t, http.MethodPost, seen.Method) + assert.Equal(t, "https", seen.URL.Scheme) + assert.Equal(t, "egress.example", seen.URL.Host) + assert.Equal(t, "/proxy", seen.URL.Path) + assert.Equal(t, "a", seen.URL.Query().Get("tenant")) + assert.Equal(t, "https://api.example.com/v1/items?q=a%20b&x=1", seen.URL.Query().Get(EgressProxyURLParam), "fragment is never sent") + assert.Empty(t, seen.Host) + assert.Equal(t, "Bearer token", seen.Header.Get("Authorization")) + assert.Equal(t, "application/json", seen.Header.Get("Content-Type")) + assert.Equal(t, "error", seen.Header.Get("js.fetch:redirect")) + assert.Equal(t, "omit", seen.Header.Get("js.fetch:credentials")) + assert.JSONEq(t, `{"k":"v"}`, seenBody) + + // The caller's request is untouched. + assert.Equal(t, target, req.URL.String()) + assert.Equal(t, "override.example", req.Host) + assert.Empty(t, req.Header.Get("js.fetch:redirect")) + assert.Empty(t, req.Header.Get("js.fetch:credentials")) +} + +func TestEgressProxyTransportFailsClosedWithoutProxy(t *testing.T) { + t.Parallel() + + transport := &egressProxyTransport{base: roundTripperFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("base transport must not be used without a proxy") + return nil, nil + })} + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://api.example.com/", http.NoBody) + require.NoError(t, err) + + resp, err := transport.RoundTrip(req) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } + require.ErrorIs(t, err, ErrBrowserEgressUnsupported) + assert.Contains(t, err.Error(), "toolProxy") + assert.Contains(t, err.Error(), "allow_private_ips") +} + +func TestEgressProxyTransportRejectsNonAbsoluteTarget(t *testing.T) { + t.Parallel() + + transport := &egressProxyTransport{base: roundTripperFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("base transport must not be used for an invalid target") + return nil, nil + })} + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + + for _, target := range []string{"/relative", "ftp://files.example/x", "unix:///var/run/sock"} { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, http.NoBody) + require.NoError(t, err) + resp, err := transport.RoundTrip(req) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } + require.ErrorContains(t, err, "absolute http(s) target URL", target) + } +} + +// fakeEgressProxy is a base transport standing in for the trusted proxy: it +// serves canned responses per target URL and records the hops it received. +type fakeEgressProxy struct { + t *testing.T + responses map[string]canned + targets []string + auth []string +} + +type canned struct { + code int + header http.Header +} + +func relayedRedirect(code int, location string) canned { + return canned{code: code, header: http.Header{EgressProxyLocationHeader: {location}}} +} + +func (p *fakeEgressProxy) RoundTrip(req *http.Request) (*http.Response, error) { + assert.Equal(p.t, "egress.example", req.URL.Host, "every hop goes to the proxy") + assert.Equal(p.t, "error", req.Header.Get("js.fetch:redirect")) + assert.Equal(p.t, "omit", req.Header.Get("js.fetch:credentials")) + target := req.URL.Query().Get(EgressProxyURLParam) + p.targets = append(p.targets, target) + p.auth = append(p.auth, req.Header.Get("Authorization")) + c, ok := p.responses[target] + if !ok { + return nil, errors.New("unexpected target " + target) + } + return &http.Response{StatusCode: c.code, Header: c.header.Clone(), Body: http.NoBody}, nil +} + +func egressTestClient(t *testing.T, proxy *fakeEgressProxy, checkRedirect func(*http.Request, []*http.Request) error) (*http.Client, *http.Request) { + t.Helper() + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/v1/items", http.NoBody) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer token") + return &http.Client{Transport: &egressProxyTransport{base: proxy}, CheckRedirect: checkRedirect}, req +} + +func TestEgressProxyTransportRestoresRelayedLocation(t *testing.T) { + t.Parallel() + + for _, code := range []int{301, 302, 303, 307, 308} { + proxy := &fakeEgressProxy{t: t, responses: map[string]canned{ + "https://api.example.com/v1/items": relayedRedirect(code, "/v2/items"), + }} + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/v1/items", http.NoBody) + require.NoError(t, err) + + resp, err := (&egressProxyTransport{base: proxy}).RoundTrip(req) + require.NoError(t, err, code) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, code, resp.StatusCode) + assert.Equal(t, "/v2/items", resp.Header.Get("Location"), code) + assert.Empty(t, resp.Header.Values(EgressProxyLocationHeader), code) + assert.Same(t, req, resp.Request, "resp.Request must name the target, not the proxy") + } +} + +func TestEgressProxyTransportIgnoresRelayedLocationOnNonRedirects(t *testing.T) { + t.Parallel() + + proxy := &fakeEgressProxy{t: t, responses: map[string]canned{ + "https://api.example.com/ok": {code: http.StatusOK, header: http.Header{EgressProxyLocationHeader: {"https://evil.example/"}}}, + "https://api.example.com/created": {code: http.StatusCreated, header: http.Header{ + "Location": {"/v1/items/42"}, + EgressProxyLocationHeader: {"https://evil.example/"}, + }}, + "https://api.example.com/modified": {code: http.StatusNotModified, header: http.Header{EgressProxyLocationHeader: {"/other"}}}, + }} + transport := &egressProxyTransport{base: proxy} + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + + for target, wantLocation := range map[string]string{ + "https://api.example.com/ok": "", + "https://api.example.com/created": "/v1/items/42", + "https://api.example.com/modified": "", + } { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, http.NoBody) + require.NoError(t, err) + resp, err := transport.RoundTrip(req) + require.NoError(t, err, target) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, wantLocation, resp.Header.Get("Location"), target) + assert.Empty(t, resp.Header.Values(EgressProxyLocationHeader), target) + assert.Same(t, req, resp.Request) + } +} + +func TestEgressProxyTransportRejectsRealLocationOnRedirect(t *testing.T) { + t.Parallel() + + body := &closeRecorder{} + base := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": {"https://api.example.com/v2"}}, + Body: body, + }, nil + }) + ctx, err := WithEgressProxy(t.Context(), "https://egress.example/proxy") + require.NoError(t, err) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/v1", http.NoBody) + require.NoError(t, err) + + resp, err := (&egressProxyTransport{base: base}).RoundTrip(req) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } + assert.Nil(t, resp) + require.ErrorContains(t, err, "302 with a Location header") + assert.Contains(t, err.Error(), EgressProxyLocationHeader) + assert.True(t, body.closed) +} + +func TestEgressProxyClientFollowsRelayedRedirects(t *testing.T) { + t.Parallel() + + proxy := &fakeEgressProxy{t: t, responses: map[string]canned{ + "https://api.example.com/v1/items": relayedRedirect(http.StatusFound, "/v2/items"), + "https://api.example.com/v2/items": relayedRedirect(http.StatusTemporaryRedirect, "https://cdn.example/items"), + "https://cdn.example/items": {code: http.StatusOK}, + }} + var seen []string + client, req := egressTestClient(t, proxy, func(req *http.Request, via []*http.Request) error { + seen = append(seen, req.URL.String()) + return nil + }) + + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "https://cdn.example/items", resp.Request.URL.String()) + assert.Equal(t, []string{"https://api.example.com/v2/items", "https://cdn.example/items"}, seen, + "CheckRedirect sees target URLs, relative ones resolved against the target") + assert.Equal(t, []string{"https://api.example.com/v1/items", "https://api.example.com/v2/items", "https://cdn.example/items"}, proxy.targets) + assert.Equal(t, []string{"Bearer token", "Bearer token", ""}, proxy.auth, "credentials are stripped across hosts") +} + +func TestEgressProxyClientReturnsRedirectWithoutLocation(t *testing.T) { + t.Parallel() + + proxy := &fakeEgressProxy{t: t, responses: map[string]canned{ + "https://api.example.com/v1/items": {code: http.StatusFound}, + }} + client, req := egressTestClient(t, proxy, func(*http.Request, []*http.Request) error { + t.Fatal("no redirect to check") + return nil + }) + + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Equal(t, []string{"https://api.example.com/v1/items"}, proxy.targets) +} + +func TestEgressProxyClientEnforcesRedirectPolicy(t *testing.T) { + t.Parallel() + + proxy := &fakeEgressProxy{t: t, responses: map[string]canned{ + "https://api.example.com/v1/items": relayedRedirect(http.StatusFound, "http://api.example.com/plain"), + "http://api.example.com/plain": {code: http.StatusOK}, + }} + client, req := egressTestClient(t, proxy, HTTPSOnlyRedirects(10)) + + resp, err := client.Do(req) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } + require.ErrorContains(t, err, `refusing redirect to non-https URL "http://api.example.com/plain"`) + assert.Equal(t, []string{"https://api.example.com/v1/items"}, proxy.targets, "the rejected hop is never requested") +} + +func TestEgressProxyClientBoundsRedirectChain(t *testing.T) { + t.Parallel() + + proxy := &fakeEgressProxy{t: t, responses: map[string]canned{ + "https://api.example.com/v1/items": relayedRedirect(http.StatusFound, "/loop"), + "https://api.example.com/loop": relayedRedirect(http.StatusFound, "/loop"), + }} + client, req := egressTestClient(t, proxy, BoundedRedirects(3)) + + resp, err := client.Do(req) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } + require.ErrorContains(t, err, "stopped after 3 redirects") + assert.Len(t, proxy.targets, 3, "the fourth hop is never requested") +} + +type closeRecorder struct { + closed bool +} + +func (r *closeRecorder) Read([]byte) (int, error) { return 0, io.EOF } +func (r *closeRecorder) Close() error { r.closed = true; return nil }