Skip to content

Commit 0d8de87

Browse files
committed
config: strip credentials on cross-host redirects
When FollowRedirects is true, credentials (Authorization header, Cookie headers set via HTTPHeaders) were forwarded to any redirect target, including cross-host redirects. Fix by detecting cross-host redirects via isCrossHostRedirect, which walks the req.Response chain to find the original request's hostname and compares it to the current destination. Each credential round-tripper (bearer, basic auth, OAuth2) skips adding credentials when isCrossHostRedirect returns true. A new sensitiveHeadersStripRT also strips sensitive headers added by headersRoundTripper on cross-host redirects. This approach requires no CheckRedirect hook and works whether the caller uses NewClientFromConfig or a custom http.Client built from NewRoundTripperFromConfigWithContext directly. This aligns to Go's HTTP client behaviour. Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com>
1 parent 0dfcdfb commit 0d8de87

3 files changed

Lines changed: 525 additions & 2 deletions

File tree

config/headers_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@
1717
package config
1818

1919
import (
20+
"fmt"
21+
"io"
22+
"net"
2023
"net/http"
24+
"net/http/httptest"
25+
"strings"
2126
"testing"
27+
28+
"github.com/stretchr/testify/require"
2229
)
2330

2431
func TestReservedHeaders(t *testing.T) {
@@ -29,3 +36,111 @@ func TestReservedHeaders(t *testing.T) {
2936
}
3037
}
3138
}
39+
40+
func TestHeadersRoundTripperSameHost(t *testing.T) {
41+
// All headers, including sensitive ones, must be forwarded on same-host requests.
42+
for _, header := range []string{"Cookie", "X-Custom-Header"} {
43+
t.Run(header, func(t *testing.T) {
44+
received := ""
45+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+
received = r.Header.Get(header)
47+
fmt.Fprint(w, "ok")
48+
}))
49+
t.Cleanup(server.Close)
50+
51+
headers := &Headers{
52+
Headers: map[string]Header{
53+
header: {Values: []string{"testvalue"}},
54+
},
55+
}
56+
rt := NewHeadersRoundTripper(headers, http.DefaultTransport)
57+
58+
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
59+
require.NoError(t, err)
60+
61+
resp, err := rt.RoundTrip(req)
62+
require.NoError(t, err)
63+
defer resp.Body.Close()
64+
body, err := io.ReadAll(resp.Body)
65+
require.NoError(t, err)
66+
require.Equal(t, "ok", strings.TrimSpace(string(body)))
67+
require.Equalf(t, "testvalue", received, "header %q must be forwarded on same-host request", header)
68+
})
69+
}
70+
}
71+
72+
func TestHeadersRoundTripperCrossHostRedirect(t *testing.T) {
73+
// Cookie must be set on the initial request but stripped on cross-host redirects.
74+
cookieOnRedirect := ""
75+
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
cookieOnRedirect = r.Header.Get("Cookie")
77+
fmt.Fprint(w, "ok")
78+
}))
79+
t.Cleanup(target.Close)
80+
81+
// Use "localhost" as the redirect target hostname so that it differs from
82+
// "127.0.0.1" used by the origin server, making it a cross-host redirect.
83+
targetPort := target.Listener.Addr().(*net.TCPAddr).Port
84+
targetURL := fmt.Sprintf("http://localhost:%d", targetPort)
85+
86+
cookieOnOrigin := ""
87+
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+
cookieOnOrigin = r.Header.Get("Cookie")
89+
http.Redirect(w, r, targetURL, http.StatusFound)
90+
}))
91+
t.Cleanup(origin.Close)
92+
93+
cfg := HTTPClientConfig{
94+
FollowRedirects: true,
95+
HTTPHeaders: &Headers{
96+
Headers: map[string]Header{
97+
"Cookie": {Values: []string{"session=abc"}},
98+
},
99+
},
100+
}
101+
client, err := NewClientFromConfig(cfg, "test")
102+
require.NoError(t, err)
103+
104+
resp, err := client.Get(origin.URL)
105+
require.NoError(t, err)
106+
defer resp.Body.Close()
107+
_, err = io.ReadAll(resp.Body)
108+
require.NoError(t, err)
109+
110+
require.Equalf(t, "session=abc", cookieOnOrigin, "Cookie must be set on the initial request.")
111+
require.Emptyf(t, cookieOnRedirect, "Cookie must not be forwarded on a cross-host redirect.")
112+
}
113+
114+
func TestHeadersRoundTripperSameHostRedirect(t *testing.T) {
115+
// Cookie must be forwarded on same-host redirects.
116+
mux := http.NewServeMux()
117+
cookieOnRedirect := ""
118+
mux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) {
119+
http.Redirect(w, r, "/end", http.StatusFound)
120+
})
121+
mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
122+
cookieOnRedirect = r.Header.Get("Cookie")
123+
fmt.Fprint(w, "ok")
124+
})
125+
server := httptest.NewServer(mux)
126+
t.Cleanup(server.Close)
127+
128+
cfg := HTTPClientConfig{
129+
FollowRedirects: true,
130+
HTTPHeaders: &Headers{
131+
Headers: map[string]Header{
132+
"Cookie": {Values: []string{"session=abc"}},
133+
},
134+
},
135+
}
136+
client, err := NewClientFromConfig(cfg, "test")
137+
require.NoError(t, err)
138+
139+
resp, err := client.Get(server.URL + "/start")
140+
require.NoError(t, err)
141+
defer resp.Body.Close()
142+
_, err = io.ReadAll(resp.Body)
143+
require.NoError(t, err)
144+
145+
require.Equalf(t, "session=abc", cookieOnRedirect, "Cookie must be forwarded on a same-host redirect.")
146+
}

config/http_config.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,14 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
721721
}
722722

