Skip to content

Commit d0289db

Browse files
committed
fix: credentials follow a redirect only back to the exact origin
97b898f gave X-Environment-Key the treatment I believed Go already gave Authorization. Both halves of that were wrong. Go's rule is laxer than I described — shouldCopyHeaderOnRedirect permits any subdomain of the initial host, so api.example → evil.api.example keeps Authorization — and my own host-only comparison ignored the scheme, so https://api.examplehttp://api.example re-sent both secrets in cleartext on the same host. checkRedirect now strips Authorization as well as X-Environment-Key whenever the target is not the exact origin the request began at: same host, and no downgrade away from https (an upgrade to it is fine). The policy moved out of the client literal so it can be tested directly — subdomains and scheme downgrades are awkward to stage against httptest. Both new protections are mutation-verified. That caught a real defect in the first version of the test: it looped over the production secretHeaders slice, so shrinking that slice shrank what the test checked and the Authorization protection was never actually asserted. The header names are now literal in the test. Addresses Themis review on #43 (httpx/client.go:62). beep boop
1 parent 030c59e commit d0289db

2 files changed

Lines changed: 112 additions & 15 deletions

File tree

internal/httpx/client.go

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net"
1313
"net/http"
1414
"net/http/httptrace"
15+
"net/url"
1516
"os"
1617
"strconv"
1718
"strings"
@@ -54,24 +55,52 @@ func New(userAgent string) *http.Client {
5455
rt = &tracer{base: rt, out: os.Stderr}
5556
}
5657
return &http.Client{
57-
Transport: &transport{base: rt, userAgent: userAgent},
58-
// Go strips Authorization when a redirect crosses hosts, but copies
59-
// custom headers verbatim — and X-Environment-Key can carry a
60-
// server-side (ser.) secret. Give it the same treatment. Setting
61-
// CheckRedirect replaces the default policy, so the ten-hop cap
62-
// comes with it.
63-
CheckRedirect: func(req *http.Request, via []*http.Request) error {
64-
if len(via) >= 10 {
65-
return errors.New("stopped after 10 redirects")
66-
}
67-
if req.URL.Host != via[0].URL.Host {
68-
req.Header.Del("X-Environment-Key")
69-
}
70-
return nil
71-
},
58+
Transport: &transport{base: rt, userAgent: userAgent},
59+
CheckRedirect: checkRedirect,
7260
}
7361
}
7462

63+
// maxRedirects mirrors the cap in Go's default policy, which setting
64+
// CheckRedirect replaces.
65+
const maxRedirects = 10
66+
67+
// secretHeaders never travel to an origin the request did not start at.
68+
// Authorization carries the Admin credential; X-Environment-Key can carry a
69+
// server-side (ser.) environment key, also a secret.
70+
var secretHeaders = []string{"Authorization", "X-Environment-Key"}
71+
72+
// checkRedirect strips credentials when a redirect leaves the origin the
73+
// request began at. Go's own rule is laxer in two ways: it forwards
74+
// Authorization to any subdomain of the initial host (shouldCopyHeaderOnRedirect
75+
// → isDomainOrSubdomain, so api.example → evil.api.example keeps it), and it
76+
// applies to Authorization only, copying custom headers verbatim. It also
77+
// ignores the scheme, so https → http on the same host would re-send both
78+
// secrets in cleartext. Require the same host and no downgrade instead.
79+
func checkRedirect(req *http.Request, via []*http.Request) error {
80+
if len(via) >= maxRedirects {
81+
return errors.New("stopped after 10 redirects")
82+
}
83+
if !sameOrigin(via[0].URL, req.URL) {
84+
for _, h := range secretHeaders {
85+
req.Header.Del(h)
86+
}
87+
}
88+
return nil
89+
}
90+
91+
// sameOrigin reports whether credentials may follow a redirect from → to:
92+
// the same host (exactly — not a subdomain), over the same scheme or an
93+
// upgrade to https, never a downgrade away from it.
94+
func sameOrigin(from, to *url.URL) bool {
95+
if !strings.EqualFold(from.Host, to.Host) {
96+
return false
97+
}
98+
if strings.EqualFold(from.Scheme, "https") && !strings.EqualFold(to.Scheme, "https") {
99+
return false
100+
}
101+
return true
102+
}
103+
75104
// envBool reads a boolean switch from the environment: presence alone is not
76105
// truth, so FLAGSMITH_DEBUG=0 leaves tracing off. Duplicated rather than
77106
// shared because this package deliberately imports nothing internal; the

