laskoviymishka commented on code in PR #2098:
URL: https://github.com/apache/iceberg-go/pull/2098#discussion_r4195553909


##########
catalog/rest/rest.go:
##########
@@ -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
+       // 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)

Review Comment:
   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.



##########
catalog/rest/rest.go:
##########
@@ -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 {

Review Comment:
   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.



##########
catalog/rest/rest.go:
##########
@@ -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,

Review Comment:
   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.



##########
catalog/rest/rest.go:
##########
@@ -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,
+               authOrigin:     opts.authUri,

Review Comment:
   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.



##########
catalog/rest/rest.go:
##########
@@ -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} {
+               if v := session.defaultHeaders.Values(k); len(v) > 0 {
+                       session.builtinHeaders[http.CanonicalHeaderKey(k)] = v

Review Comment:
   `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:
   
   ```go
   session.builtinHeaders[http.CanonicalHeaderKey(k)] = slices.Clone(v)
   ```



##########
catalog/rest/rest.go:
##########
@@ -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

Review Comment:
   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.



##########
catalog/rest/rest.go:
##########
@@ -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} {

Review Comment:
   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.



##########
catalog/rest/rest_internal_test.go:
##########
@@ -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) {

Review Comment:
   `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()`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to