Skip to content

Commit 5c585f7

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 marking cross-host redirects in the request context inside CheckRedirect, then having each credential round-tripper (bearer, basic auth, OAuth2) skip adding credentials for marked requests. A new sensitiveHeadersStripRT also strips sensitive headers added by headersRoundTripper on cross-host redirects. This aligns to Go's HTTP client behaviour. Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com>
1 parent 0dfcdfb commit 5c585f7

3 files changed

Lines changed: 327 additions & 4 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: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -608,10 +608,14 @@ func NewClientFromConfig(cfg HTTPClientConfig, name string, optFuncs ...HTTPClie
608608
return nil, err
609609
}
610610
client := newClient(rt)
611-
if !cfg.FollowRedirects {
612-
client.CheckRedirect = func(*http.Request, []*http.Request) error {
611+
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
612+
if !cfg.FollowRedirects {
613613
return http.ErrUseLastResponse
614614
}
615+
if len(via) > 0 && !shouldSendCredentialsOnRedirect(via[0].URL, req.URL) {
616+
*req = *req.WithContext(context.WithValue(req.Context(), crossHostRedirectKey{}, true))
617+
}
618+
return nil
615619
}
616620
return client, nil
617621
}
@@ -721,6 +725,11 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
721725
}
722726

723727
if cfg.HTTPHeaders != nil {
728+
// Strip sensitive headers added by headersRoundTripper on cross-host
729+
// redirects before they reach the transport.
730+
if cfg.FollowRedirects {
731+
rt = &sensitiveHeadersStripRT{next: rt}
732+
}
724733
rt = NewHeadersRoundTripper(cfg.HTTPHeaders, rt)
725734
}
726735

@@ -862,7 +871,7 @@ func NewAuthorizationCredentialsRoundTripper(authType string, authCredentials Se
862871
}
863872