internal/httpx/client_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,71 @@ func TestRedirectStripsEnvironmentKeyAcrossHosts(t *testing.T) {
329329
t.Errorf("X-Environment-Key = %q after a same-host redirect, want it kept", v)
330330
}
331331
}
332+
333+
// Credentials follow a redirect only back to the exact origin the request
334+
// started at. Go's own rule is laxer — it forwards Authorization to any
335+
// subdomain and ignores the scheme — and it never protects custom headers,
336+
// so X-Environment-Key needs this too.
337+
func TestCheckRedirectStripsSecrets(t *testing.T) {
338+
cases := []struct {
339+
name string
340+
from, to string
341+
wantKept bool
342+
}{
343+
{"same origin", "https://api.example/a", "https://api.example/b", true},
344+
{"upgrade to https is fine", "http://api.example/a", "https://api.example/b", true},
345+
{"scheme downgrade on the same host", "https://api.example/a", "http://api.example/b", false},
346+
{"different host", "https://api.example/a", "https://evil.example/b", false},
347+
{"subdomain of the original host", "https://api.example/a", "https://evil.api.example/b", false},
348+
{"parent of the original host", "https://api.example/a", "https://example/b", false},
349+
{"different port", "https://api.example/a", "https://api.example:8443/b", false},
350+
{"host case is not a difference", "https://API.example/a", "https://api.example/b", true},
351+
}
352+
for _, c := range cases {
353+
t.Run(c.name, func(t *testing.T) {
354+
// Given a redirect from → to, carrying both secret headers
355+
from, err := http.NewRequest(http.MethodGet, c.from, nil)
356+
if err != nil {
357+
t.Fatal(err)
358+
}
359+
to, err := http.NewRequest(http.MethodGet, c.to, nil)
360+
if err != nil {
361+
t.Fatal(err)
362+
}
363+
// Literal names, not secretHeaders: the test must fail if a
364+
// header is dropped from the production list.
365+
for _, h := range []string{"Authorization", "X-Environment-Key"} {
366+
to.Header.Set(h, "secret")
367+
}
368+
369+
// When
370+
if err := checkRedirect(to, []*http.Request{from}); err != nil {
371+
t.Fatal(err)
372+
}
373+
374+
// Then both headers survive together, or neither does
375+
for _, h := range []string{"Authorization", "X-Environment-Key"} {
376+
if kept := to.Header.Get(h) != ""; kept != c.wantKept {
377+
t.Errorf("%s kept = %v, want %v", h, kept, c.wantKept)
378+
}
379+
}
380+
})
381+
}
382+
}
383+
384+
func TestCheckRedirectCapsHops(t *testing.T) {
385+
// Given a chain already at the cap
386+
req, err := http.NewRequest(http.MethodGet, "https://api.example/", nil)
387+
if err != nil {
388+
t.Fatal(err)
389+
}
390+
via := make([]*http.Request, maxRedirects)
391+
for i := range via {
392+
via[i] = req
393+
}
394+
395+
// When / Then — setting CheckRedirect replaces Go's cap, so ours must hold
396+
if err := checkRedirect(req, via); err == nil {
397+
t.Error("err = nil, want the redirect chain to stop")
398+
}
399+
}

0 commit comments

Comments
 (0)