mirror of
https://github.com/rclone/rclone.git
synced 2026-10-09 14:39:28 -04:00
SameHost compares two URLs by host name (case insensitively) and port (treating the scheme's default port as no port) so redirect policies can tell a real change of host from a server spelling its own host differently, e.g. redirecting "https://example.com/" to "https://EXAMPLE.com:443/". The HTTPS downgrade check now compares the redirect target against the original request rather than the previous hop, so a chain which started on plaintext http, passed through an https server and came back to http is no longer refused - nothing is being downgraded relative to what the user asked for. A chain which started on https and reaches http via any number of hops is still refused. (cherry picked from commit fc7a64f61c2e4b364b5789f6ea17ad0485d8246e)
183 lines
7.4 KiB
Go
183 lines
7.4 KiB
Go
package rest
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// mkRedirectReq makes a minimal *http.Request pointing at rawURL for
|
|
// exercising the CheckRedirect functions.
|
|
func mkRedirectReq(t *testing.T, rawURL, method string) *http.Request {
|
|
u, err := url.Parse(rawURL)
|
|
require.NoError(t, err)
|
|
return &http.Request{URL: u, Method: method, Header: http.Header{}}
|
|
}
|
|
|
|
func TestPreserveMethodRedirectFn(t *testing.T) {
|
|
t.Run("PreservesMethod", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "https://example.com/a", "PROPFIND")
|
|
next := mkRedirectReq(t, "https://example.com/b", "GET")
|
|
require.NoError(t, PreserveMethodRedirectFn(next, []*http.Request{orig}))
|
|
assert.Equal(t, "PROPFIND", next.Method)
|
|
})
|
|
t.Run("RefusesDowngrade", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "https://example.com/a", "PROPFIND")
|
|
next := mkRedirectReq(t, "http://example.com/b", "PROPFIND")
|
|
assert.ErrorIs(t, PreserveMethodRedirectFn(next, []*http.Request{orig}), ErrHTTPSDowngrade)
|
|
})
|
|
t.Run("AllowsCrossHostHTTPS", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "https://example.com/a", "PROPFIND")
|
|
next := mkRedirectReq(t, "https://other.example.com/b", "PROPFIND")
|
|
assert.NoError(t, PreserveMethodRedirectFn(next, []*http.Request{orig}))
|
|
})
|
|
t.Run("AllowsPlainHTTP", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "http://example.com/a", "PROPFIND")
|
|
next := mkRedirectReq(t, "http://example.com/b", "PROPFIND")
|
|
assert.NoError(t, PreserveMethodRedirectFn(next, []*http.Request{orig}))
|
|
})
|
|
t.Run("TooManyRedirects", func(t *testing.T) {
|
|
next := mkRedirectReq(t, "https://example.com/b", "GET")
|
|
via := make([]*http.Request, 10)
|
|
assert.Error(t, PreserveMethodRedirectFn(next, via))
|
|
})
|
|
}
|
|
|
|
func TestRefuseHTTPSDowngradeRedirectFn(t *testing.T) {
|
|
t.Run("RefusesDowngrade", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "https://example.com/a", "GET")
|
|
next := mkRedirectReq(t, "http://example.com/b", "GET")
|
|
assert.ErrorIs(t, RefuseHTTPSDowngradeRedirectFn(next, []*http.Request{orig}), ErrHTTPSDowngrade)
|
|
})
|
|
t.Run("AllowsUpgrade", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "http://example.com/a", "GET")
|
|
next := mkRedirectReq(t, "https://example.com/b", "GET")
|
|
assert.NoError(t, RefuseHTTPSDowngradeRedirectFn(next, []*http.Request{orig}))
|
|
})
|
|
t.Run("AllowsSameScheme", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "https://example.com/a", "GET")
|
|
next := mkRedirectReq(t, "https://example.com/b", "GET")
|
|
assert.NoError(t, RefuseHTTPSDowngradeRedirectFn(next, []*http.Request{orig}))
|
|
})
|
|
t.Run("RefusesDowngradeViaOtherHost", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "https://example.com/a", "GET")
|
|
mid := mkRedirectReq(t, "https://other.example/b", "GET")
|
|
next := mkRedirectReq(t, "http://example.com/c", "GET")
|
|
assert.ErrorIs(t, RefuseHTTPSDowngradeRedirectFn(next, []*http.Request{orig, mid}), ErrHTTPSDowngrade)
|
|
})
|
|
t.Run("AllowsPlaintextOriginViaHTTPS", func(t *testing.T) {
|
|
orig := mkRedirectReq(t, "http://example.com/a", "GET")
|
|
mid := mkRedirectReq(t, "https://other.example/b", "GET")
|
|
next := mkRedirectReq(t, "http://example.com/c", "GET")
|
|
assert.NoError(t, RefuseHTTPSDowngradeRedirectFn(next, []*http.Request{orig, mid}))
|
|
})
|
|
t.Run("TooManyRedirects", func(t *testing.T) {
|
|
next := mkRedirectReq(t, "https://example.com/b", "GET")
|
|
via := make([]*http.Request, 10)
|
|
assert.Error(t, RefuseHTTPSDowngradeRedirectFn(next, via))
|
|
})
|
|
}
|
|
|
|
func TestSameHost(t *testing.T) {
|
|
for _, test := range []struct {
|
|
a, b string
|
|
want bool
|
|
}{
|
|
{"https://example.com/a", "https://example.com/b", true},
|
|
{"https://example.com/", "https://EXAMPLE.com/", true},
|
|
{"https://example.com/", "https://example.com:443/", true},
|
|
{"http://example.com/", "http://example.com:80/", true},
|
|
{"https://example.com/", "http://example.com/", false},
|
|
{"https://example.com:8443/", "https://example.com:8444/", false},
|
|
{"https://example.com/", "https://www.example.com/", false},
|
|
{"https://example.com/", "https://example.com.evil/", false},
|
|
{"http://[::1]:8080/", "http://[::1]:8080/", true},
|
|
{"http://[::1]:8080/", "http://[::1]:8081/", false},
|
|
} {
|
|
a, err := url.Parse(test.a)
|
|
require.NoError(t, err)
|
|
b, err := url.Parse(test.b)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.want, SameHost(a, b), "%s vs %s", test.a, test.b)
|
|
}
|
|
}
|
|
|
|
// newDowngradeServers returns an HTTPS server that redirects every
|
|
// request to a plaintext HTTP server on the same host, together with a
|
|
// flag that records whether the plaintext server ever received an
|
|
// Authorization header. Use tlsSrv.Client() for a client that trusts the
|
|
// test certificate.
|
|
func newDowngradeServers(t *testing.T) (tlsSrv *httptest.Server, sawAuth *atomic.Bool) {
|
|
t.Helper()
|
|
sawAuth = new(atomic.Bool)
|
|
|
|
// Plaintext HTTP target that records whether it received credentials.
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "" {
|
|
sawAuth.Store(true)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(target.Close)
|
|
|
|
// HTTPS server that redirects to the plaintext target on the same host.
|
|
tlsSrv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusTemporaryRedirect)
|
|
}))
|
|
t.Cleanup(tlsSrv.Close)
|
|
|
|
return tlsSrv, sawAuth
|
|
}
|
|
|
|
// TestRefuseHTTPSDowngradeRedirectEndToEnd drives a real HTTPS-to-HTTP
|
|
// redirect through rest.Client with credentials set via SetUserPass, the
|
|
// same way the webdav backend does. It shows that the default client
|
|
// leaks credentials to the plaintext hop, whereas the redirect handlers
|
|
// refuse to follow the downgrade and the plaintext hop never sees them.
|
|
func TestRefuseHTTPSDowngradeRedirectEndToEnd(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Baseline: without any check the credentials set by SetUserPass are
|
|
// forwarded over the plaintext hop, which is the vulnerability.
|
|
t.Run("DefaultLeaks", func(t *testing.T) {
|
|
tlsSrv, sawAuth := newDowngradeServers(t)
|
|
api := NewClient(tlsSrv.Client()).SetRoot(tlsSrv.URL)
|
|
api.SetUserPass("user", "pass")
|
|
_, err := api.Call(ctx, &Opts{Method: "GET", Path: "/file", NoResponse: true})
|
|
require.NoError(t, err)
|
|
assert.True(t, sawAuth.Load(), "expected credentials to be sent over plaintext")
|
|
})
|
|
|
|
// Default path (all normal webdav calls): the client refuses the
|
|
// downgrade so nothing is sent.
|
|
t.Run("DefaultPathRefused", func(t *testing.T) {
|
|
tlsSrv, sawAuth := newDowngradeServers(t)
|
|
client := tlsSrv.Client()
|
|
client.CheckRedirect = RefuseHTTPSDowngradeRedirectFn
|
|
api := NewClient(client).SetRoot(tlsSrv.URL)
|
|
api.SetUserPass("user", "pass")
|
|
_, err := api.Call(ctx, &Opts{Method: "GET", Path: "/file", NoResponse: true})
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ErrHTTPSDowngrade)
|
|
assert.False(t, sawAuth.Load(), "plaintext hop must not receive credentials")
|
|
})
|
|
|
|
// PROPFIND path (readMetaDataForPath): the per-call CheckRedirect
|
|
// refuses the downgrade too.
|
|
t.Run("PropfindPathRefused", func(t *testing.T) {
|
|
tlsSrv, sawAuth := newDowngradeServers(t)
|
|
api := NewClient(tlsSrv.Client()).SetRoot(tlsSrv.URL)
|
|
api.SetUserPass("user", "pass")
|
|
_, err := api.Call(ctx, &Opts{Method: "PROPFIND", Path: "/dir", NoResponse: true, CheckRedirect: PreserveMethodRedirectFn})
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ErrHTTPSDowngrade)
|
|
assert.False(t, sawAuth.Load(), "plaintext hop must not receive credentials")
|
|
})
|
|
}
|