http: don't leak configured headers to other hosts or over plaintext on redirect GHSA-486v-q2wf-fp2r CVE-PENDING
The headers set with --http-headers are documented for passing credentials such as Authorization or Cookie. The backend used the default net/http redirect policy which copies all but a handful of well known headers to any redirect target, so a redirect from the configured server to another host would send those credentials to that host, and a redirect from https to http would send them in plaintext. When headers are configured this installs a CheckRedirect function which: - removes the configured headers from every hop once the redirect chain has left the originally requested host - refuses a redirect from https to http with an error
This commit is contained in:
+51
-1
@@ -57,7 +57,13 @@ The input format is comma separated list of key,value pairs. Standard
|
|||||||
|
|
||||||
For example, to set a Cookie use 'Cookie,name=value', or '"Cookie","name=value"'.
|
For example, to set a Cookie use 'Cookie,name=value', or '"Cookie","name=value"'.
|
||||||
|
|
||||||
You can set multiple headers, e.g. '"Cookie","name=value","Authorization","xxx"'.`,
|
You can set multiple headers, e.g. '"Cookie","name=value","Authorization","xxx"'.
|
||||||
|
|
||||||
|
The headers are only sent to the host in the configured URL. If the
|
||||||
|
server redirects to another host (including a subdomain or a different
|
||||||
|
port) the headers are not sent to it, or to any further hop in that
|
||||||
|
redirect chain. When headers are set, a redirect from https to http is
|
||||||
|
refused as it would send them in cleartext.`,
|
||||||
Default: fs.CommaSepList{},
|
Default: fs.CommaSepList{},
|
||||||
Advanced: true,
|
Advanced: true,
|
||||||
}, {
|
}, {
|
||||||
@@ -283,6 +289,11 @@ func (f *Fs) httpConnection(ctx context.Context, opt *Options) (isFile bool, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := fshttp.NewClient(ctx)
|
client := fshttp.NewClient(ctx)
|
||||||
|
// Without configured headers keep the default policy which
|
||||||
|
// public mirrors that redirect from https to http rely on.
|
||||||
|
if len(opt.Headers) > 0 {
|
||||||
|
client.CheckRedirect = checkRedirect(opt)
|
||||||
|
}
|
||||||
|
|
||||||
endpoint, isFile := getFsEndpoint(ctx, client, u.String(), opt)
|
endpoint, isFile := getFsEndpoint(ctx, client, u.String(), opt)
|
||||||
fs.Debugf(nil, "Root: %s", endpoint)
|
fs.Debugf(nil, "Root: %s", endpoint)
|
||||||
@@ -510,6 +521,45 @@ func addHeaders(req *http.Request, opt *Options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkRedirect returns an http.Client.CheckRedirect function which
|
||||||
|
// follows redirects but refuses an HTTPS to HTTP downgrade with
|
||||||
|
// rest.ErrHTTPSDowngrade and strips the configured headers when the
|
||||||
|
// redirect chain has left the originally requested host at any point.
|
||||||
|
func checkRedirect(opt *Options) func(req *http.Request, via []*http.Request) error {
|
||||||
|
return func(req *http.Request, via []*http.Request) error {
|
||||||
|
if err := rest.RefuseHTTPSDowngradeRedirectFn(req, via); err != nil {
|
||||||
|
if errors.Is(err, rest.ErrHTTPSDowngrade) {
|
||||||
|
err = fmt.Errorf("%w (the configured headers would be sent to the plaintext target)", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if redirectLeavesHost(req, via) {
|
||||||
|
for i := 0; i < len(opt.Headers); i += 2 {
|
||||||
|
req.Header.Del(opt.Headers[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectLeavesHost reports whether any hop in the redirect chain
|
||||||
|
// via plus the pending request req is to a different host from the
|
||||||
|
// original request via[0].
|
||||||
|
//
|
||||||
|
// net/http copies the headers afresh from the original request for
|
||||||
|
// every hop, so once the chain has visited another host the headers
|
||||||
|
// must be stripped from every subsequent hop, even one back to the
|
||||||
|
// original host, as the other host chose the URL.
|
||||||
|
func redirectLeavesHost(req *http.Request, via []*http.Request) bool {
|
||||||
|
origin := via[0].URL
|
||||||
|
for _, hop := range via {
|
||||||
|
if !rest.SameHost(hop.URL, origin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !rest.SameHost(req.URL, origin)
|
||||||
|
}
|
||||||
|
|
||||||
// Adds the configured headers to the request if any
|
// Adds the configured headers to the request if any
|
||||||
func (f *Fs) addHeaders(req *http.Request) {
|
func (f *Fs) addHeaders(req *http.Request) {
|
||||||
addHeaders(req, &f.opt)
|
addHeaders(req, &f.opt)
|
||||||
|
|||||||
@@ -581,3 +581,182 @@ func TestFsNoSlashRoots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readRedirected makes an http remote with the configured headers
|
||||||
|
// pointing at url and reads file.txt from it, checking the content
|
||||||
|
func readRedirected(t *testing.T, url string) {
|
||||||
|
configfile.Install()
|
||||||
|
m := configmap.Simple{
|
||||||
|
"type": "http",
|
||||||
|
"url": url + "/",
|
||||||
|
"headers": strings.Join(headers, ","),
|
||||||
|
}
|
||||||
|
f, err := NewFs(context.Background(), remoteName, "", m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
o, err := f.NewObject(context.Background(), "file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
fd, err := o.Open(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := io.ReadAll(fd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, fd.Close())
|
||||||
|
assert.Equal(t, "hello", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectKeepsHeadersOnSameHost(t *testing.T) {
|
||||||
|
var got http.Header
|
||||||
|
var ts *httptest.Server
|
||||||
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/moved/") {
|
||||||
|
http.Redirect(w, r, ts.URL+"/moved"+r.URL.Path, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got = r.Header.Clone()
|
||||||
|
_, _ = w.Write([]byte("hello"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
readRedirected(t, ts.URL)
|
||||||
|
|
||||||
|
require.NotNil(t, got)
|
||||||
|
for i := 0; i < len(headers); i += 2 {
|
||||||
|
assert.Equal(t, headers[i+1], got.Get(headers[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectRefusesHTTPSDowngrade(t *testing.T) {
|
||||||
|
// A plaintext server to be redirected to
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("hello"))
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
// An HTTPS server which redirects to plaintext HTTP
|
||||||
|
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// open makes a remote pointing at ts with or without headers
|
||||||
|
// and opens file.txt on it
|
||||||
|
open := func(t *testing.T, withHeaders bool) (io.ReadCloser, error) {
|
||||||
|
configfile.Install()
|
||||||
|
m := configmap.Simple{
|
||||||
|
"type": "http",
|
||||||
|
"url": ts.URL + "/",
|
||||||
|
"no_head": "true",
|
||||||
|
}
|
||||||
|
if withHeaders {
|
||||||
|
m.Set("headers", strings.Join(headers, ","))
|
||||||
|
}
|
||||||
|
ctx, ci := fs.AddConfig(context.Background())
|
||||||
|
ci.InsecureSkipVerify = true
|
||||||
|
f, err := NewFs(ctx, remoteName, "", m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// no_head means NewObject doesn't make a request so use Open to make one
|
||||||
|
o, err := f.NewObject(ctx, "file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return o.Open(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("WithHeaders", func(t *testing.T) {
|
||||||
|
// Headers could leak so the downgrade must be refused
|
||||||
|
_, err := open(t, true)
|
||||||
|
require.ErrorIs(t, err, rest.ErrHTTPSDowngrade)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithoutHeaders", func(t *testing.T) {
|
||||||
|
// Nothing to leak so the downgrade is followed
|
||||||
|
fd, err := open(t, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := io.ReadAll(fd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, fd.Close())
|
||||||
|
assert.Equal(t, "hello", string(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectKeepsHeadersOnSameHostDifferentCase(t *testing.T) {
|
||||||
|
// The redirect spells the host differently but it is the same
|
||||||
|
// host so the headers must be kept
|
||||||
|
var got http.Header
|
||||||
|
var ts *httptest.Server
|
||||||
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/moved/") {
|
||||||
|
http.Redirect(w, r, "http://LOCALHOST:"+port(t, ts)+"/moved"+r.URL.Path, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got = r.Header.Clone()
|
||||||
|
_, _ = w.Write([]byte("hello"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
readRedirected(t, "http://localhost:"+port(t, ts))
|
||||||
|
|
||||||
|
require.NotNil(t, got)
|
||||||
|
for i := 0; i < len(headers); i += 2 {
|
||||||
|
assert.Equal(t, headers[i+1], got.Get(headers[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// port returns the port ts is listening on
|
||||||
|
func port(t *testing.T, ts *httptest.Server) string {
|
||||||
|
u, err := url.Parse(ts.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return u.Port()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectStripsHeadersAfterHostChange(t *testing.T) {
|
||||||
|
// The configured server redirects to a second server which
|
||||||
|
// redirects back to the configured server. The headers must not
|
||||||
|
// be sent on the way back as the other host chose the path.
|
||||||
|
var got http.Header
|
||||||
|
var ts *httptest.Server
|
||||||
|
bouncer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, ts.URL+"/moved"+r.URL.Path, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer bouncer.Close()
|
||||||
|
|
||||||
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/moved/") {
|
||||||
|
http.Redirect(w, r, bouncer.URL+r.URL.Path, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got = r.Header.Clone()
|
||||||
|
_, _ = w.Write([]byte("hello"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
readRedirected(t, ts.URL)
|
||||||
|
|
||||||
|
require.NotNil(t, got)
|
||||||
|
for i := 0; i < len(headers); i += 2 {
|
||||||
|
assert.Empty(t, got.Values(headers[i]), "header %q sent after cross-host redirect", headers[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectStripsHeadersOnHostChange(t *testing.T) {
|
||||||
|
// Serve the final response from a second server. httptest servers
|
||||||
|
// listen on 127.0.0.1:port so the two servers have different hosts
|
||||||
|
// for the purposes of the redirect check.
|
||||||
|
var got http.Header
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = r.Header.Clone()
|
||||||
|
_, _ = w.Write([]byte("hello"))
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer redirector.Close()
|
||||||
|
|
||||||
|
readRedirected(t, redirector.URL)
|
||||||
|
|
||||||
|
require.NotNil(t, got)
|
||||||
|
for i := 0; i < len(headers); i += 2 {
|
||||||
|
assert.Empty(t, got.Values(headers[i]), "header %q leaked to redirect target", headers[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user