This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new befe7c5bc fix(rest): prevent credential and default header leaks on 
cross-origin redirects (#2098)
befe7c5bc is described below

commit befe7c5bc0ebae0f8794a7df164ffe9f00230fc1
Author: Revanth <[email protected]>
AuthorDate: Tue Oct 13 05:57:15 2026 -0500

    fix(rest): prevent credential and default header leaks on cross-origin 
redirects (#2098)
    
    * fix(rest): prevent credential and default header leaks on cross-origin 
redirects
    
    * address feedback
    
    * fix(rest): refuse cross-origin redirects on catalog and OAuth clients
---
 catalog/rest/metrics_reporter_test.go |   2 +
 catalog/rest/options.go               |   4 +
 catalog/rest/rest.go                  | 116 +++++++--
 catalog/rest/rest_internal_test.go    | 432 ++++++++++++++++++++++++++++++++--
 4 files changed, 514 insertions(+), 40 deletions(-)

diff --git a/catalog/rest/metrics_reporter_test.go 
b/catalog/rest/metrics_reporter_test.go
index 7485135b7..3814fb639 100644
--- a/catalog/rest/metrics_reporter_test.go
+++ b/catalog/rest/metrics_reporter_test.go
@@ -344,6 +344,7 @@ func reporterWithSession(t *testing.T, auth AuthManager, tr 
http.RoundTripper) *
                RoundTripper:   tr,
                authManager:    auth,
                defaultHeaders: http.Header{},
+               catalogOrigin:  &url.URL{Scheme: "http", Host: 
"catalog.invalid"},
        }
        session.defaultHeaders.Set("Content-Type", "application/json")
 
@@ -384,6 +385,7 @@ func TestRESTMetricsReporterRefetchesCredentialPerReport(t 
*testing.T) {
                RoundTripper:   &captureTransport{ch: received},
                authManager:    &rotatingAuthManager{},
                defaultHeaders: http.Header{},
+               catalogOrigin:  &url.URL{Scheme: "http", Host: 
"catalog.invalid"},
        }
        rep := reporterWith(t, session, d, nil)
 
diff --git a/catalog/rest/options.go b/catalog/rest/options.go
index 884d0a759..e58b2c63a 100644
--- a/catalog/rest/options.go
+++ b/catalog/rest/options.go
@@ -139,6 +139,10 @@ func WithPrefix(prefix string) Option {
 // property, and any server-provided signing-region / signing-name overrides.
 // Use WithSignerFactory (or, for AWS, sigv4.WithAwsConfig) instead if you want
 // the region and service to stay driven by those settings.
+//
+// A signer only runs for requests to the catalog origin. Requests to any other
+// origin, including an OAuth token endpoint set with WithAuthURI, are sent
+// unsigned.
 func WithSigner(signer RequestSigner) Option {
        return func(o *options) {
                o.signer = signer
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 83c5d9855..0b4105ea3 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -235,10 +235,43 @@ type sessionTransport struct {
        authManager    AuthManager
        defaultHeaders http.Header
        signer         RequestSigner
-       // signingOrigin is the configured catalog origin. A request to a 
different
-       // origin (e.g. a redirect hop) is not signed, so the signer's 
Authorization
-       // header and any session token never reach an unconfigured host.
-       signingOrigin *url.URL
+
+       // builtinHeaders is the subset of defaultHeaders under the built-in 
keys
+       // (with any operator override applied), which identify the client and
+       // carry no credentials. It is all a request to an origin other than
+       // catalogOrigin or authOrigin (e.g. a redirect hop) receives.
+       builtinHeaders http.Header
+       // catalogOrigin is the configured catalog origin: the only origin that
+       // receives the auth header and the signer's signature. authOrigin is 
the
+       // configured OAuth token endpoint, if any, which also receives
+       // user-supplied default headers.
+       // These are compared against the request URL rather than derived from
+       // the redirect chain, so a transport that omits Response.Request cannot
+       // widen them. A nil catalogOrigin trusts no origin.
+       catalogOrigin *url.URL
+       authOrigin    *url.URL
+}
+
+// maxRedirects matches net/http's default redirect policy, which a custom
+// CheckRedirect replaces.
+const maxRedirects = 10
+
+// sameOriginRedirectsOnly is the CheckRedirect policy for every client the
+// catalog builds. A redirect is followed only while it stays on the origin of
+// the request that started the chain. A cross-origin hop would otherwise
+// replay a 307/308 body (such as the client_credentials form, client_secret
+// included) to the other origin, and a hop that redirects back to a different
+// catalog path would be sent with the catalog's credentials.
+func sameOriginRedirectsOnly(req *http.Request, via []*http.Request) error {
+       if len(via) >= maxRedirects {
+               return fmt.Errorf("stopped after %d redirects", maxRedirects)
+       }
+       if from := via[0].URL; !sameOrigin(from, req.URL) {
+               return fmt.Errorf("%w: refusing redirect from %s://%s to a 
different origin %s://%s",
+                       ErrRESTError, from.Scheme, from.Host, req.URL.Scheme, 
req.URL.Host)
+       }
+
+       return nil
 }
 
 // sameOrigin reports whether two URLs share scheme, host, and effective port.
@@ -263,12 +296,25 @@ func defaultedPort(u *url.URL) string {
 }
 
 func (s *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
+       // This transport adds the auth header, the signature and the session
+       // defaults to every request it sends, including each redirect hop.
+       // net/http's redirect stripping only covers Authorization and cookies 
set
+       // on the request passed to Client.Do, and never custom headers, so 
these
+       // are applied only for the configured origins. sameOriginRedirectsOnly
+       // keeps the clients from following a cross-origin hop at all; this gate
+       // is the second line of defense.
+       toCatalog := s.catalogOrigin != nil && sameOrigin(s.catalogOrigin, 
r.URL)
+       defaults := s.builtinHeaders
+       if toCatalog || (s.authOrigin != nil && sameOrigin(s.authOrigin, 
r.URL)) {
+               defaults = s.defaultHeaders
+       }
+
        // A session default is applied unless the request already carries that
        // header (a per-request override of any default, not just Content-Type
        // wins) or explicitly opted out of it via withSuppressedHeaders 
(carried on
        // the context as an explicit set, never inferred from header values).
        suppressed := suppressedHeadersFrom(r.Context())
-       for k, v := range s.defaultHeaders {
+       for k, v := range defaults {
                ck := http.CanonicalHeaderKey(k)
                if _, ok := r.Header[ck]; ok {
                        continue
@@ -287,7 +333,7 @@ func (s *sessionTransport) RoundTrip(r *http.Request) 
(*http.Response, error) {
        // session default of the same key. A caller cannot suppress or spoof 
the
        // Authorization header by supplying its own. Do not reorder this 
before the
        // default-header loop.
-       if s.authManager != nil && r.Context().Value(skipOAuth) == nil {
+       if s.authManager != nil && toCatalog && r.Context().Value(skipOAuth) == 
nil {
                var (
                        k, v string
                        err  error
@@ -306,7 +352,12 @@ func (s *sessionTransport) RoundTrip(r *http.Request) 
(*http.Response, error) {
                r.Header.Set(k, v)
        }
 
-       if s.signer != nil && (s.signingOrigin == nil || 
sameOrigin(s.signingOrigin, r.URL)) {
+       // A signer only signs requests to the configured catalog origin: a 
request
+       // to a different origin (e.g. a redirect hop) is left unsigned, so the
+       // signer's Authorization header and any session token never reach an
+       // unconfigured host. The guard lives here, in core, so it covers every
+       // signer, including one installed verbatim via WithSigner.
+       if s.signer != nil && toCatalog {
                if err := s.signer.SignRequest(r); err != nil {
                        return nil, err
                }
@@ -953,7 +1004,11 @@ func setupOAuthManager(r *Catalog, cl *http.Client, opts 
*options) (AuthManager,
        // this reuses the catalog client's transport — preserving its TLS, 
proxy and
        // header behavior — but as a distinct *http.Client so the Timeout 
applies to
        // refresh alone and not to ordinary catalog requests.
-       oauthClient := &http.Client{Transport: cl.Transport, Timeout: 
defaultOAuthTimeout}
+       oauthClient := &http.Client{
+               Transport:     cl.Transport,
+               Timeout:       defaultOAuthTimeout,
+               CheckRedirect: sameOriginRedirectsOnly,
+       }
        var closeIdleConnections func()
        if opts.oauthTLSConfig != nil {
                transport := &http.Transport{
@@ -961,8 +1016,9 @@ func setupOAuthManager(r *Catalog, cl *http.Client, opts 
*options) (AuthManager,
                        TLSClientConfig: opts.oauthTLSConfig,
                }
                oauthClient = &http.Client{
-                       Transport: transport,
-                       Timeout:   defaultOAuthTimeout,
+                       Transport:     transport,
+                       Timeout:       defaultOAuthTimeout,
+                       CheckRedirect: sameOriginRedirectsOnly,
                }
                closeIdleConnections = transport.CloseIdleConnections
        }
@@ -1020,6 +1076,19 @@ func (r *Catalog) init(ctx context.Context, ops 
*options, uri string) error {
        return nil
 }
 
+// builtinHeaderDefaults returns the headers every session sends by default.
+// They identify the client and carry no credentials, so they are the only
+// defaults a request to an unconfigured origin receives (see
+// sessionTransport.builtinHeaders).
+func builtinHeaderDefaults() []struct{ key, value string } {
+       return []struct{ key, value string }{
+               {"X-Client-Version", icebergRestSpecVersion},
+               {"Content-Type", "application/json"},
+               {"User-Agent", "GoIceberg/" + iceberg.Version()},
+               {headerIcebergAccessDelegation, defaultAccessDelegation},
+       }
+}
+
 // createSession returns a cleanup that closes only transports created by this
 // function, never transports supplied by the caller.
 func (r *Catalog) createSession(ctx context.Context, opts *options) 
(*http.Client, func(), error) {
@@ -1047,8 +1116,10 @@ func (r *Catalog) createSession(ctx context.Context, 
opts *options) (*http.Clien
        session := &sessionTransport{
                RoundTripper:   baseTransport,
                defaultHeaders: http.Header{},
+               catalogOrigin:  r.baseURI,
+               authOrigin:     opts.authUri,
        }
-       cl := &http.Client{Transport: session}
+       cl := &http.Client{Transport: session, CheckRedirect: 
sameOriginRedirectsOnly}
 
        // If the user does not set an AuthManager, construct one for this 
session
        // without storing it in opts. Bootstrap authentication must not leak 
into
@@ -1062,10 +1133,10 @@ func (r *Catalog) createSession(ctx context.Context, 
opts *options) (*http.Clien
                }
        }
 
-       session.defaultHeaders.Set("X-Client-Version", icebergRestSpecVersion)
-       session.defaultHeaders.Set("Content-Type", "application/json")
-       session.defaultHeaders.Set("User-Agent", "GoIceberg/"+iceberg.Version())
-       session.defaultHeaders.Set(headerIcebergAccessDelegation, 
defaultAccessDelegation)
+       builtins := builtinHeaderDefaults()
+       for _, h := range builtins {
+               session.defaultHeaders.Set(h.key, h.value)
+       }
 
        for k, v := range opts.headers {
                session.defaultHeaders.Set(k, v)
@@ -1077,6 +1148,13 @@ func (r *Catalog) createSession(ctx context.Context, 
opts *options) (*http.Clien
                }
        }
 
+       session.builtinHeaders = http.Header{}
+       for _, h := range builtins {
+               if v := session.defaultHeaders.Values(h.key); len(v) > 0 {
+                       session.builtinHeaders[http.CanonicalHeaderKey(h.key)] 
= slices.Clone(v)
+               }
+       }
+
        if authManager != nil {
                session.authManager = authManager
        }
@@ -1088,14 +1166,6 @@ func (r *Catalog) createSession(ctx context.Context, 
opts *options) (*http.Clien
                return nil, nil, err
        }
        session.signer = signer
-       // A signer only signs requests to the configured catalog origin: a 
request
-       // to a different origin (e.g. a redirect hop) is left unsigned, so the
-       // signer's Authorization header and any session token never reach an
-       // unconfigured host. The guard lives here, in core, so it covers every
-       // signer, including one installed verbatim via WithSigner.
-       if signer != nil {
-               session.signingOrigin = r.baseURI
-       }
 
        return cl, cleanup, nil
 }
diff --git a/catalog/rest/rest_internal_test.go 
b/catalog/rest/rest_internal_test.go
index c3eb75da9..057f3e565 100644
--- a/catalog/rest/rest_internal_test.go
+++ b/catalog/rest/rest_internal_test.go
@@ -58,16 +58,31 @@ func (markingSigner) SignRequest(r *http.Request) error {
        return nil
 }
 
-// TestSignerDoesNotSignCrossOriginRedirect pins the origin guard in
-// sessionTransport.RoundTrip: a redirect to a different origin is not signed, 
so
+// serveEmptyConfig answers GET /v1/config with no defaults or overrides.
+func serveEmptyConfig(w http.ResponseWriter, _ *http.Request) {
+       _ = json.NewEncoder(w).Encode(map[string]any{"defaults": 
map[string]any{}, "overrides": map[string]any{}})
+}
+
+// roundTripTo sends a GET for target through the catalog session's transport
+// directly, bypassing the client's redirect policy, so a test can exercise the
+// transport's own origin gate.
+func roundTripTo(t *testing.T, cat *Catalog, target string) {
+       t.Helper()
+       req, err := http.NewRequestWithContext(context.Background(), 
http.MethodGet, target, nil)
+       require.NoError(t, err)
+       resp, err := cat.cl.Transport.RoundTrip(req)
+       require.NoError(t, err)
+       require.NoError(t, resp.Body.Close())
+}
+
+// TestSignerDoesNotSignCrossOriginRequest pins the origin guard in
+// sessionTransport.RoundTrip: a request to a different origin is not signed, 
so
 // the signer's Authorization header and any session token never reach an
 // unconfigured host. The guard is signer-agnostic, so it covers an explicit
 // WithSigner too; a marking signer exercises it without a real SigV4 backend.
-func TestSignerDoesNotSignCrossOriginRedirect(t *testing.T) {
-       var secondHit bool
+func TestSignerDoesNotSignCrossOriginRequest(t *testing.T) {
        var gotAuth, gotToken string
        second := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
-               secondHit = true
                gotAuth = r.Header.Get("Authorization")
                gotToken = r.Header.Get("X-Amz-Security-Token")
                w.WriteHeader(http.StatusOK)
@@ -76,12 +91,10 @@ func TestSignerDoesNotSignCrossOriginRedirect(t *testing.T) 
{
 
        var firstAuth string
        mux := http.NewServeMux()
-       mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r 
*http.Request) {
-               _ = json.NewEncoder(w).Encode(map[string]any{"defaults": 
map[string]any{}, "overrides": map[string]any{}})
-       })
-       mux.HandleFunc("/redirect", func(w http.ResponseWriter, r 
*http.Request) {
+       mux.HandleFunc("/v1/config", serveEmptyConfig)
+       mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {
                firstAuth = r.Header.Get("Authorization")
-               http.Redirect(w, r, second.URL+"/landing", 
http.StatusTemporaryRedirect)
+               w.WriteHeader(http.StatusOK)
        })
        first := httptest.NewServer(mux)
        defer first.Close()
@@ -90,16 +103,365 @@ func TestSignerDoesNotSignCrossOriginRedirect(t 
*testing.T) {
        require.NoError(t, err)
        t.Cleanup(func() { _ = cat.Close() })
 
-       req, err := http.NewRequestWithContext(context.Background(), 
http.MethodGet, first.URL+"/redirect", nil)
+       roundTripTo(t, cat, first.URL+"/ok")
+       roundTripTo(t, cat, second.URL+"/landing")
+
+       require.NotEmpty(t, firstAuth, "the configured origin must be signed")
+       require.Empty(t, gotAuth, "another origin must not receive the 
Authorization header")
+       require.Empty(t, gotToken, "another origin must not receive the session 
token")
+}
+
+// TestCredentialsNotSentCrossOrigin pins that the OAuth bearer token and
+// user-supplied default headers are only added for the catalog origin, while
+// built-in client headers go everywhere, including an operator override of
+// one of them.
+func TestCredentialsNotSentCrossOrigin(t *testing.T) {
+       var other http.Header
+       second := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               other = r.Header.Clone()
+               w.WriteHeader(http.StatusOK)
+       }))
+       defer second.Close()
+
+       var first http.Header
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", serveEmptyConfig)
+       mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {
+               first = r.Header.Clone()
+               w.WriteHeader(http.StatusOK)
+       })
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+
+       cat, err := NewCatalog(context.Background(), "rest", srv.URL,
+               WithOAuthToken("SECRET-CATALOG-TOKEN"),
+               WithHeaders(map[string]string{"X-Custom": "SECRET-CUSTOM", 
"User-Agent": "corp-agent/1"}),
+               WithAdditionalProps(iceberg.Properties{
+                       "header.X-Api-Key":                        
"SECRET-API-KEY",
+                       "header." + headerIcebergAccessDelegation: 
"remote-signing",
+               }))
+       require.NoError(t, err)
+
+       roundTripTo(t, cat, srv.URL+"/ok")
+       roundTripTo(t, cat, second.URL+"/landing")
+
+       assert.Equal(t, "Bearer SECRET-CATALOG-TOKEN", 
first.Get("Authorization"))
+       assert.Equal(t, "SECRET-API-KEY", first.Get("X-Api-Key"))
+       assert.Equal(t, "SECRET-CUSTOM", first.Get("X-Custom"))
+
+       assert.Empty(t, other.Get("Authorization"), "another origin must not 
receive the bearer token")
+       assert.Empty(t, other.Get("X-Api-Key"), "another origin must not 
receive header.* defaults")
+       assert.Empty(t, other.Get("X-Custom"), "another origin must not receive 
WithHeaders defaults")
+       assert.Equal(t, "corp-agent/1", other.Get("User-Agent"), "an overridden 
built-in header keeps the override")
+       assert.Equal(t, "remote-signing", 
other.Get(headerIcebergAccessDelegation))
+       assert.Equal(t, icebergRestSpecVersion, other.Get("X-Client-Version"), 
"built-in client headers are still sent")
+}
+
+// TestCrossOriginRedirectNotFollowed pins that the catalog client refuses a
+// redirect to another origin before contacting it, while a same-origin
+// redirect is still followed with credentials.
+func TestCrossOriginRedirectNotFollowed(t *testing.T) {
+       var otherHit atomic.Bool
+       second := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               otherHit.Store(true)
+               w.WriteHeader(http.StatusOK)
+       }))
+       defer second.Close()
+
+       var landingAuth string
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", serveEmptyConfig)
+       mux.HandleFunc("/cross", func(w http.ResponseWriter, r *http.Request) {
+               http.Redirect(w, r, second.URL+"/landing", 
http.StatusTemporaryRedirect)
+       })
+       mux.HandleFunc("/same", func(w http.ResponseWriter, r *http.Request) {
+               http.Redirect(w, r, "/landing", http.StatusTemporaryRedirect)
+       })
+       // The loop ends after maxRedirects*2 hops, so a missing redirect limit 
fails
+       // the test instead of hanging it.
+       var loopHops atomic.Int32
+       mux.HandleFunc("/loop", func(w http.ResponseWriter, r *http.Request) {
+               if loopHops.Add(1) > maxRedirects*2 {
+                       w.WriteHeader(http.StatusOK)
+
+                       return
+               }
+               http.Redirect(w, r, "/loop", http.StatusTemporaryRedirect)
+       })
+       mux.HandleFunc("/landing", func(w http.ResponseWriter, r *http.Request) 
{
+               landingAuth = r.Header.Get("Authorization")
+               w.WriteHeader(http.StatusOK)
+       })
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+
+       cat, err := NewCatalog(context.Background(), "rest", srv.URL, 
WithOAuthToken("SECRET-CATALOG-TOKEN"))
        require.NoError(t, err)
-       resp, err := cat.cl.Do(req)
+
+       get := func(t *testing.T, path string) (*http.Response, error) {
+               t.Helper()
+               req, err := http.NewRequestWithContext(context.Background(), 
http.MethodGet, srv.URL+path, nil)
+               require.NoError(t, err)
+
+               return cat.cl.Do(req)
+       }
+
+       t.Run("cross origin", func(t *testing.T) {
+               _, err := get(t, "/cross")
+               require.ErrorIs(t, err, ErrRESTError)
+               assert.False(t, otherHit.Load(), "the redirect target must not 
be contacted")
+       })
+
+       t.Run("same origin", func(t *testing.T) {
+               resp, err := get(t, "/same")
+               require.NoError(t, err)
+               require.NoError(t, resp.Body.Close())
+               assert.Equal(t, "Bearer SECRET-CATALOG-TOKEN", landingAuth)
+       })
+
+       t.Run("loop", func(t *testing.T) {
+               _, err := get(t, "/loop")
+               require.ErrorContains(t, err, "stopped after 10 redirects")
+       })
+}
+
+// TestSyntheticCrossOriginRedirectNotFollowed pins that the redirect policy
+// does not depend on the transport linking Response.Request: a custom
+// transport that returns a bare 307 to another origin is not followed either.
+func TestSyntheticCrossOriginRedirectNotFollowed(t *testing.T) {
+       var otherHit bool
+       transport := roundTripFunc(func(r *http.Request) (*http.Response, 
error) {
+               switch {
+               case r.URL.Host == "other.test":
+                       otherHit = true
+
+                       return &http.Response{StatusCode: http.StatusOK, Body: 
http.NoBody}, nil
+               case r.URL.Path == "/v1/config":
+                       return &http.Response{
+                               StatusCode: http.StatusOK,
+                               Body:       
io.NopCloser(bytes.NewReader([]byte(`{"defaults":{},"overrides":{}}`))),
+                       }, nil
+               default:
+                       // A synthetic redirect: no Request backlink on the 
response.
+                       return &http.Response{
+                               StatusCode: http.StatusTemporaryRedirect,
+                               Header:     http.Header{"Location": 
{"http://other.test/landing"}},
+                               Body:       http.NoBody,
+                       }, nil
+               }
+       })
+
+       cat, err := NewCatalog(context.Background(), "rest", 
"http://catalog.test";,
+               WithCustomTransport(transport),
+               WithOAuthToken("SECRET-CATALOG-TOKEN"))
        require.NoError(t, err)
-       require.NoError(t, resp.Body.Close())
 
-       require.NotEmpty(t, firstAuth, "the configured origin must be signed")
-       require.True(t, secondHit, "the redirect target must be reached")
-       require.Empty(t, gotAuth, "the redirect target must not receive the 
Authorization header")
-       require.Empty(t, gotToken, "the redirect target must not receive the 
session token")
+       req, err := http.NewRequestWithContext(context.Background(), 
http.MethodGet, "http://catalog.test/redirect";, nil)
+       require.NoError(t, err)
+       _, err = cat.cl.Do(req)
+       require.ErrorIs(t, err, ErrRESTError)
+       assert.False(t, otherHit, "the redirect target must not be contacted")
+}
+
+// TestTokenRequestCrossOriginRedirectNotFollowed pins that a token endpoint
+// redirecting to another origin does not get the client_credentials form,
+// client_secret included, replayed there, on every OAuth client the catalog
+// builds.
+func TestTokenRequestCrossOriginRedirectNotFollowed(t *testing.T) {
+       var evilHit atomic.Bool
+       evil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, 
r *http.Request) {
+               evilHit.Store(true)
+               w.Header().Set("Content-Type", "application/json")
+               _ = json.NewEncoder(w).Encode(map[string]any{"access_token": 
"EVIL", "token_type": "Bearer", "expires_in": 3600})
+       }))
+       defer evil.Close()
+
+       redirectToEvil := func(w http.ResponseWriter, r *http.Request) {
+               http.Redirect(w, r, evil.URL+"/steal", 
http.StatusTemporaryRedirect)
+       }
+
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", serveEmptyConfig)
+       mux.HandleFunc("/v1/oauth/tokens", redirectToEvil)
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+
+       tokenSrv := httptest.NewServer(http.HandlerFunc(redirectToEvil))
+       defer tokenSrv.Close()
+       tlsTokenSrv := httptest.NewTLSServer(http.HandlerFunc(redirectToEvil))
+       defer tlsTokenSrv.Close()
+
+       authURI, err := url.Parse(tokenSrv.URL + "/token")
+       require.NoError(t, err)
+       tlsAuthURI, err := url.Parse(tlsTokenSrv.URL + "/token")
+       require.NoError(t, err)
+       tlsTransport, ok := tlsTokenSrv.Client().Transport.(*http.Transport)
+       require.True(t, ok)
+
+       for _, tc := range []struct {
+               name string
+               opts []Option
+       }{
+               {"catalog token endpoint", nil},
+               {"separate token endpoint", []Option{WithAuthURI(authURI)}},
+               {"separate oauth tls config", []Option{
+                       WithAuthURI(tlsAuthURI),
+                       
WithOAuthTLSConfig(tlsTransport.TLSClientConfig.Clone()),
+               }},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       evilHit.Store(false)
+                       opts := 
append([]Option{WithCredential("client:SECRET-CLIENT-SECRET")}, tc.opts...)
+                       _, err := NewCatalog(context.Background(), "rest", 
srv.URL, opts...)
+                       require.ErrorIs(t, err, ErrRESTError)
+                       assert.False(t, evilHit.Load(), "the redirect target 
must not receive the token request")
+               })
+       }
+}
+
+// TestDropTableCrossOriginRedirectNotFollowed pins that a DELETE redirected to
+// another origin is not followed, so that origin cannot bounce it back to the
+// catalog as a purge of a different table carrying the bearer.
+func TestDropTableCrossOriginRedirectNotFollowed(t *testing.T) {
+       var srvURL string
+       var evilHit, victimHit atomic.Bool
+       evil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, 
r *http.Request) {
+               evilHit.Store(true)
+               http.Redirect(w, r, 
srvURL+"/v1/namespaces/ns/tables/victim?purgeRequested=true", 
http.StatusTemporaryRedirect)
+       }))
+       defer evil.Close()
+
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", serveEmptyConfig)
+       mux.HandleFunc("/v1/namespaces/ns/tables/t", func(w 
http.ResponseWriter, r *http.Request) {
+               http.Redirect(w, r, evil.URL+"/bounce", 
http.StatusTemporaryRedirect)
+       })
+       mux.HandleFunc("/v1/namespaces/ns/tables/victim", func(w 
http.ResponseWriter, r *http.Request) {
+               victimHit.Store(true)
+               w.WriteHeader(http.StatusNoContent)
+       })
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+       srvURL = srv.URL
+
+       cat, err := NewCatalog(context.Background(), "rest", srv.URL, 
WithOAuthToken("SECRET-CATALOG-TOKEN"))
+       require.NoError(t, err)
+
+       err = cat.DropTable(context.Background(), table.Identifier{"ns", "t"})
+       require.ErrorIs(t, err, ErrRESTError)
+       assert.False(t, evilHit.Load(), "the redirect target must not be 
contacted")
+       assert.False(t, victimHit.Load(), "a different table must not be 
dropped")
+}
+
+// TestConfigURIOverrideKeepsCredentials pins that a /v1/config uri override
+// onto another origin moves the trusted origin with it. The origin gate relies
+// on init creating the final session after fetchConfig applies the override.
+func TestConfigURIOverrideKeepsCredentials(t *testing.T) {
+       var got http.Header
+       second := http.NewServeMux()
+       second.HandleFunc("/v1/oauth/tokens", func(w http.ResponseWriter, r 
*http.Request) {
+               w.Header().Set("Content-Type", "application/json")
+               _ = json.NewEncoder(w).Encode(map[string]any{"access_token": 
"SECOND-TOKEN", "token_type": "Bearer", "expires_in": 3600})
+       })
+       second.HandleFunc("/v1/namespaces", func(w http.ResponseWriter, r 
*http.Request) {
+               got = r.Header.Clone()
+               _ = json.NewEncoder(w).Encode(map[string]any{"namespaces": 
[][]string{}})
+       })
+       secondSrv := httptest.NewServer(second)
+       defer secondSrv.Close()
+
+       first := http.NewServeMux()
+       first.HandleFunc("/v1/oauth/tokens", func(w http.ResponseWriter, r 
*http.Request) {
+               w.Header().Set("Content-Type", "application/json")
+               _ = json.NewEncoder(w).Encode(map[string]any{"access_token": 
"FIRST-TOKEN", "token_type": "Bearer", "expires_in": 3600})
+       })
+       first.HandleFunc("/v1/config", func(w http.ResponseWriter, r 
*http.Request) {
+               _ = json.NewEncoder(w).Encode(map[string]any{"defaults": 
map[string]any{}, "overrides": map[string]any{"uri": secondSrv.URL}})
+       })
+       firstSrv := httptest.NewServer(first)
+       defer firstSrv.Close()
+
+       for _, tc := range []struct {
+               name       string
+               opt        Option
+               header     string
+               wantHeader string
+       }{
+               {"static token", WithOAuthToken("STATIC-TOKEN"), 
"Authorization", "Bearer STATIC-TOKEN"},
+               {"client credentials", WithCredential("client:secret"), 
"Authorization", "Bearer SECOND-TOKEN"},
+               {"signer", WithSigner(markingSigner{}), "X-Amz-Security-Token", 
"SENTINELTOKEN"},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       got = nil
+                       cat, err := NewCatalog(context.Background(), "rest", 
firstSrv.URL, tc.opt,
+                               
WithAdditionalProps(iceberg.Properties{"header.X-Api-Key": "SECRET-API-KEY"}))
+                       require.NoError(t, err)
+
+                       _, err = cat.ListNamespaces(context.Background(), nil)
+                       require.NoError(t, err)
+                       require.NotNil(t, got, "the overridden origin must be 
reached")
+                       assert.Equal(t, tc.wantHeader, got.Get(tc.header))
+                       assert.Equal(t, "SECRET-API-KEY", got.Get("X-Api-Key"))
+               })
+       }
+}
+
+func TestSameOrigin(t *testing.T) {
+       for _, tc := range []struct {
+               a, b string
+               want bool
+       }{
+               {"https://catalog.example.com/v1";, 
"https://catalog.example.com/other?x=1";, true},
+               {"https://catalog.example.com";, "https://CATALOG.example.com";, 
true},
+               {"HTTPS://catalog.example.com", "https://catalog.example.com";, 
true},
+               {"https://catalog.example.com";, 
"https://catalog.example.com:443";, true},
+               {"http://catalog.example.com";, "http://catalog.example.com:80";, 
true},
+               {"https://catalog.example.com";, "http://catalog.example.com";, 
false},
+               {"https://catalog.example.com";, 
"https://catalog.example.com:8443";, false},
+               {"https://example.com";, "https://sub.example.com";, false},
+               {"https://sub.example.com";, "https://example.com";, false},
+               {"https://catalog.example.com";, "https://other.example.com";, 
false},
+       } {
+               a, err := url.Parse(tc.a)
+               require.NoError(t, err)
+               b, err := url.Parse(tc.b)
+               require.NoError(t, err)
+               assert.Equal(t, tc.want, sameOrigin(a, b), "%s vs %s", tc.a, 
tc.b)
+       }
+}
+
+// TestHeaderDefaultsReachSeparateTokenEndpoint pins that the configured OAuth
+// token endpoint is trusted for header.* defaults even on a different origin
+// from the catalog, so the redirect guard does not break IdPs that need them.
+func TestHeaderDefaultsReachSeparateTokenEndpoint(t *testing.T) {
+       var tokenAPIKey string
+       tokenSrv := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               tokenAPIKey = r.Header.Get("X-Api-Key")
+               w.Header().Set("Content-Type", "application/json")
+               json.NewEncoder(w).Encode(map[string]any{"access_token": 
"TOKEN", "token_type": "Bearer", "expires_in": 3600})
+       }))
+       defer tokenSrv.Close()
+
+       var catalogAuth string
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r 
*http.Request) {
+               catalogAuth = r.Header.Get("Authorization")
+               json.NewEncoder(w).Encode(map[string]any{"defaults": 
map[string]any{}, "overrides": map[string]any{}})
+       })
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+
+       authURI, err := url.Parse(tokenSrv.URL + "/token")
+       require.NoError(t, err)
+
+       _, err = NewCatalog(context.Background(), "rest", srv.URL,
+               WithCredential("client:secret"),
+               WithAuthURI(authURI),
+               WithAdditionalProps(iceberg.Properties{"header.X-Api-Key": 
"SECRET-API-KEY"}))
+       require.NoError(t, err)
+
+       assert.Equal(t, "Bearer TOKEN", catalogAuth)
+       assert.Equal(t, "SECRET-API-KEY", tokenAPIKey, "the configured token 
endpoint must still receive header.* defaults")
 }
 
 func TestSplitIdentForPathRequiresNamespaceAndName(t *testing.T) {
@@ -891,6 +1253,7 @@ func TestRoundTripDefaultHeaderHandling(t *testing.T) {
                                return &http.Response{StatusCode: 
http.StatusOK, Body: http.NoBody}, nil
                        }),
                        defaultHeaders: http.Header{},
+                       catalogOrigin:  &url.URL{Scheme: "http", Host: 
"example.com"},
                }
                s.defaultHeaders.Set(headerIcebergAccessDelegation, 
defaultAccessDelegation)
 
@@ -956,6 +1319,7 @@ func TestRoundTripAuthManagerWinsOverPerRequestHeader(t 
*testing.T) {
                        return &http.Response{StatusCode: http.StatusOK, Body: 
http.NoBody}, nil
                }),
                defaultHeaders: http.Header{},
+               catalogOrigin:  &url.URL{Scheme: "http", Host: "example.com"},
                authManager:    staticAuthManager{key: "Authorization", value: 
"Bearer managed-token"},
        }
 
@@ -1884,6 +2248,7 @@ func TestSessionTransportInvokesSigner(t *testing.T) {
                        return &http.Response{StatusCode: http.StatusOK, Body: 
http.NoBody}, nil
                }),
                defaultHeaders: http.Header{},
+               catalogOrigin:  &url.URL{Scheme: "http", Host: "example.com"},
                signer: signerFunc(func(r *http.Request) error {
                        signed = true
                        r.Header.Set("X-Signed", "yes")
@@ -1900,6 +2265,37 @@ func TestSessionTransportInvokesSigner(t *testing.T) {
        assert.Equal(t, "yes", got.Get("X-Signed"))
 }
 
+// TestSessionTransportNilCatalogOriginTrustsNothing pins that the origin gate
+// fails closed: a sessionTransport built without a catalogOrigin adds no auth
+// header, signature or user-supplied default to any request.
+func TestSessionTransportNilCatalogOriginTrustsNothing(t *testing.T) {
+       t.Parallel()
+
+       var got http.Header
+       s := &sessionTransport{
+               RoundTripper: roundTripFunc(func(r *http.Request) 
(*http.Response, error) {
+                       got = r.Header.Clone()
+
+                       return &http.Response{StatusCode: http.StatusOK, Body: 
http.NoBody}, nil
+               }),
+               authManager:    staticAuthManager{key: "Authorization", value: 
"Bearer managed-token"},
+               defaultHeaders: http.Header{"X-Api-Key": {"SECRET-API-KEY"}},
+               signer: signerFunc(func(r *http.Request) error {
+                       r.Header.Set("X-Signed", "yes")
+
+                       return nil
+               }),
+       }
+
+       req, err := http.NewRequest(http.MethodGet, "http://example.com";, nil)
+       require.NoError(t, err)
+       _, err = s.RoundTrip(req)
+       require.NoError(t, err)
+       assert.Empty(t, got.Get("Authorization"))
+       assert.Empty(t, got.Get("X-Signed"))
+       assert.Empty(t, got.Get("X-Api-Key"))
+}
+
 func TestSessionTransportSignerErrorAbortsRequest(t *testing.T) {
        t.Parallel()
 
@@ -1911,6 +2307,7 @@ func TestSessionTransportSignerErrorAbortsRequest(t 
*testing.T) {
                        return nil, nil
                }),
                defaultHeaders: http.Header{},
+               catalogOrigin:  &url.URL{Scheme: "http", Host: "example.com"},
                signer:         signerFunc(func(_ *http.Request) error { return 
wantErr }),
        }
 
@@ -1934,6 +2331,7 @@ func TestSessionTransportConcurrentRoundTrip(t 
*testing.T) {
                        return &http.Response{StatusCode: http.StatusOK, Body: 
http.NoBody}, nil
                }),
                defaultHeaders: http.Header{"X-Default": {"v"}},
+               catalogOrigin:  &url.URL{Scheme: "http", Host: "example.com"},
                signer: signerFunc(func(r *http.Request) error {
                        count.Add(1)
                        r.Header.Set("X-Signed", "yes")

Reply via email to