Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 42 additions & 15 deletions catalog/rest/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,21 @@ 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 disables the check.
catalogOrigin *url.URL
authOrigin *url.URL
}

// sameOrigin reports whether two URLs share scheme, host, and effective port.
Expand All @@ -262,12 +273,22 @@ func defaultedPort(u *url.URL) string {
}

func (s *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
// net/http strips Authorization on redirect only for a new hostname (a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still a bit off, same as the earlier pass flagged: net/http drops Authorization only when the host differs; it doesn't compare port or scheme, and in current Go a subdomain hop (foo.example.com to example.com) is also stripped, not kept. The thing that actually makes this gate load-bearing is that net/http never strips custom headers like X-Api-Key at all. I'd narrow the wording to that.

// port or scheme change, or a hop to a subdomain, keeps it), but this
// runs after that stripping, so credentials and user-supplied headers are
// only applied for the configured origins.
toCatalog := s.catalogOrigin == nil || sameOrigin(s.catalogOrigin, r.URL)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nil catalogOrigin here means every host is treated as the catalog, so a hand-built or zero-value sessionTransport would send the bearer and signature everywhere. createSession always sets it today so it's latent, but for a security gate I'd rather it fail closed: treat nil as "trust nothing," or assert non-nil in createSession and drop the nil branch.

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
Expand All @@ -286,7 +307,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
Expand All @@ -305,7 +326,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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping signingOrigin for toCatalog is the right call, it was always equal to catalogOrigin. One asymmetry worth a line of godoc on WithSigner: a custom signer now never runs outside the catalog origin, including a signer meant to sign the token request on a separate authUri (headers get the authOrigin carve-out, signing doesn't). Fine as a decision, just document it so nobody expects their STS-style signer to fire on the IdP hop.

if err := s.signer.SignRequest(r); err != nil {
return nil, err
}
Expand Down Expand Up @@ -1046,6 +1072,8 @@ func (r *Catalog) createSession(ctx context.Context, opts *options) (*http.Clien
session := &sessionTransport{
RoundTripper: baseTransport,
defaultHeaders: http.Header{},
catalogOrigin: r.baseURI,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This freezes catalogOrigin at the pre-config baseURI, but fetchConfig can reassign r.baseURI from a server-advertised uri override after the session exists. When that origin differs, every later request is suddenly cross-origin: no bearer, no signature, no header.*, so the catalog 401s on every call. That's a hard regression for gateway/LB deployments that hand back the real backend host, and it worked before this PR. I'd make the trusted origin track r.baseURI after the config merge (an atomic pointer, or a func() *url.URL the transport reads), and add a test where /v1/config returns a uri on a second origin and asserts it still gets auth.

authOrigin: opts.authUri,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gate stops at headers, but neither this client nor the OAuth token client in setupOAuthManager sets CheckRedirect, so cross-origin hops are still followed. The one that worries me is the token endpoint: a 307 replays the client_credentials form body, client_secret and all, to the other origin, and header gating can't touch the body. A hop can also 307 back to a catalog path and pick the bearer back up. I'd add a CheckRedirect that refuses (or returns ErrUseLastResponse on) any hop that isn't same-origin or authOrigin, on both clients, then the header gate becomes defense-in-depth. More on why I'm treating this as blocking in the top-level comment.

}
cl := &http.Client{Transport: session}

Expand Down Expand Up @@ -1076,6 +1104,13 @@ func (r *Catalog) createSession(ctx context.Context, opts *options) (*http.Clien
}
}

session.builtinHeaders = http.Header{}
for _, k := range []string{"X-Client-Version", "Content-Type", "User-Agent", headerIcebergAccessDelegation} {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This key list is a second copy of the four Set calls above, and headerIcebergAccessDelegation is a const here but a literal there. Add a fifth built-in header and it silently stops crossing origins with no test to catch it. I'd hoist one builtinHeaderKeys slice and drive both the defaults and this subset from it.

if v := session.defaultHeaders.Values(k); len(v) > 0 {
session.builtinHeaders[http.CanonicalHeaderKey(k)] = v

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultHeaders.Values(k) hands back the backing slice, not a copy, so builtinHeaders[ck] aliases defaultHeaders[ck]. Nothing mutates these today so it's latent, but if the default-header loop ever assigns the slice onto the request directly, the request, builtinHeaders and defaultHeaders share one array and a downstream Header.Add or a concurrent request can scribble into session state. One line closes it:

session.builtinHeaders[http.CanonicalHeaderKey(k)] = slices.Clone(v)

}
}

if authManager != nil {
session.authManager = authManager
}
Expand All @@ -1087,14 +1122,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
}
Expand Down
201 changes: 201 additions & 0 deletions catalog/rest/rest_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,207 @@ func TestSignerDoesNotSignCrossOriginRedirect(t *testing.T) {
require.Empty(t, gotToken, "the redirect target must not receive the session token")
}

// TestCredentialsNotSentOnCrossOriginRedirect pins that the OAuth bearer token
// and user-supplied default headers stay on the origin that started the request,
// while a same-origin redirect keeps them.
func TestCredentialsNotSentOnCrossOriginRedirect(t *testing.T) {
type seen struct {
hit bool
auth, apiKey, custom, agent string
}
record := func(s *seen, r *http.Request) {
s.hit = true
s.auth = r.Header.Get("Authorization")
s.apiKey = r.Header.Get("X-Api-Key")
s.custom = r.Header.Get("X-Custom")
s.agent = r.Header.Get("User-Agent")
}

var first, other, sameLanding seen
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
record(&other, r)
w.WriteHeader(http.StatusOK)
}))
defer second.Close()

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("/cross", func(w http.ResponseWriter, r *http.Request) {
record(&first, r)
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)
})
mux.HandleFunc("/landing", func(w http.ResponseWriter, r *http.Request) {
record(&sameLanding, r)
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"}),
WithAdditionalProps(iceberg.Properties{"header.X-Api-Key": "SECRET-API-KEY"}))
require.NoError(t, err)

get := func(path string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get closes over the outer t, so a require failure inside a subtest calls FailNow on the parent t from the subtest goroutine: that's the "FailNow from a goroutine other than the test" footgun, and it reports at the wrong location. I'd pass the subtest t in via get := func(t *testing.T, path string) with a t.Helper().

req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+path, nil)
require.NoError(t, err)
resp, err := cat.cl.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
}

