proxy-pool/internal/platform/httpsecurity/security_test.go

330 lines
9.9 KiB
Go

package httpsecurity
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
)
func TestProtectionAuthenticatesAPIRequestsAndBuildsStableIdentity(t *testing.T) {
t.Parallel()
tests := []struct {
name string
auth Authentication
configure func(*http.Request)
wantClient string
}{
{
name: "basic",
auth: Authentication{Mode: ModeUsernamePassword, Username: "alice", Password: "secret"},
configure: func(request *http.Request) {
request.SetBasicAuth("alice", "secret")
},
wantClient: "basic:alice",
},
{
name: "api key",
auth: Authentication{Mode: ModeAPIKey, Header: "X-API-Key", Token: "api-secret"},
configure: func(request *http.Request) {
request.Header.Set("X-API-Key", "api-secret")
},
wantClient: credentialSubject("apiKey", "api-secret"),
},
{
name: "bearer",
auth: Authentication{Mode: ModeBearer, Token: "bearer-secret"},
configure: func(request *http.Request) {
request.Header.Set("Authorization", "Bearer bearer-secret")
},
wantClient: credentialSubject("bearer", "bearer-secret"),
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
protection := mustProtection(t, Config{
Authentication: test.auth,
ClientIdentification: ClientAuthenticated,
}, nil)
request := newRequest()
test.configure(request)
identity, err := protection.Resolve(request)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if identity.ClientID != test.wantClient || identity.SourceIP != "198.51.100.8" {
t.Fatalf("identity = %+v", identity)
}
})
}
}
func TestProtectionSupportsAnyAuthentication(t *testing.T) {
t.Parallel()
protection := mustProtection(t, Config{
Authentication: Authentication{Mode: ModeAny, Methods: []Method{
{Mode: ModeUsernamePassword, Username: "alice", Password: "wrong-for-request"},
{Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret"},
}},
ClientIdentification: ClientAuthenticatedOrSourceIP,
}, nil)
request := newRequest()
request.Header.Set("X-API-Key", "api-secret")
identity, err := protection.Resolve(request)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if identity.ClientID != credentialSubject("apiKey", "api-secret") {
t.Fatalf("client ID = %q", identity.ClientID)
}
}
func TestProtectionAnyPreservesSourceRejection(t *testing.T) {
t.Parallel()
tests := []struct {
name string
methods []Method
}{
{
name: "IP whitelist only",
methods: []Method{{Mode: ModeIPWhitelist, CIDRs: []string{"10.0.0.0/8"}}},
},
{
name: "IP whitelist and bearer",
methods: []Method{
{Mode: ModeIPWhitelist, CIDRs: []string{"10.0.0.0/8"}},
{Mode: ModeBearer, Value: "bearer-secret"},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
protection := mustProtection(t, Config{
Authentication: Authentication{Mode: ModeAny, Methods: test.methods},
}, nil)
_, err := protection.Resolve(newRequest())
var httpError *HTTPError
if !errors.As(err, &httpError) || httpError.StatusCode != http.StatusForbidden || httpError.Code != "FORBIDDEN" {
t.Fatalf("Resolve() error = %+v, want 403 FORBIDDEN", httpError)
}
})
}
}
func TestProtectionReturnsSafeHTTPFailures(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config Config
configure func(*http.Request)
admitter Admitter
wantStatus int
wantCode string
wantHeader string
}{
{
name: "unauthorized basic",
config: Config{Authentication: Authentication{Mode: ModeUsernamePassword, Username: "alice", Password: "secret"}},
wantStatus: http.StatusUnauthorized,
wantCode: "UNAUTHORIZED",
wantHeader: `Basic realm="proxy-pool"`,
},
{
name: "unauthorized API key",
config: Config{Authentication: Authentication{Mode: ModeAPIKey, Header: "X-API-Key", Token: "secret"}},
wantStatus: http.StatusUnauthorized,
wantCode: "UNAUTHORIZED",
wantHeader: `ApiKey realm="proxy-pool", header="X-API-Key"`,
},
{
name: "forbidden source",
config: Config{Authentication: Authentication{Mode: ModeNone}, AllowCIDRs: []string{"10.0.0.0/8"}},
wantStatus: http.StatusForbidden,
wantCode: "FORBIDDEN",
},
{
name: "invalid forwarded chain",
config: Config{Authentication: Authentication{Mode: ModeNone}, TrustedProxies: []string{"10.0.0.0/8"}},
configure: func(request *http.Request) {
request.RemoteAddr = "10.0.0.1:1"
request.Header.Set("Forwarded", "for=unknown")
},
wantStatus: http.StatusBadRequest,
wantCode: "INVALID_SOURCE",
},
{
name: "invalid forwarded IPv6 suffix",
config: Config{Authentication: Authentication{Mode: ModeNone}, TrustedProxies: []string{"10.0.0.0/8"}},
configure: func(request *http.Request) {
request.RemoteAddr = "10.0.0.1:1"
request.Header.Set("Forwarded", `for="[2001:db8::7]junk"`)
},
wantStatus: http.StatusBadRequest,
wantCode: "INVALID_SOURCE",
},
{
name: "rate limited",
config: Config{Authentication: Authentication{Mode: ModeNone}},
admitter: rejectAdmitter{},
wantStatus: http.StatusTooManyRequests,
wantCode: "RATE_LIMITED",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
protection := mustProtection(t, test.config, test.admitter)
request := newRequest()
if test.configure != nil {
test.configure(request)
}
_, err := protection.Resolve(request)
var httpError *HTTPError
if !errors.As(err, &httpError) {
t.Fatalf("Resolve() error = %T %v, want *HTTPError", err, err)
}
if httpError.StatusCode != test.wantStatus || httpError.Code != test.wantCode {
t.Fatalf("HTTP error = %+v", httpError)
}
if test.wantHeader != "" && httpError.Header.Get("WWW-Authenticate") != test.wantHeader {
t.Fatalf("challenge = %q", httpError.Header.Get("WWW-Authenticate"))
}
if strings := err.Error(); strings == "" || strings == "secret" {
t.Fatalf("unsafe error = %q", strings)
}
})
}
}
func TestProtectionOnlyTrustsForwardedHeadersFromConfiguredPeers(t *testing.T) {
t.Parallel()
protection := mustProtection(t, Config{
Authentication: Authentication{Mode: ModeNone},
TrustedProxies: []string{"10.0.0.0/8", "192.0.2.0/24"},
ClientIdentification: ClientSourceIP,
}, nil)
request := newRequest()
request.RemoteAddr = "10.0.0.9:1234"
request.Header.Set("X-Forwarded-For", "198.51.100.7, 192.0.2.5")
identity, err := protection.Resolve(request)
if err != nil {
t.Fatalf("Resolve(trusted) error = %v", err)
}
if identity.SourceIP != "198.51.100.7" || identity.ClientID != "source:198.51.100.7" {
t.Fatalf("trusted identity = %+v", identity)
}
request.RemoteAddr = "203.0.113.9:1234"
identity, err = protection.Resolve(request)
if err != nil {
t.Fatalf("Resolve(untrusted) error = %v", err)
}
if identity.SourceIP != "203.0.113.9" {
t.Fatalf("untrusted identity = %+v", identity)
}
}
func TestProtectionUsesProxyAuthenticationSemantics(t *testing.T) {
t.Parallel()
protection := mustProtection(t, Config{
Authentication: Authentication{Mode: ModeBearer, Token: "proxy-token"},
Semantics: ProxySemantics,
}, nil)
request := newRequest()
request.Header.Set("Authorization", "Bearer proxy-token")
if _, err := protection.Resolve(request); err == nil {
t.Fatal("Resolve(Authorization) error = nil, want proxy authentication failure")
}
request.Header.Del("Authorization")
request.Header.Set("Proxy-Authorization", "Bearer proxy-token")
if _, err := protection.Resolve(request); err != nil {
t.Fatalf("Resolve(Proxy-Authorization) error = %v", err)
}
request.Header.Set("Proxy-Authorization", "Bearer wrong")
_, err := protection.Resolve(request)
var httpError *HTTPError
if !errors.As(err, &httpError) || httpError.StatusCode != http.StatusProxyAuthRequired ||
httpError.Header.Get("Proxy-Authenticate") != `Bearer realm="proxy-pool"` {
t.Fatalf("proxy error = %+v", httpError)
}
}
func TestNewProtectionRejectsInvalidConfiguration(t *testing.T) {
t.Parallel()
tests := []Config{
{Authentication: Authentication{Mode: "unknown"}},
{Authentication: Authentication{Mode: ModeAPIKey, Header: "Bad Header", Token: "secret"}},
{Authentication: Authentication{Mode: ModeNone}, TrustedProxies: []string{"invalid"}},
{Authentication: Authentication{Mode: ModeNone}, ClientIdentification: "unknown"},
{Authentication: Authentication{Mode: ModeNone}, ClientIdentification: ClientAuthenticated},
}
for _, config := range tests {
if _, err := New(config, nil); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("New(%+v) error = %v, want %v", config, err, ErrInvalidConfig)
}
}
}
func TestProtectionResolvesConcurrentRequestsWithoutSharedMutation(t *testing.T) {
t.Parallel()
protection := mustProtection(t, Config{
Authentication: Authentication{Mode: ModeAPIKey, Header: "X-API-Key", Token: "api-secret"},
ClientIdentification: ClientAuthenticated,
}, nil)
var failures atomic.Int64
var wait sync.WaitGroup
for range 1000 {
wait.Add(1)
go func() {
defer wait.Done()
request := newRequest()
request.Header.Set("X-API-Key", "api-secret")
identity, err := protection.Resolve(request)
if err != nil || identity.ClientID != credentialSubject("apiKey", "api-secret") {
failures.Add(1)
}
}()
}
wait.Wait()
if failures.Load() != 0 {
t.Fatalf("concurrent resolve failures = %d", failures.Load())
}
}
func mustProtection(t *testing.T, config Config, admitter Admitter) *Protection {
t.Helper()
protection, err := New(config, admitter)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return protection
}
func newRequest() *http.Request {
request := httptest.NewRequest(http.MethodGet, "http://example.test", nil)
request.RemoteAddr = "198.51.100.8:1234"
return request
}
type rejectAdmitter struct{}
func (rejectAdmitter) Admit(context.Context, string) error { return errors.New("backend details") }