723723
if cfg.HTTPHeaders != nil {
724+
// Strip sensitive headers added by headersRoundTripper on cross-host
725+
// redirects before they reach the transport. Only needed when
726+
// redirects are actually followed; when FollowRedirects is false
727+
// CheckRedirect returns ErrUseLastResponse immediately so there are
728+
// no subsequent requests.
729+
if cfg.FollowRedirects {
730+
rt = &sensitiveHeadersStripRT{next: rt}
731+
}
724732
rt = NewHeadersRoundTripper(cfg.HTTPHeaders, rt)
725733
}
726734

@@ -862,7 +870,7 @@ func NewAuthorizationCredentialsRoundTripper(authType string, authCredentials Se
862870
}
863871

864872
func (rt *authorizationCredentialsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
865-
if len(req.Header.Get("Authorization")) != 0 {
873+
if len(req.Header.Get("Authorization")) != 0 || isCrossHostRedirect(req) {
866874
return rt.rt.RoundTrip(req)
867875
}
868876

@@ -900,7 +908,7 @@ func NewBasicAuthRoundTripper(username, password SecretReader, rt http.RoundTrip
900908
}
901909

902910
func (rt *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
903-
if len(req.Header.Get("Authorization")) != 0 {
911+
if len(req.Header.Get("Authorization")) != 0 || isCrossHostRedirect(req) {
904912
return rt.rt.RoundTrip(req)
905913
}
906914
var username string
@@ -1085,6 +1093,9 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
10851093
rt.mtx.RLock()
10861094
currentRT := rt.lastRT
10871095
rt.mtx.RUnlock()
1096+
if isCrossHostRedirect(req) {
1097+
return currentRT.Base.RoundTrip(req)
1098+
}
10881099
return currentRT.RoundTrip(req)
10891100
}
10901101

@@ -1106,6 +1117,85 @@ func mapToValues(m map[string]string) url.Values {
11061117
return v
11071118
}
11081119

1120+
// isCrossHostRedirect reports whether req is a redirect to a different host
1121+
// than the original request. It detects this by walking the req.Response chain
1122+
// (which Go's HTTP client populates on every redirect hop) to find the original
1123+
// request's hostname, then comparing it to the current destination.
1124+
// This works regardless of whether the caller uses NewClientFromConfig or a
1125+
// custom http.Client built from NewRoundTripperFromConfigWithContext directly.
1126+
func isCrossHostRedirect(req *http.Request) bool {
1127+
if req.Response == nil {
1128+
return false
1129+
}
1130+
originalHost := strings.ToLower(originalRequestHost(req))
1131+
return !isDomainOrSubdomain(strings.ToLower(req.URL.Hostname()), originalHost)
1132+
}
1133+
1134+
func originalRequestHost(req *http.Request) string {
1135+
r := req
1136+
for r.Response != nil && r.Response.Request != nil {
1137+
r = r.Response.Request
1138+
}
1139+
return r.URL.Hostname()
1140+
}
1141+
1142+
// sensitiveHeadersOnRedirect lists the headers that must not be forwarded when
1143+
// following a redirect to a different host, mirroring the list in
1144+
// makeHeadersCopier in net/http/client.go.
1145+
var sensitiveHeadersOnRedirect = map[string]struct{}{
1146+
"Authorization": {},
1147+
// "Www-Authenticate" is the canonical form produced by
1148+
// textproto.CanonicalMIMEHeaderKey; it is not a typo of "WWW-Authenticate".
1149+
"Www-Authenticate": {},
1150+
"Cookie": {},
1151+
"Cookie2": {},
1152+
"Proxy-Authorization": {},
1153+
"Proxy-Authenticate": {},
1154+
}
1155+
1156+
// sensitiveHeadersStripRT strips sensitive headers from requests marked as
1157+
// cross-host redirects before passing them to the underlying transport.
1158+
type sensitiveHeadersStripRT struct {
1159+
next http.RoundTripper
1160+
}
1161+
1162+
func (rt *sensitiveHeadersStripRT) RoundTrip(req *http.Request) (*http.Response, error) {
1163+
if isCrossHostRedirect(req) {
1164+
req = cloneRequest(req)
1165+
for h := range sensitiveHeadersOnRedirect {
1166+
req.Header.Del(h)
1167+
}
1168+
}
1169+
return rt.next.RoundTrip(req)
1170+
}
1171+
1172+
func (rt *sensitiveHeadersStripRT) CloseIdleConnections() {
1173+
if ci, ok := rt.next.(closeIdler); ok {
1174+
ci.CloseIdleConnections()
1175+
}
1176+
}
1177+
1178+
// isDomainOrSubdomain reports whether sub is a subdomain (or exact match) of
1179+
// parent. It mirrors isDomainOrSubdomain from net/http/client.go.
1180+
func isDomainOrSubdomain(sub, parent string) bool {
1181+
if parent == "" {
1182+
return false
1183+
}
1184+
if sub == parent {
1185+
return true
1186+
}
1187+
// A colon means sub is an IPv6 address; a percent sign introduces an IPv6
1188+
// zone ID. Neither can be a hostname, and both could otherwise pass the
1189+
// suffix check below (e.g. "::1%.www.example.com" ends with "example.com").
1190+
if strings.ContainsAny(sub, ":%") {
1191+
return false
1192+
}
1193+
if !strings.HasSuffix(sub, parent) {
1194+
return false
1195+
}
1196+
return sub[len(sub)-len(parent)-1] == '.'
1197+
}
1198+
11091199
// cloneRequest returns a clone of the provided *http.Request.
11101200
// The clone is a shallow copy of the struct and its Header map.
11111201
func cloneRequest(r *http.Request) *http.Request {

0 commit comments

Comments
 (0)