mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 18:30:09 +00:00
feat(httpclient): route guarded browser requests through trusted egress
Assisted-By: claude-opus-4-5
This commit is contained in:
7 files changed
+676
-3
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user