From 0ee17f025d334624c4d6463699480935d436a18d Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 7 May 2026 12:21:10 +0545 Subject: [PATCH 1/2] fix(auth/httptransport): prevent bearer token leakage on cross-host redirects authTransport.RoundTrip unconditionally injects the Authorization header on every call, including intermediate calls http.Client makes when following redirects. Go's http.Client strips Authorization before building a cross-host redirect request, but authTransport immediately re-adds the token, forwarding it to every host in the redirect chain. Any code using NewClient or AddAuthorizationMiddleware that follows a cross-host redirect (e.g. a webhook caller, an API proxy, or code that accepts user-supplied URLs) leaks its OAuth bearer token to the redirect destination. An attacker who controls a redirect via an open redirect on a Google API endpoint or a user-controlled URL can steal the token. Fix: check req.Response in RoundTrip. When req.Response is non-nil, this call is a redirect. If the current URL host differs from the previous hop's host, skip auth injection and delegate directly to the base transport. Adds two tests: one verifying tokens are not forwarded to a different host, and one verifying same-host redirects continue to be authorized. --- auth/httptransport/transport.go | 13 ++++ auth/httptransport/transport_test.go | 88 ++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/auth/httptransport/transport.go b/auth/httptransport/transport.go index 87b3fef21876..30c9a572d67e 100644 --- a/auth/httptransport/transport.go +++ b/auth/httptransport/transport.go @@ -528,6 +528,19 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { } }() } + + // Prevent bearer token leakage on cross-host redirects. Go's http.Client + // strips the Authorization header before calling the RoundTripper for a + // redirect to a different host, but this transport would unconditionally + // re-inject it. If req.Response is set, this call is a redirect; skip + // auth injection when the destination host differs from the previous hop. + if prev := req.Response; prev != nil && prev.Request != nil { + if prev.Request.URL.Host != req.URL.Host { + reqBodyClosed = true + return t.base.RoundTrip(req.Clone(req.Context())) + } + } + token, err := t.creds.Token(req.Context()) if err != nil { return nil, err diff --git a/auth/httptransport/transport_test.go b/auth/httptransport/transport_test.go index 8b92087f820b..7eb039650b37 100644 --- a/auth/httptransport/transport_test.go +++ b/auth/httptransport/transport_test.go @@ -15,8 +15,11 @@ package httptransport import ( + "net/http" + "net/http/httptest" "testing" + "cloud.google.com/go/auth" "cloud.google.com/go/auth/internal" ) @@ -65,3 +68,88 @@ func TestAuthTransport_GetClientUniverseDomain(t *testing.T) { }) } } + +// TestAuthTransport_CrossHostRedirectDoesNotLeakToken verifies that the +// authTransport does not forward bearer tokens to a different host when +// following a redirect. An attacker-controlled redirect (e.g. via an open +// redirect on the target API) must not receive the caller's credentials. +func TestAuthTransport_CrossHostRedirectDoesNotLeakToken(t *testing.T) { + const token = "super-secret-token" + + // victim receives requests and records whether the auth header was present. + var victimSawToken bool + victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + victimSawToken = true + } + w.WriteHeader(http.StatusOK) + })) + defer victim.Close() + + // redirector redirects all requests to the victim (different host). + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, victim.URL+"/", http.StatusFound) + })) + defer redirector.Close() + + creds := auth.NewCredentials(&auth.CredentialsOptions{ + TokenProvider: staticTP(token), + }) + client := &http.Client{ + Transport: &authTransport{ + creds: creds, + base: http.DefaultTransport, + }, + } + + resp, err := client.Get(redirector.URL + "/resource") + if err != nil { + t.Fatalf("GET: %v", err) + } + resp.Body.Close() + + if victimSawToken { + t.Error("bearer token was leaked to cross-host redirect destination") + } +} + +// TestAuthTransport_SameHostRedirectIsAuthorized verifies that same-host +// redirects continue to carry the bearer token. +func TestAuthTransport_SameHostRedirectIsAuthorized(t *testing.T) { + const token = "super-secret-token" + + var finalSawToken bool + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + + mux.HandleFunc("/final", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer "+token { + finalSawToken = true + } + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, srv.URL+"/final", http.StatusFound) + }) + + creds := auth.NewCredentials(&auth.CredentialsOptions{ + TokenProvider: staticTP(token), + }) + client := &http.Client{ + Transport: &authTransport{ + creds: creds, + base: http.DefaultTransport, + }, + } + + resp, err := client.Get(srv.URL + "/redirect") + if err != nil { + t.Fatalf("GET: %v", err) + } + resp.Body.Close() + + if !finalSawToken { + t.Error("bearer token was not forwarded to same-host redirect destination") + } +} From 6cc18b51ae6077dd59667f0ebb37e6cffd667d53 Mon Sep 17 00:00:00 2001 From: evilgensec Date: Thu, 7 May 2026 21:11:20 +0545 Subject: [PATCH 2/2] Update auth/httptransport/transport.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- auth/httptransport/transport.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/httptransport/transport.go b/auth/httptransport/transport.go index 30c9a572d67e..f8526e10acc8 100644 --- a/auth/httptransport/transport.go +++ b/auth/httptransport/transport.go @@ -535,9 +535,9 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { // re-inject it. If req.Response is set, this call is a redirect; skip // auth injection when the destination host differs from the previous hop. if prev := req.Response; prev != nil && prev.Request != nil { - if prev.Request.URL.Host != req.URL.Host { + if !strings.EqualFold(prev.Request.URL.Host, req.URL.Host) { reqBodyClosed = true - return t.base.RoundTrip(req.Clone(req.Context())) + return t.base.RoundTrip(req) } }