From d217ea950271608a49665529b3744efaf843e391 Mon Sep 17 00:00:00 2001 From: Kacper Rzetelski Date: Wed, 10 May 2023 17:06:21 +0200 Subject: [PATCH] Add path exclusion support to BasicAuth authentication --- docs/web-configuration.md | 4 + web/authentication/authenticator.go | 50 ++++ web/authentication/authenticator_test.go | 92 +++++++ web/authentication/basicauth/basicauth.go | 84 ++++++ .../basicauth/basicauth_test.go | 252 ++++++++++++++++++ web/{ => authentication/basicauth}/cache.go | 2 +- .../basicauth}/cache_test.go | 2 +- web/authentication/chain/chain.go | 51 ++++ web/authentication/chain/chain_test.go | 165 ++++++++++++ web/authentication/exceptor.go | 65 +++++ web/authentication/exceptor_test.go | 119 +++++++++ web/handler.go | 50 +--- web/handler_test.go | 172 ------------ ...eb_config_users.authexcludedpaths.good.yml | 11 + web/tls_config.go | 45 +++- web/tls_config_test.go | 26 +- 16 files changed, 961 insertions(+), 229 deletions(-) create mode 100644 web/authentication/authenticator.go create mode 100644 web/authentication/authenticator_test.go create mode 100644 web/authentication/basicauth/basicauth.go create mode 100644 web/authentication/basicauth/basicauth_test.go rename web/{ => authentication/basicauth}/cache.go (99%) rename web/{ => authentication/basicauth}/cache_test.go (98%) create mode 100644 web/authentication/chain/chain.go create mode 100644 web/authentication/chain/chain_test.go create mode 100644 web/authentication/exceptor.go create mode 100644 web/authentication/exceptor_test.go create mode 100644 web/testdata/web_config_users.authexcludedpaths.good.yml diff --git a/docs/web-configuration.md b/docs/web-configuration.md index 4042fd0a..970f7796 100644 --- a/docs/web-configuration.md +++ b/docs/web-configuration.md @@ -107,6 +107,10 @@ http_server_config: # required. Passwords are hashed with bcrypt. basic_auth_users: [ : ... ] + +# A list of HTTP paths to be excepted from authentication. +auth_excluded_paths: +[ - ] ``` [A sample configuration file](web-config.yml) is provided. diff --git a/web/authentication/authenticator.go b/web/authentication/authenticator.go new file mode 100644 index 00000000..d3e23b79 --- /dev/null +++ b/web/authentication/authenticator.go @@ -0,0 +1,50 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authentication + +import ( + "net/http" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" +) + +type Authenticator interface { + Authenticate(*http.Request) (bool, string, error) +} + +type AuthenticatorFunc func(r *http.Request) (bool, string, error) + +func (f AuthenticatorFunc) Authenticate(r *http.Request) (bool, string, error) { + return f(r) +} + +func WithAuthentication(handler http.Handler, authenticator Authenticator, logger log.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ok, reason, err := authenticator.Authenticate(r) + if err != nil { + level.Error(logger).Log("msg", "Error authenticating", "URI", r.RequestURI, "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if ok { + handler.ServeHTTP(w, r) + return + } + + level.Warn(logger).Log("msg", "Unauthenticated request", "URI", r.RequestURI, "reason", reason) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + }) +} diff --git a/web/authentication/authenticator_test.go b/web/authentication/authenticator_test.go new file mode 100644 index 00000000..75faec79 --- /dev/null +++ b/web/authentication/authenticator_test.go @@ -0,0 +1,92 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authentication + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-kit/log" +) + +func TestWithAuthentication(t *testing.T) { + logger := &noOpLogger{} + + ts := []struct { + Name string + Authenticator Authenticator + ExpectedStatusCode int + }{ + { + Name: "Accepting authenticator", + Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, error) { + return true, "", nil + }), + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "Denying authenticator", + Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, error) { + return false, "", nil + }), + ExpectedStatusCode: http.StatusUnauthorized, + }, + { + Name: "Erroring authenticator", + Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, error) { + return false, "", errors.New("error authenticating") + }), + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range ts { + t.Run(tt.Name, func(t *testing.T) { + req := makeDefaultRequest(t) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + authHandler := WithAuthentication(handler, tt.Authenticator, logger) + authHandler.ServeHTTP(rr, req) + got := rr.Result() + + if tt.ExpectedStatusCode != got.StatusCode { + t.Errorf("Expected status code %q, got %q", tt.ExpectedStatusCode, got.Status) + } + }) + } +} + +type noOpLogger struct{} + +func (noOpLogger) Log(...interface{}) error { + return nil +} + +var _ log.Logger = &noOpLogger{} + +func makeDefaultRequest(t *testing.T) *http.Request { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatalf("Error creating request: %v", err) + } + return req +} diff --git a/web/authentication/basicauth/basicauth.go b/web/authentication/basicauth/basicauth.go new file mode 100644 index 00000000..f7d8f4d8 --- /dev/null +++ b/web/authentication/basicauth/basicauth.go @@ -0,0 +1,84 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package basicauth + +import ( + "encoding/hex" + "net/http" + "strings" + "sync" + + "github.com/prometheus/common/config" + "github.com/prometheus/exporter-toolkit/web/authentication" + "golang.org/x/crypto/bcrypt" +) + +type BasicAuthAuthenticator struct { + users map[string]config.Secret + + cache *cache + // bcryptMtx is there to ensure that bcrypt.CompareHashAndPassword is run + // only once in parallel as this is CPU intensive. + bcryptMtx sync.Mutex +} + +func (b *BasicAuthAuthenticator) Authenticate(r *http.Request) (bool, string, error) { + user, pass, auth := r.BasicAuth() + + if !auth { + return false, "No credentials in request", nil + } + + hashedPassword, validUser := b.users[user] + + if !validUser { + // The user is not found. Use a fixed password hash to + // prevent user enumeration by timing requests. + // This is a bcrypt-hashed version of "fakepassword". + hashedPassword = "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSi" + } + + cacheKey := strings.Join( + []string{ + hex.EncodeToString([]byte(user)), + hex.EncodeToString([]byte(hashedPassword)), + hex.EncodeToString([]byte(pass)), + }, ":") + authOk, ok := b.cache.get(cacheKey) + + if !ok { + // This user, hashedPassword, password is not cached. + b.bcryptMtx.Lock() + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(pass)) + b.bcryptMtx.Unlock() + + authOk = validUser && err == nil + b.cache.set(cacheKey, authOk) + } + + if authOk && validUser { + return true, "", nil + } + + return false, "Invalid credentials", nil +} + +func NewBasicAuthAuthenticator(users map[string]config.Secret) authentication.Authenticator { + return &BasicAuthAuthenticator{ + cache: newCache(), + users: users, + } +} + +var _ authentication.Authenticator = &BasicAuthAuthenticator{} diff --git a/web/authentication/basicauth/basicauth_test.go b/web/authentication/basicauth/basicauth_test.go new file mode 100644 index 00000000..23cd94fe --- /dev/null +++ b/web/authentication/basicauth/basicauth_test.go @@ -0,0 +1,252 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package basicauth + +import ( + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/go-kit/log" + config_util "github.com/prometheus/common/config" + "github.com/prometheus/exporter-toolkit/web/authentication" +) + +func TestBasicAuthAuthenticator_Authenticate(t *testing.T) { + ts := []struct { + Name string + + Users map[string]config_util.Secret + Username string + Password string + + ExpectAuthenticated bool + ExpectedResponse string + ExpectedError error + }{ + { + Name: "Existing user, correct password", + Users: map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + }, + Username: "alice", + Password: "alice123", + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Existing user, incorrect password", + Users: map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + }, + Username: "alice", + Password: "alice1234", + ExpectAuthenticated: false, + ExpectedResponse: "Invalid credentials", + ExpectedError: nil, + }, + { + Name: "Nonexisting user", + Users: map[string]config_util.Secret{ + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + "carol": "$2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu", + }, + Username: "alice", + Password: "alice123", + ExpectAuthenticated: false, + ExpectedResponse: "Invalid credentials", + ExpectedError: nil, + }, + } + + for _, tt := range ts { + t.Run(tt.Name, func(t *testing.T) { + req := makeDefaultRequest(t) + req.SetBasicAuth(tt.Username, tt.Password) + + a := NewBasicAuthAuthenticator(tt.Users) + authenticated, response, err := a.Authenticate(req) + + if err != nil && tt.ExpectedError == nil { + t.Errorf("Got unexpected error: %v", err) + } + + if err == nil && tt.ExpectedError != nil { + t.Errorf("Expected error %v, got none", tt.ExpectedError) + } + + if err != nil && tt.ExpectedError != nil && !errors.Is(err, tt.ExpectedError) { + t.Errorf("Expected error %v, got %v", tt.ExpectedError, err) + } + + if tt.ExpectedResponse != response { + t.Errorf("Expected response %v, got %v", tt.ExpectedResponse, response) + } + + if tt.ExpectAuthenticated != authenticated { + t.Errorf("Expected authenticated %v, got %v", tt.ExpectAuthenticated, authenticated) + } + }) + } +} + +// TestWithAuthentication_BasicAuthAuthenticator_Cache validates that the cache is working by calling a password +// protected endpoint multiple times. +func TestWithAuthentication_BasicAuthAuthenticator_Cache(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + users := map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + "carol": "$2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu", + "dave": "$2y$10$2UXri9cIDdgeKjBo4Rlpx.U3ZLDV8X1IxKmsfOvhcM5oXQt/mLmXq", + } + + authenticator := NewBasicAuthAuthenticator(users) + authHandler := authentication.WithAuthentication(handler, authenticator, noOpLogger{}) + + login := func(username, password string, expectedStatusCode int) { + req := makeDefaultRequest(t) + req.SetBasicAuth(username, password) + + rr := httptest.NewRecorder() + authHandler.ServeHTTP(rr, req) + + res := rr.Result() + if expectedStatusCode != res.StatusCode { + t.Fatalf("Expected status code %d, got %d", expectedStatusCode, res.StatusCode) + } + } + + // Initial logins, checking that it just works. + login("alice", "alice123", 200) + login("alice", "alice1234", 401) + + var ( + start = make(chan struct{}) + wg sync.WaitGroup + ) + wg.Add(300) + for i := 0; i < 150; i++ { + go func() { + <-start + login("alice", "alice123", 200) + wg.Done() + }() + go func() { + <-start + login("alice", "alice1234", 401) + wg.Done() + }() + } + close(start) + wg.Wait() +} + +// TestWithAuthentication_BasicAuthAuthenticator_WithFakepassword validates that we can't login the "fakepassword" used +// to prevent user enumeration. +func TestWithAuthentication_BasicAuthAuthenticator_WithFakepassword(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + users := map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + "carol": "$2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu", + "dave": "$2y$10$2UXri9cIDdgeKjBo4Rlpx.U3ZLDV8X1IxKmsfOvhcM5oXQt/mLmXq", + } + + authenticator := NewBasicAuthAuthenticator(users) + authHandler := authentication.WithAuthentication(handler, authenticator, noOpLogger{}) + + expectedStatusCode := http.StatusUnauthorized + login := func() { + req := makeDefaultRequest(t) + req.SetBasicAuth("fakeuser", "fakepassword") + + rr := httptest.NewRecorder() + authHandler.ServeHTTP(rr, req) + + res := rr.Result() + if expectedStatusCode != res.StatusCode { + t.Fatalf("Expected status code %d, got %d", expectedStatusCode, res.StatusCode) + } + } + + // Login with a cold cache. + login() + // Login with the response cached. + login() +} + +// TestWithAuthentication_BasicAuthAuthenticator_BypassBasicAuthVuln tests for CVE-2022-46146. +func TestWithAuthentication_BasicAuthAuthenticator_BypassBasicAuthVuln(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + users := map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + "carol": "$2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu", + "dave": "$2y$10$2UXri9cIDdgeKjBo4Rlpx.U3ZLDV8X1IxKmsfOvhcM5oXQt/mLmXq", + } + + authenticator := NewBasicAuthAuthenticator(users) + authHandler := authentication.WithAuthentication(handler, authenticator, noOpLogger{}) + + expectedStatusCode := http.StatusUnauthorized + login := func(username, password string) { + req := makeDefaultRequest(t) + req.SetBasicAuth(username, password) + + rr := httptest.NewRecorder() + authHandler.ServeHTTP(rr, req) + + res := rr.Result() + if expectedStatusCode != res.StatusCode { + t.Fatalf("Expected status code %d, got %d", expectedStatusCode, res.StatusCode) + } + } + + // Poison the cache. + login("alice$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", "fakepassword") + // Login with a wrong password. + login("alice", "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSifakepassword") +} + +type noOpLogger struct{} + +func (noOpLogger) Log(...interface{}) error { + return nil +} + +var _ log.Logger = &noOpLogger{} + +func makeDefaultRequest(t *testing.T) *http.Request { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatalf("Error creating request: %v", err) + } + return req +} diff --git a/web/cache.go b/web/authentication/basicauth/cache.go similarity index 99% rename from web/cache.go rename to web/authentication/basicauth/cache.go index 9425e7ac..8402a057 100644 --- a/web/cache.go +++ b/web/authentication/basicauth/cache.go @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package web +package basicauth import ( weakrand "math/rand" diff --git a/web/cache_test.go b/web/authentication/basicauth/cache_test.go similarity index 98% rename from web/cache_test.go rename to web/authentication/basicauth/cache_test.go index 4ba1eff9..8c1adf13 100644 --- a/web/cache_test.go +++ b/web/authentication/basicauth/cache_test.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package web +package basicauth import ( "fmt" diff --git a/web/authentication/chain/chain.go b/web/authentication/chain/chain.go new file mode 100644 index 00000000..fcfe1925 --- /dev/null +++ b/web/authentication/chain/chain.go @@ -0,0 +1,51 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chain + +import ( + "net/http" + "strings" + + "github.com/prometheus/exporter-toolkit/web/authentication" +) + +type ChainAuthenticator []authentication.Authenticator + +func (c ChainAuthenticator) Authenticate(r *http.Request) (bool, string, error) { + var reasons []string + + for _, a := range c { + ok, reason, err := a.Authenticate(r) + if err != nil { + return false, "", err + } + + if !ok { + return false, reason, nil + } + + if len(reason) > 0 { + reasons = append(reasons, reason) + } + } + + reason := strings.Join(reasons, ";") + return true, reason, nil +} + +func NewChainAuthenticator(authenticators []authentication.Authenticator) authentication.Authenticator { + return ChainAuthenticator(authenticators) +} + +var _ authentication.Authenticator = &ChainAuthenticator{} diff --git a/web/authentication/chain/chain_test.go b/web/authentication/chain/chain_test.go new file mode 100644 index 00000000..833a8eca --- /dev/null +++ b/web/authentication/chain/chain_test.go @@ -0,0 +1,165 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chain + +import ( + "errors" + "net/http" + "testing" + + "github.com/go-kit/log" + "github.com/prometheus/exporter-toolkit/web/authentication" +) + +func TestChainAuthenticator_Authenticate(t *testing.T) { + firstAuthenticatorErr := errors.New("first authenticator error") + secondAuthenticatorErr := errors.New("second authenticator error") + + ts := []struct { + Name string + + AuthenticatorsFn func(t *testing.T) []authentication.Authenticator + + ExpectAuthenticated bool + ExpectedResponse string + ExpectedError error + }{ + { + Name: "First authenticator denies, the rest is not called, chain denies", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return false, "First authenticator denied the request.", nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + t.Fatalf("Expected second authenticator not to be called, it was.") + return true, "", nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedResponse: "First authenticator denied the request.", + ExpectedError: nil, + }, + { + Name: "First authenticator accepts, second is called and denies, chain denies", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return true, "", nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return false, "Second authenticator denied the request.", nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedResponse: "Second authenticator denied the request.", + ExpectedError: nil, + }, + { + Name: "All authenticators accept, chain accepts", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return true, "", nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return true, "", nil + }), + } + }, + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "First authenticator returns an error, the rest is not called, chain returns an error", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return false, "", firstAuthenticatorErr + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + t.Fatalf("Expected second authenticator not to be called, it was.") + return true, "", nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedError: firstAuthenticatorErr, + }, + { + Name: "First authenticator accepts the request, second authenticator returns an error, chain returns an error", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return true, "", nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + return false, "", secondAuthenticatorErr + }), + } + }, + ExpectAuthenticated: false, + ExpectedError: secondAuthenticatorErr, + }, + } + + for _, tt := range ts { + t.Run(tt.Name, func(t *testing.T) { + req := makeDefaultRequest(t) + + a := NewChainAuthenticator(tt.AuthenticatorsFn(t)) + authenticated, response, err := a.Authenticate(req) + + if err != nil && tt.ExpectedError == nil { + t.Errorf("Got unexpected error: %v", err) + } + + if err == nil && tt.ExpectedError != nil { + t.Errorf("Expected error %v, got none", tt.ExpectedError) + } + + if err != nil && tt.ExpectedError != nil && !errors.Is(err, tt.ExpectedError) { + t.Errorf("Expected error %v, got %v", tt.ExpectedError, err) + } + + if tt.ExpectedResponse != response { + t.Errorf("Expected response %v, got %v", tt.ExpectedResponse, response) + } + + if tt.ExpectAuthenticated != authenticated { + t.Errorf("Expected authenticated %v, got %v", tt.ExpectAuthenticated, authenticated) + } + }) + } +} + +type noOpLogger struct{} + +func (noOpLogger) Log(...interface{}) error { + return nil +} + +var _ log.Logger = &noOpLogger{} + +func makeDefaultRequest(t *testing.T) *http.Request { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatalf("Error creating request: %v", err) + } + return req +} diff --git a/web/authentication/exceptor.go b/web/authentication/exceptor.go new file mode 100644 index 00000000..906ef7b2 --- /dev/null +++ b/web/authentication/exceptor.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authentication + +import ( + "net/http" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" +) + +type Exceptor interface { + IsExcepted(r *http.Request) bool +} + +type ExceptorFunc func(*http.Request) bool + +func (f ExceptorFunc) IsExcepted(r *http.Request) bool { + return f(r) +} + +func WithExceptor(handler http.Handler, authenticator Authenticator, exceptor Exceptor, logger log.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if exceptor.IsExcepted(r) { + level.Debug(logger).Log("msg", "Excepting request from authentication", "URI", r.RequestURI) + handler.ServeHTTP(w, r) + return + } + + authHandler := WithAuthentication(handler, authenticator, logger) + authHandler.ServeHTTP(w, r) + }) +} + +type PathExceptor struct { + excludedPaths map[string]bool +} + +func (p PathExceptor) IsExcepted(r *http.Request) bool { + return p.excludedPaths[r.URL.Path] +} + +func NewPathExceptor(excludedPaths []string) Exceptor { + excludedPathSet := make(map[string]bool, len(excludedPaths)) + for _, p := range excludedPaths { + excludedPathSet[p] = true + } + + return &PathExceptor{ + excludedPaths: excludedPathSet, + } +} + +var _ Exceptor = &PathExceptor{} diff --git a/web/authentication/exceptor_test.go b/web/authentication/exceptor_test.go new file mode 100644 index 00000000..fa71df08 --- /dev/null +++ b/web/authentication/exceptor_test.go @@ -0,0 +1,119 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authentication + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPathAuthenticationExceptor_IsExcepted(t *testing.T) { + ts := []struct { + Name string + ExcludedPaths []string + URI string + ExpectedExcepted bool + }{ + { + Name: "Path not excepted", + ExcludedPaths: []string{"/somepath"}, + URI: "/someotherpath", + ExpectedExcepted: false, + }, + { + Name: "Exact path excepted (single)", + ExcludedPaths: []string{"/somepath"}, + URI: "/somepath", + ExpectedExcepted: true, + }, + { + Name: "Exact path excepted (multiple)", + ExcludedPaths: []string{"/somepath", "/someotherpath"}, + URI: "/somepath", + ExpectedExcepted: true, + }, + } + + for _, tt := range ts { + t.Run(tt.Name, func(t *testing.T) { + tt := tt + req, _ := http.NewRequest(http.MethodGet, tt.URI, nil) + + exceptor := NewPathExceptor(tt.ExcludedPaths) + excepted := exceptor.IsExcepted(req) + + if tt.ExpectedExcepted && !excepted { + t.Fatal("Expected path to be excepted, it wasn't") + } + + if !tt.ExpectedExcepted && excepted { + t.Fatalf("Expected path to not be excepted, it was") + } + }) + } +} + +func TestWithAuthenticationExceptor(t *testing.T) { + logger := &noOpLogger{} + + ts := []struct { + Name string + Exceptor Exceptor + ExpectedAuthenticatorCalled bool + }{ + { + Name: "Authenticator not called", + Exceptor: ExceptorFunc(func(r *http.Request) bool { + return true + }), + ExpectedAuthenticatorCalled: false, + }, + { + Name: "Authenticator called", + Exceptor: ExceptorFunc(func(r *http.Request) bool { + return false + }), + ExpectedAuthenticatorCalled: true, + }, + } + + for _, tt := range ts { + t.Run(tt.Name, func(t *testing.T) { + req := makeDefaultRequest(t) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + authenticatorCalled := false + authenticator := AuthenticatorFunc(func(r *http.Request) (bool, string, error) { + authenticatorCalled = true + return false, "", nil + }) + + rr := httptest.NewRecorder() + exceptorHandler := WithExceptor(handler, authenticator, tt.Exceptor, logger) + exceptorHandler.ServeHTTP(rr, req) + + if tt.ExpectedAuthenticatorCalled && !authenticatorCalled { + t.Error("Expected authenticator to be called, it wasn't") + } + + if !tt.ExpectedAuthenticatorCalled && authenticatorCalled { + t.Error("Expected authenticator to not be called, it was") + } + }) + } +} diff --git a/web/handler.go b/web/handler.go index c607a163..99149987 100644 --- a/web/handler.go +++ b/web/handler.go @@ -16,11 +16,8 @@ package web import ( - "encoding/hex" "fmt" "net/http" - "strings" - "sync" "github.com/go-kit/log" "golang.org/x/crypto/bcrypt" @@ -79,10 +76,6 @@ type webHandler struct { tlsConfigPath string handler http.Handler logger log.Logger - cache *cache - // bcryptMtx is there to ensure that bcrypt.CompareHashAndPassword is run - // only once in parallel as this is CPU intensive. - bcryptMtx sync.Mutex } func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -98,46 +91,5 @@ func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set(k, v) } - if len(c.Users) == 0 { - u.handler.ServeHTTP(w, r) - return - } - - user, pass, auth := r.BasicAuth() - if auth { - hashedPassword, validUser := c.Users[user] - - if !validUser { - // The user is not found. Use a fixed password hash to - // prevent user enumeration by timing requests. - // This is a bcrypt-hashed version of "fakepassword". - hashedPassword = "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSi" - } - - cacheKey := strings.Join( - []string{ - hex.EncodeToString([]byte(user)), - hex.EncodeToString([]byte(hashedPassword)), - hex.EncodeToString([]byte(pass)), - }, ":") - authOk, ok := u.cache.get(cacheKey) - - if !ok { - // This user, hashedPassword, password is not cached. - u.bcryptMtx.Lock() - err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(pass)) - u.bcryptMtx.Unlock() - - authOk = validUser && err == nil - u.cache.set(cacheKey, authOk) - } - - if authOk && validUser { - u.handler.ServeHTTP(w, r) - return - } - } - - w.Header().Set("WWW-Authenticate", "Basic") - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + u.handler.ServeHTTP(w, r) } diff --git a/web/handler_test.go b/web/handler_test.go index 80d594a9..ccc934aa 100644 --- a/web/handler_test.go +++ b/web/handler_test.go @@ -17,182 +17,10 @@ import ( "context" "net" "net/http" - "sync" "testing" "time" ) -// TestBasicAuthCache validates that the cache is working by calling a password -// protected endpoint multiple times. -func TestBasicAuthCache(t *testing.T) { - server := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello World!")) - }), - } - - done := make(chan struct{}) - t.Cleanup(func() { - if err := server.Shutdown(context.Background()); err != nil { - t.Fatal(err) - } - <-done - }) - - go func() { - flags := FlagConfig{ - WebListenAddresses: &([]string{port}), - WebSystemdSocket: OfBool(false), - WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"), - } - ListenAndServe(server, &flags, testlogger) - close(done) - }() - - waitForPort(t, port) - - login := func(username, password string, code int) { - client := &http.Client{} - req, err := http.NewRequest("GET", "http://localhost"+port, nil) - if err != nil { - t.Fatal(err) - } - req.SetBasicAuth(username, password) - r, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - if r.StatusCode != code { - t.Fatalf("bad return code, expected %d, got %d", code, r.StatusCode) - } - } - - // Initial logins, checking that it just works. - login("alice", "alice123", 200) - login("alice", "alice1234", 401) - - var ( - start = make(chan struct{}) - wg sync.WaitGroup - ) - wg.Add(300) - for i := 0; i < 150; i++ { - go func() { - <-start - login("alice", "alice123", 200) - wg.Done() - }() - go func() { - <-start - login("alice", "alice1234", 401) - wg.Done() - }() - } - close(start) - wg.Wait() -} - -// TestBasicAuthWithFakePassword validates that we can't login the "fakepassword" used in -// to prevent user enumeration. -func TestBasicAuthWithFakepassword(t *testing.T) { - server := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello World!")) - }), - } - - done := make(chan struct{}) - t.Cleanup(func() { - if err := server.Shutdown(context.Background()); err != nil { - t.Fatal(err) - } - <-done - }) - - go func() { - flags := FlagConfig{ - WebListenAddresses: &([]string{port}), - WebSystemdSocket: OfBool(false), - WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"), - } - ListenAndServe(server, &flags, testlogger) - close(done) - }() - - waitForPort(t, port) - - login := func() { - client := &http.Client{} - req, err := http.NewRequest("GET", "http://localhost"+port, nil) - if err != nil { - t.Fatal(err) - } - req.SetBasicAuth("fakeuser", "fakepassword") - r, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - if r.StatusCode != 401 { - t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode) - } - } - - // Login with a cold cache. - login() - // Login with the response cached. - login() -} - -// TestByPassBasicAuthVuln tests for CVE-2022-46146. -func TestByPassBasicAuthVuln(t *testing.T) { - server := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello World!")) - }), - } - - done := make(chan struct{}) - t.Cleanup(func() { - if err := server.Shutdown(context.Background()); err != nil { - t.Fatal(err) - } - <-done - }) - - go func() { - flags := FlagConfig{ - WebListenAddresses: &([]string{port}), - WebSystemdSocket: OfBool(false), - WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"), - } - ListenAndServe(server, &flags, testlogger) - close(done) - }() - - waitForPort(t, port) - - login := func(username, password string) { - client := &http.Client{} - req, err := http.NewRequest("GET", "http://localhost"+port, nil) - if err != nil { - t.Fatal(err) - } - req.SetBasicAuth(username, password) - r, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - if r.StatusCode != 401 { - t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode) - } - } - - // Poison the cache. - login("alice$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", "fakepassword") - // Login with a wrong password. - login("alice", "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSifakepassword") -} - // TestHTTPHeaders validates that HTTP headers are added correctly. func TestHTTPHeaders(t *testing.T) { server := &http.Server{ diff --git a/web/testdata/web_config_users.authexcludedpaths.good.yml b/web/testdata/web_config_users.authexcludedpaths.good.yml new file mode 100644 index 00000000..6d4cbb38 --- /dev/null +++ b/web/testdata/web_config_users.authexcludedpaths.good.yml @@ -0,0 +1,11 @@ +tls_server_config: + cert_file: "server.crt" + key_file: "server.key" +basic_auth_users: + alice: $2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby + bob: $2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR. + carol: $2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu + dave: $2y$10$2UXri9cIDdgeKjBo4Rlpx.U3ZLDV8X1IxKmsfOvhcM5oXQt/mLmXq + +auth_excluded_paths: +- "/somepath" diff --git a/web/tls_config.go b/web/tls_config.go index 4ef31a3f..87856505 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -27,6 +27,9 @@ import ( "github.com/go-kit/log" "github.com/go-kit/log/level" config_util "github.com/prometheus/common/config" + "github.com/prometheus/exporter-toolkit/web/authentication" + basicauth_authentication "github.com/prometheus/exporter-toolkit/web/authentication/basicauth" + chain_authentication "github.com/prometheus/exporter-toolkit/web/authentication/chain" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" ) @@ -37,9 +40,10 @@ var ( ) type Config struct { - TLSConfig TLSConfig `yaml:"tls_server_config"` - HTTPConfig HTTPConfig `yaml:"http_server_config"` - Users map[string]config_util.Secret `yaml:"basic_auth_users"` + TLSConfig TLSConfig `yaml:"tls_server_config"` + HTTPConfig HTTPConfig `yaml:"http_server_config"` + Users map[string]config_util.Secret `yaml:"basic_auth_users"` + AuthExcludedPaths []string `yaml:"auth_excluded_paths"` } type TLSConfig struct { @@ -268,6 +272,36 @@ func ListenAndServe(server *http.Server, flags *FlagConfig, logger log.Logger) e return ServeMultiple(listeners, server, flags, logger) } +func withRequestAuthentication(handler http.Handler, webConfigPath string, logger log.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := getConfig(webConfigPath) + if err != nil { + level.Error(logger).Log("msg", "Error parsing configuration", "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + authenticators := make([]authentication.Authenticator, 0) + + if len(c.Users) > 0 { + basicAuthAuthenticator := basicauth_authentication.NewBasicAuthAuthenticator(c.Users) + authenticators = append(authenticators, basicAuthAuthenticator) + } + + authenticator := chain_authentication.NewChainAuthenticator(authenticators) + + if len(c.AuthExcludedPaths) == 0 { + authHandler := authentication.WithAuthentication(handler, authenticator, logger) + authHandler.ServeHTTP(w, r) + return + } + + exceptor := authentication.NewPathExceptor(c.AuthExcludedPaths) + exceptorHandler := authentication.WithExceptor(handler, authenticator, exceptor, logger) + exceptorHandler.ServeHTTP(w, r) + }) +} + // Server starts the server on the given listener. Based on the file path // WebConfigFile in the FlagConfig, TLS or basic auth could be enabled. func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger log.Logger) error { @@ -288,6 +322,8 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger log.Lo handler = server.Handler } + authHandler := withRequestAuthentication(handler, tlsConfigPath, logger) + c, err := getConfig(tlsConfigPath) if err != nil { return err @@ -296,8 +332,7 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger log.Lo server.Handler = &webHandler{ tlsConfigPath: tlsConfigPath, logger: logger, - handler: handler, - cache: newCache(), + handler: authHandler, } config, err := ConfigToTLSConfig(&c.TLSConfig) diff --git a/web/tls_config_test.go b/web/tls_config_test.go index b2479338..0d155dc7 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -24,6 +24,7 @@ import ( "io" "net" "net/http" + "net/url" "os" "regexp" "sync" @@ -100,6 +101,7 @@ type TestInputs struct { Username string Password string ClientCertificate string + URI string } func TestYAMLFiles(t *testing.T) { @@ -502,7 +504,11 @@ func (test *TestInputs) Test(t *testing.T) { client = http.DefaultClient proto = "http" } - req, err := http.NewRequest("GET", proto+"://localhost"+port, nil) + path, err := url.JoinPath(proto+"://localhost"+port, test.URI) + if err != nil { + t.Fatalf("Can't join url path: %v", err) + } + req, err := http.NewRequest("GET", path, nil) if err != nil { t.Error(err) } @@ -686,6 +692,24 @@ func TestUsers(t *testing.T) { Password: "nonexistent", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `with bad username, TLS and auth_excluded_paths (path not matching)`, + YAMLConfigPath: "testdata/web_config_users.authexcludedpaths.good.yml", + UseTLSClient: true, + Username: "nonexistent", + Password: "nonexistent", + URI: "/someotherpath", + ExpectedError: ErrorMap["Unauthorized"], + }, + { + Name: `with bad username, TLS and auth_excluded_paths (path matching)`, + YAMLConfigPath: "testdata/web_config_users.authexcludedpaths.good.yml", + UseTLSClient: true, + Username: "nonexistent", + Password: "nonexistent", + URI: "/somepath", + ExpectedError: nil, + }, } for _, testInputs := range testTables { t.Run(testInputs.Name, testInputs.Test)