t.Run("cross origin", func(t *testing.T) {
get("/cross")

require.Equal(t, "Bearer SECRET-CATALOG-TOKEN", first.auth, "the configured origin must still be authenticated")
require.Equal(t, "SECRET-API-KEY", first.apiKey)
require.Equal(t, "SECRET-CUSTOM", first.custom)

require.True(t, other.hit, "the redirect target must be reached")
assert.Empty(t, other.auth, "the redirect target must not receive the bearer token")
assert.Empty(t, other.apiKey, "the redirect target must not receive header.* defaults")
assert.Empty(t, other.custom, "the redirect target must not receive WithHeaders defaults")
assert.Equal(t, "GoIceberg/"+iceberg.Version(), other.agent, "built-in client headers are still sent")
})

t.Run("same origin", func(t *testing.T) {
get("/same")

require.True(t, sameLanding.hit)
assert.Equal(t, "Bearer SECRET-CATALOG-TOKEN", sameLanding.auth)
assert.Equal(t, "SECRET-API-KEY", sameLanding.apiKey)
assert.Equal(t, "SECRET-CUSTOM", sameLanding.custom)
})
}

// TestOverriddenBuiltinHeadersSentOnCrossOriginRedirect pins that a
// cross-origin hop receives the operator's value for a built-in header it
// overrode, not the built-in default.
func TestOverriddenBuiltinHeadersSentOnCrossOriginRedirect(t *testing.T) {
var otherHit bool
var otherAgent, otherDelegation, otherCustom string
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
otherHit = true
otherAgent = r.Header.Get("User-Agent")
otherDelegation = r.Header.Get(headerIcebergAccessDelegation)
otherCustom = r.Header.Get("X-Custom")
w.WriteHeader(http.StatusOK)
}))
defer second.Close()

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("/cross", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, second.URL+"/landing", http.StatusTemporaryRedirect)
})
srv := httptest.NewServer(mux)
defer srv.Close()

cat, err := NewCatalog(context.Background(), "rest", srv.URL,
WithHeaders(map[string]string{"User-Agent": "corp-agent/1", "X-Custom": "SECRET-CUSTOM"}),
WithAdditionalProps(iceberg.Properties{"header." + headerIcebergAccessDelegation: "remote-signing"}))
require.NoError(t, err)

req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/cross", nil)
require.NoError(t, err)
resp, err := cat.cl.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())

require.True(t, otherHit, "the redirect target must be reached")
assert.Equal(t, "corp-agent/1", otherAgent)
assert.Equal(t, "remote-signing", otherDelegation)
assert.Empty(t, otherCustom, "the redirect target must not receive WithHeaders defaults")
}

// TestCredentialsNotSentOnSyntheticRedirect pins that the redirect guard does
// not depend on the transport linking Response.Request: a custom transport that
// returns a bare 307 must not cause credentials to follow it.
func TestCredentialsNotSentOnSyntheticRedirect(t *testing.T) {
var otherHit bool
var otherAuth, otherAPIKey string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.URL.Host == "other.test":
otherHit = true
otherAuth = r.Header.Get("Authorization")
otherAPIKey = r.Header.Get("X-Api-Key")

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"),
WithAdditionalProps(iceberg.Properties{"header.X-Api-Key": "SECRET-API-KEY"}))
require.NoError(t, err)

req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://catalog.test/redirect", nil)
require.NoError(t, err)
resp, err := cat.cl.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())

require.True(t, otherHit, "the redirect target must be reached")
assert.Empty(t, otherAuth, "the redirect target must not receive the bearer token")
assert.Empty(t, otherAPIKey, "the redirect target must not receive header.* defaults")
}

// 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) {
cat := &Catalog{}

Expand Down
Loading