382 lines
12 KiB
Go
382 lines
12 KiB
Go
package httpsecurity
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"proxy-pool/internal/domain/authorization"
|
|
)
|
|
|
|
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 TestProtectionCarriesPermissionsFromMatchedCredential(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
auth Authentication
|
|
configure func(*http.Request)
|
|
want []string
|
|
mustAllow string
|
|
mustReject string
|
|
}{
|
|
{
|
|
name: "single credential", auth: Authentication{
|
|
Mode: ModeBearer, Token: "read-token", Permissions: []string{authorization.AdminRead},
|
|
},
|
|
configure: func(request *http.Request) { request.Header.Set("Authorization", "Bearer read-token") },
|
|
want: []string{authorization.AdminRead}, mustAllow: authorization.AdminRead, mustReject: authorization.AdminWrite,
|
|
},
|
|
{
|
|
name: "matched any method", auth: Authentication{Mode: ModeAny, Methods: []Method{
|
|
{Mode: ModeBearer, Value: "extract-token", Permissions: []string{authorization.DistributionExtract}},
|
|
{Mode: ModeAPIKey, Header: "X-Admin-Key", Value: "write-token", Permissions: []string{authorization.AdminWrite}},
|
|
}},
|
|
configure: func(request *http.Request) { request.Header.Set("X-Admin-Key", "write-token") },
|
|
want: []string{authorization.AdminWrite}, mustAllow: authorization.AdminWrite, mustReject: authorization.AdminRead,
|
|
},
|
|
}
|
|
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 !slices.Equal(identity.Permissions, test.want) {
|
|
t.Fatalf("permissions = %v, want %v", identity.Permissions, test.want)
|
|
}
|
|
if !identity.Allows(test.mustAllow) || identity.Allows(test.mustReject) {
|
|
t.Fatalf("permission checks for %v are wrong", identity.Permissions)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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") }
|