864873
func (rt *authorizationCredentialsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
865-
if len(req.Header.Get("Authorization")) != 0 {
874+
if len(req.Header.Get("Authorization")) != 0 || isCrossHostRedirect(req) {
866875
return rt.rt.RoundTrip(req)
867876
}
868877

@@ -900,7 +909,7 @@ func NewBasicAuthRoundTripper(username, password SecretReader, rt http.RoundTrip
900909
}
901910

902911
func (rt *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
903-
if len(req.Header.Get("Authorization")) != 0 {
912+
if len(req.Header.Get("Authorization")) != 0 || isCrossHostRedirect(req) {
904913
return rt.rt.RoundTrip(req)
905914
}
906915
var username string
@@ -1085,6 +1094,9 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
10851094
rt.mtx.RLock()
10861095
currentRT := rt.lastRT
10871096
rt.mtx.RUnlock()
1097+
if isCrossHostRedirect(req) {
1098+
return currentRT.Base.RoundTrip(req)
1099+
}
10881100
return currentRT.RoundTrip(req)
10891101
}
10901102

@@ -1106,6 +1118,78 @@ func mapToValues(m map[string]string) url.Values {
11061118
return v
11071119
}
11081120

1121+
// crossHostRedirectKey is the context key used to mark cross-host redirects.
1122+
type crossHostRedirectKey struct{}
1123+
1124+
// isCrossHostRedirect reports whether req was marked as a cross-host redirect
1125+
// by the CheckRedirect handler.
1126+
func isCrossHostRedirect(req *http.Request) bool {
1127+
return req.Context().Value(crossHostRedirectKey{}) != nil
1128+
}
1129+
1130+
// sensitiveHeadersOnRedirect lists the headers that must not be forwarded when
1131+
// following a redirect to a different host, mirroring the list in
1132+
// makeHeadersCopier in net/http/client.go.
1133+
var sensitiveHeadersOnRedirect = map[string]struct{}{
1134+
"Authorization": {},
1135+
"Www-Authenticate": {},
1136+
"Cookie": {},
1137+
"Cookie2": {},
1138+
"Proxy-Authorization": {},
1139+
"Proxy-Authenticate": {},
1140+
}
1141+
1142+
// sensitiveHeadersStripRT strips sensitive headers from requests marked as
1143+
// cross-host redirects before passing them to the underlying transport.
1144+
type sensitiveHeadersStripRT struct {
1145+
next http.RoundTripper
1146+
}
1147+
1148+
func (rt *sensitiveHeadersStripRT) RoundTrip(req *http.Request) (*http.Response, error) {
1149+
if isCrossHostRedirect(req) {
1150+
req = cloneRequest(req)
1151+
for h := range sensitiveHeadersOnRedirect {
1152+
req.Header.Del(h)
1153+
}
1154+
}
1155+
return rt.next.RoundTrip(req)
1156+
}
1157+
1158+
func (rt *sensitiveHeadersStripRT) CloseIdleConnections() {
1159+
if ci, ok := rt.next.(closeIdler); ok {
1160+
ci.CloseIdleConnections()
1161+
}
1162+
}
1163+
1164+
// shouldSendCredentialsOnRedirect reports whether credentials from a request
1165+
// to initial should be forwarded when redirecting to dest. It mirrors the
1166+
// logic in shouldCopyHeaderOnRedirect from net/http/client.go: credentials
1167+
// are forwarded when dest is the same host as, or a subdomain of, initial.
1168+
// Port is not considered, matching Go's standard library behaviour.
1169+
func shouldSendCredentialsOnRedirect(initial, dest *url.URL) bool {
1170+
ihost := strings.ToLower(initial.Hostname())
1171+
dhost := strings.ToLower(dest.Hostname())
1172+
return isDomainOrSubdomain(dhost, ihost)
1173+
}
1174+
1175+
// isDomainOrSubdomain reports whether sub is a subdomain (or exact match) of
1176+
// parent. It mirrors isDomainOrSubdomain from net/http/client.go.
1177+
func isDomainOrSubdomain(sub, parent string) bool {
1178+
if sub == parent {
1179+
return true
1180+
}
1181+
// A colon means sub is an IPv6 address; a percent sign introduces an IPv6
1182+
// zone ID. Neither can be a hostname, and both could otherwise pass the
1183+
// suffix check below (e.g. "::1%.www.example.com" ends with "example.com").
1184+
if strings.ContainsAny(sub, ":%") {
1185+
return false
1186+
}
1187+
if !strings.HasSuffix(sub, parent) {
1188+
return false
1189+
}
1190+
return sub[len(sub)-len(parent)-1] == '.'
1191+
}
1192+
11091193
// cloneRequest returns a clone of the provided *http.Request.
11101194
// The clone is a shallow copy of the struct and its Header map.
11111195
func cloneRequest(r *http.Request) *http.Request {

config/http_config_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,130 @@ func TestDefaultFollowRedirect(t *testing.T) {
13801380
}
13811381
}
13821382

1383+
func TestCrossHostRedirectDropsCredentials(t *testing.T) {
1384+
for _, tc := range []struct {
1385+
name string
1386+
config HTTPClientConfig
1387+
}{
1388+
{
1389+
name: "bearer token",
1390+
config: HTTPClientConfig{
1391+
FollowRedirects: true,
1392+
Authorization: &Authorization{
1393+
Type: "Bearer",
1394+
Credentials: "secret-token",
1395+
},
1396+
},
1397+
},
1398+
{
1399+
name: "basic auth",
1400+
config: HTTPClientConfig{
1401+
FollowRedirects: true,
1402+
BasicAuth: &BasicAuth{
1403+
Username: "user",
1404+
Password: "pass",
1405+
},
1406+
},
1407+
},
1408+
} {
1409+
t.Run(tc.name, func(t *testing.T) {
1410+
// target listens on 127.0.0.1 but origin redirects using "localhost"
1411+
// as the hostname. "127.0.0.1" and "localhost" are different hostname
1412+
// strings, so Go's redirect rules strip credentials on the redirect.
1413+
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1414+
if r.Header.Get("Authorization") != "" {
1415+
http.Error(w, "credentials leaked to cross-host redirect target", http.StatusForbidden)
1416+
return
1417+
}
1418+
fmt.Fprint(w, ExpectedMessage)
1419+
}))
1420+
t.Cleanup(target.Close)
1421+
1422+
// Build a redirect URL that uses "localhost" instead of "127.0.0.1".
1423+
targetPort := target.Listener.Addr().(*net.TCPAddr).Port
1424+
targetLocalhostURL := fmt.Sprintf("http://localhost:%d", targetPort)
1425+
1426+
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1427+
http.Redirect(w, r, targetLocalhostURL+r.URL.Path, http.StatusFound)
1428+
}))
1429+
t.Cleanup(origin.Close)
1430+
1431+
client, err := NewClientFromConfig(tc.config, "test")
1432+
require.NoError(t, err)
1433+
1434+
resp, err := client.Get(origin.URL)
1435+
require.NoError(t, err)
1436+
defer resp.Body.Close()
1437+
1438+
body, err := io.ReadAll(resp.Body)
1439+
require.NoError(t, err)
1440+
require.Equal(t, ExpectedMessage, strings.TrimSpace(string(body)))
1441+
})
1442+
}
1443+
}
1444+
1445+
func TestIsDomainOrSubdomain(t *testing.T) {
1446+
for _, tc := range []struct {
1447+
sub, parent string
1448+
want bool
1449+
}{
1450+
{"example.com", "example.com", true},
1451+
{"sub.example.com", "example.com", true},
1452+
{"deep.sub.example.com", "example.com", true},
1453+
{"notexample.com", "example.com", false},
1454+
{"example.com", "sub.example.com", false},
1455+
{"bar.com", "foo.com", false},
1456+
{"127.0.0.1", "127.0.0.1", true},
1457+
{"localhost", "127.0.0.1", false},
1458+
{"127.0.0.1", "localhost", false},
1459+
{"::1", "::1", true},
1460+
{"::2", "::1", false},
1461+
{"::1", "example.com", false},
1462+
// Zone ID containing a hostname must not match as a subdomain.
1463+
{"::1%.www.example.com", "example.com", false},
1464+
{"fe80::1%eth0", "eth0", false},
1465+
} {
1466+
t.Run(tc.sub+"→"+tc.parent, func(t *testing.T) {
1467+
require.Equal(t, tc.want, isDomainOrSubdomain(tc.sub, tc.parent))
1468+
})
1469+
}
1470+
}
1471+
1472+
func TestSameHostRedirectKeepsCredentials(t *testing.T) {
1473+
credsSeen := false
1474+
mux := http.NewServeMux()
1475+
mux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) {
1476+
http.Redirect(w, r, "/end", http.StatusFound)
1477+
})
1478+
mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
1479+
if r.Header.Get("Authorization") != "" {
1480+
credsSeen = true
1481+
}
1482+
fmt.Fprint(w, ExpectedMessage)
1483+
})
1484+
server := httptest.NewServer(mux)
1485+
t.Cleanup(server.Close)
1486+
1487+
cfg := HTTPClientConfig{
1488+
FollowRedirects: true,
1489+
Authorization: &Authorization{
1490+
Type: "Bearer",
1491+
Credentials: "secret-token",
1492+
},
1493+
}
1494+
client, err := NewClientFromConfig(cfg, "test")
1495+
require.NoError(t, err)
1496+
1497+
resp, err := client.Get(server.URL + "/start")
1498+
require.NoError(t, err)
1499+
defer resp.Body.Close()
1500+
1501+
body, err := io.ReadAll(resp.Body)
1502+
require.NoError(t, err)
1503+
require.Equal(t, ExpectedMessage, strings.TrimSpace(string(body)))
1504+
require.Truef(t, credsSeen, "credentials should be forwarded on same-host redirect")
1505+
}
1506+
13831507
func TestValidateHTTPConfig(t *testing.T) {
13841508
cfg, _, err := LoadHTTPConfigFile("testdata/http.conf.good.yml")
13851509
if err != nil {

0 commit comments

Comments
 (0)