Skip to content

Commit ebe2dd0

Browse files
authored
Merge commit from fork
* Strip custom-named credential headers on cross-host redirect The proxy channel for the messages-format provider sets a custom-named x-api-key header carrying the operator's real upstream key. The manager's http.Client followed redirects with no CheckRedirect; net/http strips the standard Authorization header on a cross-host redirect but not custom-named ones, so a cross-host 3xx from an operator-configured upstream leaked the upstream key to the redirect target. Add a CheckRedirect policy that strips credential headers on host change and a regression test. Signed-off-by: tonghuaroot <tonghuaroot@gmail.com> * Add regression test for cross-host proxy header leak Signed-off-by: tonghuaroot <tonghuaroot@gmail.com> --------- Signed-off-by: tonghuaroot <tonghuaroot@gmail.com>
1 parent 275cc50 commit ebe2dd0

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

internal/httpclient/manager.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,47 @@ func (m *HTTPClientManager) GetClient(config *Config) *http.Client {
9999
}
100100

101101
newClient := &http.Client{
102-
Transport: transport,
103-
Timeout: config.RequestTimeout,
102+
Transport: transport,
103+
Timeout: config.RequestTimeout,
104+
CheckRedirect: stripSensitiveOnCrossHostRedirect,
104105
}
105106

106107
m.clients[fingerprint] = newClient
107108
return newClient
108109
}
109110

111+
// sensitiveProxyHeaders are custom-named credential headers that proxy channels
112+
// attach to upstream requests (e.g. x-api-key set by the messages-format
113+
// channel's ModifyRequest). Unlike the standard Authorization header, net/http
114+
// does NOT strip these on a cross-host redirect, so without an explicit policy a
115+
// redirect from the operator-configured upstream to another host would leak the
116+
// operator's upstream key to that host (CWE-200 / CWE-522).
117+
var sensitiveProxyHeaders = []string{
118+
"Authorization",
119+
"x-api-key",
120+
"api-key",
121+
"X-Goog-Api-Key",
122+
"X-Auth-Token",
123+
}
124+
125+
// stripSensitiveOnCrossHostRedirect removes credential headers when a redirect
126+
// crosses to a different host, mirroring the protection net/http already gives
127+
// the standard Authorization header. It preserves the default redirect cap.
128+
func stripSensitiveOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
129+
if len(via) >= 10 {
130+
return fmt.Errorf("stopped after 10 redirects")
131+
}
132+
if len(via) == 0 {
133+
return nil
134+
}
135+
if req.URL.Hostname() != via[0].URL.Hostname() {
136+
for _, h := range sensitiveProxyHeaders {
137+
req.Header.Del(h)
138+
}
139+
}
140+
return nil
141+
}
142+
110143
// getFingerprint generates a unique string representation of the client configuration.
111144
func (c *Config) getFingerprint() string {
112145
return fmt.Sprintf(
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package httpclient
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestStripSensitiveOnCrossHostRedirect asserts that the custom-named x-api-key
13+
// credential header set by a proxy channel's ModifyRequest is NOT replayed to a
14+
// different host when the operator-configured upstream issues a cross-host
15+
// redirect. Regression test for the upstream-key leak (CWE-200 / CWE-522).
16+
func TestStripSensitiveOnCrossHostRedirect(t *testing.T) {
17+
var gotAPIKey, gotAuthorization string
18+
19+
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
gotAPIKey = r.Header.Get("x-api-key")
21+
gotAuthorization = r.Header.Get("Authorization")
22+
w.WriteHeader(http.StatusOK)
23+
}))
24+
defer attacker.Close()
25+
26+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
http.Redirect(w, r, "http://attacker.local/v1/messages", http.StatusFound)
28+
}))
29+
defer upstream.Close()
30+
31+
attackerAddr := strings.TrimPrefix(attacker.URL, "http://")
32+
upstreamAddr := strings.TrimPrefix(upstream.URL, "http://")
33+
34+
transport := &http.Transport{
35+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
36+
switch addr {
37+
case "victim.local:80":
38+
addr = upstreamAddr
39+
case "attacker.local:80":
40+
addr = attackerAddr
41+
}
42+
return (&net.Dialer{}).DialContext(ctx, network, addr)
43+
},
44+
}
45+
46+
client := &http.Client{
47+
Transport: transport,
48+
CheckRedirect: stripSensitiveOnCrossHostRedirect,
49+
}
50+
51+
req, _ := http.NewRequest(http.MethodPost, "http://victim.local/v1/messages", nil)
52+
req.Header.Set("x-api-key", "sk-secret-upstream-key")
53+
req.Header.Set("Authorization", "Bearer secret-bearer")
54+
55+
resp, err := client.Do(req)
56+
if err != nil {
57+
t.Fatalf("request failed: %v", err)
58+
}
59+
resp.Body.Close()
60+
61+
if gotAPIKey != "" {
62+
t.Errorf("x-api-key leaked to cross-host redirect target: %q", gotAPIKey)
63+
}
64+
if gotAuthorization != "" {
65+
t.Errorf("Authorization leaked to cross-host redirect target: %q", gotAuthorization)
66+
}
67+
}
68+
69+
// TestSensitiveHeadersPreservedSameHost asserts the policy does NOT strip the
70+
// credential header on a same-host redirect (legitimate behavior must survive).
71+
func TestSensitiveHeadersPreservedSameHost(t *testing.T) {
72+
var gotAPIKey string
73+
var hops int
74+
75+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
if r.URL.Path == "/redirect" {
77+
hops++
78+
http.Redirect(w, r, "/final", http.StatusFound)
79+
return
80+
}
81+
gotAPIKey = r.Header.Get("x-api-key")
82+
w.WriteHeader(http.StatusOK)
83+
}))
84+
defer srv.Close()
85+
86+
client := &http.Client{CheckRedirect: stripSensitiveOnCrossHostRedirect}
87+
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/redirect", nil)
88+
req.Header.Set("x-api-key", "sk-secret-upstream-key")
89+
90+
resp, err := client.Do(req)
91+
if err != nil {
92+
t.Fatalf("request failed: %v", err)
93+
}
94+
resp.Body.Close()
95+
96+
if hops == 0 {
97+
t.Fatal("expected a same-host redirect hop")
98+
}
99+
if gotAPIKey != "sk-secret-upstream-key" {
100+
t.Errorf("x-api-key was incorrectly stripped on same-host redirect: %q", gotAPIKey)
101+
}
102+
}

0 commit comments

Comments
 (0)