feat(httpclient): route guarded browser requests through trusted egress

Assisted-By: claude-opus-4-5
This commit is contained in:
David Gageot committed 2026-09-18 12:54:55 +02:00
1 parent f92ce0f6dd
commit 252ee71efd
7 files changed
+676 -3

No files matched your search

+5 -3
View File
@@ -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)
}
+189
View File
@@ -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()
}
+34
View File
@@ -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")
}
+55
View File
@@ -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))
}
+11
View File
@@ -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
}
+33
View File
@@ -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")
}
+349
View File
@@ -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 }