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") }