Files
rclone/lib/http/middleware_test.go
Nick Craig-Wood 25707f2dc6 lib/http: require authentication for OPTIONS requests GHSA-gj73-fh6v-92fj CVE-PENDING
Before this change the basic, htpasswd and auth proxy authentication
middlewares let every OPTIONS request through without checking
credentials so that browser CORS preflights, which can't carry
credentials, would succeed.

However in serve webdav the OPTIONS request is answered by the WebDAV
handler whose Allow header depends on whether the path is an existing
file, an existing directory or doesn't exist, so an unauthenticated
client could discover the existence and type of any path on the served
remote even with --user/--pass or --htpasswd set.

After this change he authentication middlewares no longer special-case
OPTIONS. Instead the CORS middleware, which runs before authentication
and owns the --allow-origin setting, answers genuine CORS preflights
itself with an empty 200 carrying the CORS headers only when
--allow-origin is set.

All other OPTIONS requests need credentials like any other method, so
clients which probe with OPTIONS before authenticating will get a 401
challenge and must retry with credentials.
2026-10-08 22:49:33 +01:00

770 lines
20 KiB
Go

package http
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMiddlewareAuth(t *testing.T) {
servers := []struct {
name string
expectedUser string
remoteUser string
http Config
auth AuthConfig
user string
pass string
}{
{
name: "Basic",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
Realm: "test",
BasicUser: "test",
BasicPass: "test",
},
user: "test",
pass: "test",
},
{
name: "Htpasswd/MD5",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
Realm: "test",
HtPasswd: "./testdata/.htpasswd",
},
user: "md5",
pass: "md5",
},
{
name: "Htpasswd/SHA",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
Realm: "test",
HtPasswd: "./testdata/.htpasswd",
},
user: "sha",
pass: "sha",
},
{
name: "Htpasswd/Bcrypt",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
Realm: "test",
HtPasswd: "./testdata/.htpasswd",
},
user: "bcrypt",
pass: "bcrypt",
},
{
name: "Custom",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
Realm: "test",
CustomAuthFn: func(r *http.Request, user, pass string) (value any, err error) {
if user == "custom" && pass == "custom" && r.RemoteAddr != "" {
return true, nil
}
return nil, errors.New("invalid credentials")
},
},
user: "custom",
pass: "custom",
}, {
name: "UserFromHeader",
remoteUser: "remoteUser",
expectedUser: "remoteUser",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
UserFromHeader: "X-Remote-User",
},
}, {
name: "UserFromHeader/MixedWithHtPasswd",
remoteUser: "remoteUser",
expectedUser: "md5",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
UserFromHeader: "X-Remote-User",
Realm: "test",
HtPasswd: "./testdata/.htpasswd",
},
user: "md5",
pass: "md5",
},
}
for _, ss := range servers {
t.Run(ss.name, func(t *testing.T) {
s, err := NewServer(context.Background(), WithConfig(ss.http), WithAuth(ss.auth))
require.NoError(t, err)
defer func() {
require.NoError(t, s.Shutdown())
}()
expected := []byte("secret-page")
if ss.expectedUser != "" {
s.Router().Mount("/", testAuthUserHandler())
} else {
s.Router().Mount("/", testEchoHandler(expected))
}
s.Serve()
url := testGetServerURL(t, s)
t.Run("NoCreds", func(t *testing.T) {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "using no creds should return unauthorized")
if ss.auth.UserFromHeader == "" {
wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
}
})
t.Run("BadCreds", func(t *testing.T) {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
if ss.user != "" {
req.SetBasicAuth(ss.user+"BAD", ss.pass+"BAD")
}
if ss.auth.UserFromHeader != "" {
req.Header.Set(ss.auth.UserFromHeader, "/test:")
}
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "using bad creds should return unauthorized")
if ss.auth.UserFromHeader == "" {
wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
}
})
t.Run("GoodCreds", func(t *testing.T) {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
if ss.user != "" {
req.SetBasicAuth(ss.user, ss.pass)
}
if ss.auth.UserFromHeader != "" {
req.Header.Set(ss.auth.UserFromHeader, ss.remoteUser)
}
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, http.StatusOK, resp.StatusCode, "using good creds should return ok")
if ss.expectedUser != "" {
testExpectRespBody(t, resp, []byte(ss.expectedUser))
} else {
testExpectRespBody(t, resp, expected)
}
})
// OPTIONS is not exempt from authentication: a handler
// may reveal information about the path in its answer
t.Run("NoCredsOPTIONS", func(t *testing.T) {
client := &http.Client{}
req, err := http.NewRequest("OPTIONS", url, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "OPTIONS with no creds should return unauthorized")
})
// A CORS preflight is only exempt when CORS is enabled
// which it isn't for these servers
t.Run("NoCredsPreflight", func(t *testing.T) {
client := &http.Client{}
req, err := http.NewRequest("OPTIONS", url, nil)
require.NoError(t, err)
req.Header.Set("Origin", "http://test.rclone.org")
req.Header.Set("Access-Control-Request-Method", "GET")
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "preflight with no creds and no CORS should return unauthorized")
})
})
}
}
// A request arriving over a plain HTTP listener has no TLS state so it can't
// carry a client certificate and must be rejected.
func TestMiddlewareAuthCertificateUserNoTLS(t *testing.T) {
handler := MiddlewareAuthCertificateUser()(testEchoHandler([]byte("ok")))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.com/", nil))
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestMiddlewareAuthCertificateUser(t *testing.T) {
serverCertBytes := testReadTestdataFile(t, "local.crt")
serverKeyBytes := testReadTestdataFile(t, "local.key")
clientCertBytes := testReadTestdataFile(t, "client.crt")
clientKeyBytes := testReadTestdataFile(t, "client.key")
clientCert, err := tls.X509KeyPair(clientCertBytes, clientKeyBytes)
require.NoError(t, err)
emptyCertBytes := testReadTestdataFile(t, "emptyclient.crt")
emptyKeyBytes := testReadTestdataFile(t, "emptyclient.key")
emptyCert, err := tls.X509KeyPair(emptyCertBytes, emptyKeyBytes)
require.NoError(t, err)
invalidCert, err := tls.X509KeyPair(serverCertBytes, serverKeyBytes)
require.NoError(t, err)
servers := []struct {
name string
wantErr bool
status int
result string
http Config
auth AuthConfig
clientCerts []tls.Certificate
}{
{
name: "Missing",
wantErr: true,
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
TLSCertBody: serverCertBytes,
TLSKeyBody: serverKeyBytes,
MinTLSVersion: "tls1.0",
ClientCA: "./testdata/client-ca.crt",
},
},
{
name: "Invalid",
wantErr: true,
clientCerts: []tls.Certificate{invalidCert},
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
TLSCertBody: serverCertBytes,
TLSKeyBody: serverKeyBytes,
MinTLSVersion: "tls1.0",
ClientCA: "./testdata/client-ca.crt",
},
},
{
name: "EmptyCommonName",
status: http.StatusUnauthorized,
result: fmt.Sprintf("%s\n", http.StatusText(http.StatusUnauthorized)),
clientCerts: []tls.Certificate{emptyCert},
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
TLSCertBody: serverCertBytes,
TLSKeyBody: serverKeyBytes,
MinTLSVersion: "tls1.0",
ClientCA: "./testdata/client-ca.crt",
},
},
{
name: "Valid",
status: http.StatusOK,
result: "rclone-dev-client",
clientCerts: []tls.Certificate{clientCert},
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
TLSCertBody: serverCertBytes,
TLSKeyBody: serverKeyBytes,
MinTLSVersion: "tls1.0",
ClientCA: "./testdata/client-ca.crt",
},
},
{
name: "CustomAuth/Invalid",
status: http.StatusUnauthorized,
result: fmt.Sprintf("%d %s\n", http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)),
clientCerts: []tls.Certificate{clientCert},
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
TLSCertBody: serverCertBytes,
TLSKeyBody: serverKeyBytes,
MinTLSVersion: "tls1.0",
ClientCA: "./testdata/client-ca.crt",
},
auth: AuthConfig{
Realm: "test",
CustomAuthFn: func(_ *http.Request, user, pass string) (value any, err error) {
if user == "custom" && pass == "custom" {
return true, nil
}
return nil, errors.New("invalid credentials")
},
},
},
{
name: "CustomAuth/Valid",
status: http.StatusOK,
result: "rclone-dev-client",
clientCerts: []tls.Certificate{clientCert},
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
TLSCertBody: serverCertBytes,
TLSKeyBody: serverKeyBytes,
MinTLSVersion: "tls1.0",
ClientCA: "./testdata/client-ca.crt",
},
auth: AuthConfig{
Realm: "test",
CustomAuthFn: func(_ *http.Request, user, pass string) (value any, err error) {
fmt.Println("CUSTOMAUTH", user, pass)
if user == "rclone-dev-client" && pass == "" {
return true, nil
}
return nil, errors.New("invalid credentials")
},
},
},
}
for _, ss := range servers {
t.Run(ss.name, func(t *testing.T) {
s, err := NewServer(context.Background(), WithConfig(ss.http), WithAuth(ss.auth))
require.NoError(t, err)
defer func() {
require.NoError(t, s.Shutdown())
}()
s.Router().Mount("/", testAuthUserHandler())
s.Serve()
url := testGetServerURL(t, s)
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: ss.clientCerts,
InsecureSkipVerify: true,
},
},
}
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
resp, err := client.Do(req)
if ss.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, ss.status, resp.StatusCode, fmt.Sprintf("should return status %d", ss.status))
testExpectRespBody(t, resp, []byte(ss.result))
})
}
}
var _testCORSHeaderKeys = []string{
"Access-Control-Allow-Origin",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Methods",
}
func TestMiddlewareCORS(t *testing.T) {
servers := []struct {
name string
http Config
tryRoot bool
method string
status int
}{
{
name: "CustomOrigin",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
},
method: "GET",
status: http.StatusOK,
},
{
name: "WithBaseURL",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
BaseURL: "/baseurl/",
},
method: "GET",
status: http.StatusOK,
},
{
name: "WithBaseURLTryRootGET",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
BaseURL: "/baseurl/",
},
method: "GET",
status: http.StatusNotFound,
tryRoot: true,
},
{
name: "WithBaseURLTryRootOPTIONS",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
BaseURL: "/baseurl/",
},
method: "OPTIONS",
status: http.StatusOK,
tryRoot: true,
},
}
for _, ss := range servers {
t.Run(ss.name, func(t *testing.T) {
s, err := NewServer(context.Background(), WithConfig(ss.http))
require.NoError(t, err)
defer func() {
require.NoError(t, s.Shutdown())
}()
expected := []byte("data")
s.Router().Mount("/", testEchoHandler(expected))
s.Serve()
url := testGetServerURL(t, s)
// Try the query on the root, ignoring the baseURL
if ss.tryRoot {
slash := strings.LastIndex(url[:len(url)-1], "/")
url = url[:slash+1]
}
client := &http.Client{}
req, err := http.NewRequest(ss.method, url, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, ss.status, resp.StatusCode, "should return expected error code")
if ss.status == http.StatusNotFound {
return
}
testExpectRespBody(t, resp, expected)
for _, key := range _testCORSHeaderKeys {
require.Contains(t, resp.Header, key, "CORS headers should be sent")
}
expectedOrigin := url
if ss.http.AllowOrigin != "" {
expectedOrigin = ss.http.AllowOrigin
}
require.Equal(t, expectedOrigin, resp.Header.Get("Access-Control-Allow-Origin"), "allow origin should match")
})
}
}
func TestMiddlewareCORSEmptyOrigin(t *testing.T) {
servers := []struct {
name string
http Config
}{
{
name: "EmptyOrigin",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "",
},
},
}
for _, ss := range servers {
t.Run(ss.name, func(t *testing.T) {
s, err := NewServer(context.Background(), WithConfig(ss.http))
require.NoError(t, err)
defer func() {
require.NoError(t, s.Shutdown())
}()
expected := []byte("data")
s.Router().Mount("/", testEchoHandler(expected))
s.Serve()
url := testGetServerURL(t, s)
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, http.StatusOK, resp.StatusCode, "should return ok")
testExpectRespBody(t, resp, expected)
for _, key := range _testCORSHeaderKeys {
require.NotContains(t, resp.Header, key, "CORS headers should not be sent")
}
})
}
}
func TestMiddlewareCORSWithAuth(t *testing.T) {
authServers := []struct {
name string
http Config
auth AuthConfig
}{
{
name: "Basic",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
},
auth: AuthConfig{
Realm: "test",
BasicUser: "test_user",
BasicPass: "test_pass",
},
},
{
name: "Custom",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
},
auth: AuthConfig{
Realm: "test",
CustomAuthFn: func(r *http.Request, user, pass string) (value any, err error) {
if user == "test_user" && pass == "test_pass" {
return true, nil
}
return nil, errors.New("invalid credentials")
},
},
},
{
name: "UserFromHeader",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
},
auth: AuthConfig{
Realm: "test",
UserFromHeader: "X-Remote-User",
},
},
}
for _, ss := range authServers {
t.Run(ss.name, func(t *testing.T) {
s, err := NewServer(context.Background(), WithConfig(ss.http), WithAuth(ss.auth))
require.NoError(t, err)
defer func() {
require.NoError(t, s.Shutdown())
}()
s.Router().Mount("/", testEchoHandler([]byte("secret-page")))
s.Serve()
url := testGetServerURL(t, s)
doOPTIONS := func(t *testing.T, setup func(req *http.Request)) *http.Response {
req, err := http.NewRequest("OPTIONS", url, nil)
require.NoError(t, err)
if setup != nil {
setup(req)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
t.Cleanup(func() {
_ = resp.Body.Close()
})
return resp
}
preflight := func(req *http.Request) {
req.Header.Set("Origin", ss.http.AllowOrigin)
req.Header.Set("Access-Control-Request-Method", "GET")
}
// checkPreflight checks resp was answered by the CORS
// middleware and never reached the handler behind it
checkPreflight := func(t *testing.T, resp *http.Response) {
require.Equal(t, http.StatusOK, resp.StatusCode, "preflight OPTIONS should return ok")
testExpectRespBody(t, resp, []byte{})
for _, key := range _testCORSHeaderKeys {
require.Contains(t, resp.Header, key, "CORS headers should be sent")
}
require.Equal(t, ss.http.AllowOrigin, resp.Header.Get("Access-Control-Allow-Origin"), "allow origin should match")
}
// A browser preflight can't carry credentials so it
// must succeed without them
t.Run("PreflightNoCreds", func(t *testing.T) {
checkPreflight(t, doOPTIONS(t, preflight))
})
// A proxy in front of the browser may stamp an
// Authorization header the server can't check onto the
// preflight, which mustn't stop it succeeding
t.Run("PreflightIgnoresAuthorization", func(t *testing.T) {
checkPreflight(t, doOPTIONS(t, func(req *http.Request) {
preflight(req)
req.Header.Set("Authorization", "Bearer not-checked-here")
}))
checkPreflight(t, doOPTIONS(t, func(req *http.Request) {
preflight(req)
req.SetBasicAuth("test_user", "wrong")
}))
checkPreflight(t, doOPTIONS(t, func(req *http.Request) {
preflight(req)
req.SetBasicAuth("test_user", "test_pass")
}))
})
// Any other OPTIONS is authenticated like any other
// request as the handler's answer may reveal
// information about the path
t.Run("OPTIONSNoCreds", func(t *testing.T) {
resp := doOPTIONS(t, nil)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "OPTIONS with no creds should return unauthorized")
})
t.Run("OPTIONSGoodCreds", func(t *testing.T) {
resp := doOPTIONS(t, func(req *http.Request) {
if ss.auth.UserFromHeader != "" {
req.Header.Set(ss.auth.UserFromHeader, "test_user")
} else {
req.SetBasicAuth("test_user", "test_pass")
}
})
require.Equal(t, http.StatusOK, resp.StatusCode, "OPTIONS with good creds should return ok")
testExpectRespBody(t, resp, []byte("secret-page"))
})
})
}
}
func TestMiddlewareResponseHeaders(t *testing.T) {
servers := []struct {
name string
http Config
header http.Header
}{
{
name: "SingleHeader",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
ResponseHeaders: []string{"X-Test-Header: test-value"},
},
header: http.Header{
"X-Test-Header": []string{"test-value"},
},
},
{
name: "MultipleHeaders",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
ResponseHeaders: []string{"X-Header-One: one", "X-Header-Two: two"},
},
header: http.Header{
"X-Header-One": []string{"one"},
"X-Header-Two": []string{"two"},
},
},
}
for _, ss := range servers {
t.Run(ss.name, func(t *testing.T) {
s, err := NewServer(context.Background(), WithConfig(ss.http))
require.NoError(t, err)
defer func() {
require.NoError(t, s.Shutdown())
}()
expected := []byte("header-test")
s.Router().Mount("/", testEchoHandler(expected))
s.Serve()
url := testGetServerURL(t, s)
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
require.Equal(t, http.StatusOK, resp.StatusCode, "should return ok")
testExpectRespBody(t, resp, expected)
for key, vals := range ss.header {
require.Contains(t, resp.Header, key, "response should contain custom header")
require.Equal(t, vals, resp.Header.Values(key), "header value should match")
}
})
}
}