Files
rclone/lib/http/middleware_test.go
T
am-at-enrollvbandGitHub 5dd34275dc serve: pass the client IP address to the auth proxy - fixes #4499
The auth proxy was only given the user and their password or public
key, so a proxy program had no way to restrict logins to particular
networks, or to record where an authentication attempt came from.

The JSON sent to the program now has a client_ip key holding the bare
IP the client connected from, with the port stripped so IPv6 arrives
as 2001:db8::1 rather than [2001:db8::1]:52344. An IPv4-mapped IPv6
address is reported as plain IPv4 so that a client arriving over a
dual-stack listener still matches IPv4 networks. The key is omitted
when the client has no IP address.

The IP is also mixed into the backend cache key. That is needed as the
program is only run on a cache miss, so a client from a
non-allowlisted address presenting valid credentials within the 5
minute cache lifetime would get a cache hit and be let in without the
program being consulted at all.
2026-08-01 12:25:06 +01:00

659 lines
16 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)
}
})
})
}
}
// 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: "ServerWithAuth",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
AllowOrigin: "http://test.rclone.org",
},
auth: AuthConfig{
Realm: "test",
BasicUser: "test_user",
BasicPass: "test_pass",
},
},
}
for _, ss := range authServers {
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())
}()
s.Router().Mount("/", testEmptyHandler())
s.Serve()
url := testGetServerURL(t, s)
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.StatusOK, resp.StatusCode, "OPTIONS should return ok even if not authenticated")
testExpectRespBody(t, resp, []byte{})
for _, key := range _testCORSHeaderKeys {
require.Contains(t, resp.Header, key, "CORS headers should be sent even if not authenticated")
}
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 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")
}
})
}
}