Skip to content

Commit 0fcda47

Browse files
authored
Merge pull request #920 from roidelapluie/roidelapluie/cross-host-sticky
config: make isCrossHostRedirect sticky across the redirect chain
2 parents 30ba470 + 428856f commit 0fcda47

2 files changed

Lines changed: 67 additions & 7 deletions

File tree

config/http_config.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,18 +1135,29 @@ func mapToValues(m map[string]string) url.Values {
11351135
return v
11361136
}
11371137

1138-
// isCrossHostRedirect reports whether req is a redirect to a different host
1139-
// than the original request. It detects this by walking the req.Response chain
1140-
// (which Go's HTTP client populates on every redirect hop) to find the original
1141-
// request's hostname, then comparing it to the current destination.
1138+
// isCrossHostRedirect reports whether req is a redirect that has left the
1139+
// original request's host at any point in the chain. It detects this by walking
1140+
// the req.Response chain (which Go's HTTP client populates on every redirect
1141+
// hop) to find the original request's hostname, then checking every hop in the
1142+
// chain against it.
1143+
//
1144+
// The decision is sticky, mirroring net/http: once any hop leaves the original
1145+
// host's domain, credentials and sensitive headers stay stripped for the rest
1146+
// of the chain, even if a later hop redirects back to the original host.
1147+
//
11421148
// This works regardless of whether the caller uses NewClientFromConfig or a
11431149
// custom http.Client built from NewRoundTripperFromConfigWithContext directly.
11441150
func isCrossHostRedirect(req *http.Request) bool {
11451151
if req.Response == nil {
11461152
return false
11471153
}
11481154
originalHost := strings.ToLower(originalRequestHost(req))
1149-
return !isDomainOrSubdomain(strings.ToLower(req.URL.Hostname()), originalHost)
1155+
for r := req; r.Response != nil && r.Response.Request != nil; r = r.Response.Request {
1156+
if !isDomainOrSubdomain(strings.ToLower(r.URL.Hostname()), originalHost) {
1157+
return true
1158+
}
1159+
}
1160+
return false
11501161
}
11511162

11521163
func originalRequestHost(req *http.Request) string {
@@ -1158,8 +1169,10 @@ func originalRequestHost(req *http.Request) string {
11581169
}
11591170

11601171
// sensitiveHeadersOnRedirect lists the headers that must not be forwarded when
1161-
// following a redirect to a different host, mirroring the list in
1162-
// makeHeadersCopier in net/http/client.go.
1172+
// following a redirect to a different host. The first four entries match the
1173+
// list stripped by makeHeadersCopier in net/http/client.go; we additionally
1174+
// strip the Proxy-* headers, which net/http does not, to avoid leaking proxy
1175+
// credentials to an untrusted host.
11631176
var sensitiveHeadersOnRedirect = map[string]struct{}{
11641177
"Authorization": {},
11651178
// "Www-Authenticate" is the canonical form produced by

config/http_config_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,6 +1509,53 @@ func TestSameHostRedirectKeepsCredentials(t *testing.T) {
15091509
require.Truef(t, credsSeen, "credentials should be forwarded on same-host redirect")
15101510
}
15111511

1512+
func TestRedirectBackToOriginalHostKeepsCredentialsStripped(t *testing.T) {
1513+
// Mirror net/http's sticky behaviour: once a redirect chain leaves the
1514+
// original host, credentials must stay stripped for the rest of the chain,
1515+
// even when a later hop redirects back to the original host.
1516+
credsSeen := false
1517+
final := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1518+
if r.Header.Get("Authorization") != "" {
1519+
credsSeen = true
1520+
}
1521+
fmt.Fprint(w, ExpectedMessage)
1522+
}))
1523+
t.Cleanup(final.Close)
1524+
1525+
// middle listens on 127.0.0.1 but is reached via "localhost", so the hop
1526+
// from the origin (127.0.0.1) to middle is cross-host. It then redirects
1527+
// back to final, which shares the original "127.0.0.1" hostname.
1528+
middle := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1529+
http.Redirect(w, r, final.URL, http.StatusFound)
1530+
}))
1531+
t.Cleanup(middle.Close)
1532+
middlePort := middle.Listener.Addr().(*net.TCPAddr).Port
1533+
middleLocalhostURL := fmt.Sprintf("http://localhost:%d", middlePort)
1534+
1535+
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1536+
http.Redirect(w, r, middleLocalhostURL, http.StatusFound)
1537+
}))
1538+
t.Cleanup(origin.Close)
1539+
1540+
cfg := HTTPClientConfig{
1541+
FollowRedirects: true,
1542+
Authorization: &Authorization{
1543+
Type: "Bearer",
1544+
Credentials: "secret-token",
1545+
},
1546+
}
1547+
client, err := NewClientFromConfig(cfg, "test")
1548+
require.NoError(t, err)
1549+
1550+
resp, err := client.Get(origin.URL)
1551+
require.NoError(t, err)
1552+
defer resp.Body.Close()
1553+
_, err = io.ReadAll(resp.Body)
1554+
require.NoError(t, err)
1555+
1556+
require.Falsef(t, credsSeen, "credentials must stay stripped after leaving the original host, even when redirected back to it")
1557+
}
1558+
15121559
func TestRoundTripperCrossHostRedirectDropsCredentials(t *testing.T) {
15131560
// Verify that a custom http.Client built from NewRoundTripperFromConfig
15141561
// (not NewClientFromConfig) also strips credentials on cross-host redirects,

0 commit comments

Comments
 (0)