From afa46160319dabc474e950fabb6496bc1f11fcfb Mon Sep 17 00:00:00 2001 From: Kacper Rzetelski Date: Wed, 10 May 2023 17:06:21 +0200 Subject: [PATCH 1/2] 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) From 9705f1ad0610e3aad0fd6296c0de43e287893c1e Mon Sep 17 00:00:00 2001 From: Kacper Rzetelski Date: Wed, 10 May 2023 17:17:22 +0200 Subject: [PATCH 2/2] Add path exclusion support to mTLS authentication --- .../x509/testdata/client2_selfsigned.pem | 12 + .../x509/testdata/client_selfsigned.pem | 12 + web/authentication/x509/x509.go | 117 ++++++++ web/authentication/x509/x509_test.go | 256 ++++++++++++++++++ ...erifyclientcert.authexcludedpaths.good.yml | 8 + ...auth_client_san.authexcludedpaths.bad.yaml | 10 + web/tls_config.go | 137 ++++++++-- web/tls_config_test.go | 50 +++- 8 files changed, 573 insertions(+), 29 deletions(-) create mode 100644 web/authentication/x509/testdata/client2_selfsigned.pem create mode 100644 web/authentication/x509/testdata/client_selfsigned.pem create mode 100644 web/authentication/x509/x509.go create mode 100644 web/authentication/x509/x509_test.go create mode 100644 web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml create mode 100644 web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml diff --git a/web/authentication/x509/testdata/client2_selfsigned.pem b/web/authentication/x509/testdata/client2_selfsigned.pem new file mode 100644 index 00000000..be1426c4 --- /dev/null +++ b/web/authentication/x509/testdata/client2_selfsigned.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB3DCCAWGgAwIBAgIUJVN8KehL1MmccvLb/mHthSMfnnswCgYIKoZIzj0EAwIw +EDEOMAwGA1UEAwwFdGVzdDMwIBcNMjMwMTEwMTgxMTAwWhgPMjEyMjEyMTcxODEx +MDBaMBAxDjAMBgNVBAMMBXRlc3QzMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEf8wC +qU9e4lPZZqJMA4nJ84rLPdfryoUI8tquBAHtae4yfXP3z6Hz92XdPaS4ZAFDjTLt +Jsl45KYixNb7y9dtbVoNxNxdDC4ywaoklqkpBGY0I9GEpNzaBll/4DIJvGcgo3ow +eDAdBgNVHQ4EFgQUvyvu/TnJyRS7OGdujTbWM/W07yMwHwYDVR0jBBgwFoAUvyvu +/TnJyRS7OGdujTbWM/W07yMwDwYDVR0TAQH/BAUwAwEB/zAQBgNVHREECTAHggV0 +ZXN0MzATBgNVHSUEDDAKBggrBgEFBQcDAjAKBggqhkjOPQQDAgNpADBmAjEAt7HK +knE2MzwZ2B2dgn1/q3ikWDiO20Hbd97jo3tmv87FcF2vMqqJpHjcldJqplfsAjEA +sfAz49y6Sf6LNlNS+Fc/lbOOwcrlzC+J5GJ8OmNoQPsvvDvhzGbwFiVw1M2uMqtG +-----END CERTIFICATE----- diff --git a/web/authentication/x509/testdata/client_selfsigned.pem b/web/authentication/x509/testdata/client_selfsigned.pem new file mode 100644 index 00000000..d25ddca8 --- /dev/null +++ b/web/authentication/x509/testdata/client_selfsigned.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBxzCCAU2gAwIBAgIUGCNnsX0qd0HD7UaQsx67ze0UaNowCgYIKoZIzj0EAwIw +DzENMAsGA1UEAwwEdGVzdDAgFw0yMTA4MjAxNDQ5MTRaGA8yMTIxMDcyNzE0NDkx +NFowDzENMAsGA1UEAwwEdGVzdDB2MBAGByqGSM49AgEGBSuBBAAiA2IABLFRLjQB +XViHUAEIsKglwb0HxPC/+CDa1TTOp1b0WErYW7Xcx5mRNEksVWAXOWYKPej10hfy +JSJE/2NiRAbrAcPjiRv01DgDt+OzwM4A0ZYqBj/3qWJKH/Kc8oKhY41bzKNoMGYw +HQYDVR0OBBYEFPRbKtRBgw+AZ0b6T8oWw/+QoyjaMB8GA1UdIwQYMBaAFPRbKtRB +gw+AZ0b6T8oWw/+QoyjaMA8GA1UdEwEB/wQFMAMBAf8wEwYDVR0lBAwwCgYIKwYB +BQUHAwIwCgYIKoZIzj0EAwIDaAAwZQIwZqwXMJiTycZdmLN+Pwk/8Sb7wQazbocb +16Zw5mZXqFJ4K+74OQMZ33i82hYohtE/AjEAn0a8q8QupgiXpr0I/PvGTRKqLQRM +0mptBvpn/DcB2p3Hi80GJhtchz9Z0OqbMX4S +-----END CERTIFICATE----- diff --git a/web/authentication/x509/x509.go b/web/authentication/x509/x509.go new file mode 100644 index 00000000..c2fd297a --- /dev/null +++ b/web/authentication/x509/x509.go @@ -0,0 +1,117 @@ +// 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 x509 + +import ( + "crypto/x509" + "encoding/hex" + "fmt" + "net/http" + "strings" + + "github.com/prometheus/exporter-toolkit/web/authentication" +) + +type RequireClientCertsFunc func() bool + +type VerifyOptionsFunc func() x509.VerifyOptions + +type VerifyPeerCertificateFunc func([][]byte, [][]*x509.Certificate) error + +type X509Authenticator struct { + requireClientCertsFn RequireClientCertsFunc + verifyOptionsFn VerifyOptionsFunc + verifyPeerCertificateFn VerifyPeerCertificateFunc +} + +func columnSeparatedHex(d []byte) string { + h := strings.ToUpper(hex.EncodeToString(d)) + var sb strings.Builder + for i, r := range h { + sb.WriteRune(r) + if i%2 == 1 && i != len(h)-1 { + sb.WriteRune(':') + } + } + return sb.String() +} + +func certificateIdentifier(c *x509.Certificate) string { + return fmt.Sprintf( + "SN=%d, SKID=%s, AKID=%s", + c.SerialNumber, + columnSeparatedHex(c.SubjectKeyId), + columnSeparatedHex(c.AuthorityKeyId), + ) +} + +func (x *X509Authenticator) Authenticate(r *http.Request) (bool, string, error) { + if r.TLS == nil { + return false, "No TLS connection state in request", nil + } + + if len(r.TLS.PeerCertificates) == 0 && x.requireClientCertsFn() { + return false, "A certificate is required to be sent by the client.", nil + } + + var verifiedChains [][]*x509.Certificate + if len(r.TLS.PeerCertificates) > 0 && x.verifyOptionsFn != nil { + opts := x.verifyOptionsFn() + if opts.Intermediates == nil && len(r.TLS.PeerCertificates) > 1 { + opts.Intermediates = x509.NewCertPool() + for _, cert := range r.TLS.PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + } + + chains, err := r.TLS.PeerCertificates[0].Verify(opts) + if err != nil { + return false, fmt.Sprintf("verifying certificate %s failed: %v", certificateIdentifier(r.TLS.PeerCertificates[0]), err), nil + } + + verifiedChains = chains + } + + if x.verifyPeerCertificateFn != nil { + rawCerts := make([][]byte, 0, len(r.TLS.PeerCertificates)) + for _, c := range r.TLS.PeerCertificates { + rawCerts = append(rawCerts, c.Raw) + } + + err := x.verifyPeerCertificateFn(rawCerts, verifiedChains) + if err != nil { + return false, fmt.Sprintf("verifying peer certificate failed: %v", err), nil + } + } + + return true, "", nil +} + +func NewX509Authenticator(requireClientCertsFn RequireClientCertsFunc, verifyOptionsFn VerifyOptionsFunc, verifyPeerCertificateFn VerifyPeerCertificateFunc) authentication.Authenticator { + return &X509Authenticator{ + requireClientCertsFn: requireClientCertsFn, + verifyOptionsFn: verifyOptionsFn, + verifyPeerCertificateFn: verifyPeerCertificateFn, + } +} + +var _ authentication.Authenticator = &X509Authenticator{} + +// DefaultVerifyOptions returns VerifyOptions that use the system root certificates, current time, +// and requires certificates to be valid for client auth (x509.ExtKeyUsageClientAuth) +func DefaultVerifyOptions() x509.VerifyOptions { + return x509.VerifyOptions{ + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } +} diff --git a/web/authentication/x509/x509_test.go b/web/authentication/x509/x509_test.go new file mode 100644 index 00000000..35a5ab15 --- /dev/null +++ b/web/authentication/x509/x509_test.go @@ -0,0 +1,256 @@ +// 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 x509 + +import ( + "crypto/tls" + "crypto/x509" + _ "embed" + "encoding/pem" + "errors" + "net/http" + "testing" + + "github.com/go-kit/log" +) + +//go:embed testdata/client_selfsigned.pem +var clientSelfsignedPEM []byte + +//go:embed testdata/client2_selfsigned.pem +var client2SelfsignedPEM []byte + +func TestX509Authenticator_Authenticate(t *testing.T) { + ts := []struct { + Name string + + RequireClientCertsFn RequireClientCertsFunc + VerifyOptionsFn VerifyOptionsFunc + VerifyPeerCertificateFn VerifyPeerCertificateFunc + + Certs []*x509.Certificate + + ExpectAuthenticated bool + ExpectedReason string + ExpectedError error + }{ + { + Name: "Certs not required, certs not provided", + RequireClientCertsFn: func() bool { + return false + }, + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Certs required, certs not provided", + RequireClientCertsFn: func() bool { + return true + }, + ExpectAuthenticated: false, + ExpectedReason: "A certificate is required to be sent by the client.", + ExpectedError: nil, + }, + { + Name: "Certs not required, no verify, selfsigned cert provided", + RequireClientCertsFn: func() bool { + return false + }, + Certs: getCerts(t, clientSelfsignedPEM), + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Certs required, no verify, selfsigned cert provided", + RequireClientCertsFn: func() bool { + return true + }, + Certs: getCerts(t, clientSelfsignedPEM), + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Certs not required, verify, selfsigned cert provided", + RequireClientCertsFn: func() bool { + return false + }, + VerifyOptionsFn: func() x509.VerifyOptions { + opts := DefaultVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + Certs: getCerts(t, clientSelfsignedPEM), + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Certs not required, verify, no certs provided", + RequireClientCertsFn: func() bool { + return false + }, + VerifyOptionsFn: func() x509.VerifyOptions { + opts := DefaultVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Certs required, verify, selfsigned cert provided", + RequireClientCertsFn: func() bool { + return true + }, + VerifyOptionsFn: func() x509.VerifyOptions { + opts := DefaultVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + Certs: getCerts(t, clientSelfsignedPEM), + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Certs required, verify, invalid selfsigned cert provided", + RequireClientCertsFn: func() bool { + return true + }, + VerifyOptionsFn: func() x509.VerifyOptions { + opts := DefaultVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + Certs: getCerts(t, client2SelfsignedPEM), + ExpectAuthenticated: false, + ExpectedReason: "verifying certificate SN=213094436555767319277040831510558969429548310139," + + " SKID=BF:2B:EE:FD:39:C9:C9:14:BB:38:67:6E:8D:36:D6:33:F5:B4:EF:23," + + " AKID=BF:2B:EE:FD:39:C9:C9:14:BB:38:67:6E:8D:36:D6:33:F5:B4:EF:23" + + " failed: x509: certificate signed by unknown authority", + ExpectedError: nil, + }, + { + Name: "Certs required, verify, selfsigned cert provided, invalid peer certificate", + RequireClientCertsFn: func() bool { + return true + }, + VerifyOptionsFn: func() x509.VerifyOptions { + opts := DefaultVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificateFn: func(_ [][]byte, _ [][]*x509.Certificate) error { + return errors.New("invalid peer certificate") + }, + Certs: getCerts(t, clientSelfsignedPEM), + ExpectAuthenticated: false, + ExpectedReason: "verifying peer certificate failed: invalid peer certificate", + ExpectedError: nil, + }, + { + Name: "RequireAndVerifyClientCert, selfsigned certs, valid peer certificate", + RequireClientCertsFn: func() bool { + return true + }, + VerifyOptionsFn: func() x509.VerifyOptions { + opts := DefaultVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificateFn: func(_ [][]byte, _ [][]*x509.Certificate) error { + return nil + }, + Certs: getCerts(t, clientSelfsignedPEM), + ExpectAuthenticated: true, + ExpectedError: nil, + }, + } + + for _, tt := range ts { + t.Run(tt.Name, func(t *testing.T) { + req := makeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: tt.Certs, + } + + a := NewX509Authenticator(tt.RequireClientCertsFn, tt.VerifyOptionsFn, tt.VerifyPeerCertificateFn) + authenticated, reason, 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.ExpectedReason != reason { + t.Errorf("Expected reason %v, got %v", tt.ExpectedReason, reason) + } + + 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 +} + +func getCertPool(t *testing.T, pemData ...[]byte) *x509.CertPool { + t.Helper() + + pool := x509.NewCertPool() + certs := getCerts(t, pemData...) + for _, c := range certs { + pool.AddCert(c) + } + + return pool +} + +func getCerts(t *testing.T, pemData ...[]byte) []*x509.Certificate { + t.Helper() + + certs := make([]*x509.Certificate, 0) + for _, pd := range pemData { + pemBlock, _ := pem.Decode(pd) + cert, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + t.Fatalf("Error parsing cert: %v", err) + } + certs = append(certs, cert) + } + + return certs +} diff --git a/web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml b/web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml new file mode 100644 index 00000000..2518daf3 --- /dev/null +++ b/web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml @@ -0,0 +1,8 @@ +tls_server_config: + cert_file: "server.crt" + key_file: "server.key" + client_auth_type: "RequireAndVerifyClientCert" + client_ca_file: "client_selfsigned.pem" + +auth_excluded_paths: +- "/somepath" diff --git a/web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml b/web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml new file mode 100644 index 00000000..5157e3a8 --- /dev/null +++ b/web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml @@ -0,0 +1,10 @@ +tls_server_config: + cert_file: "server.crt" + key_file: "server.key" + client_auth_type: "RequireAndVerifyClientCert" + client_ca_file: "client2_selfsigned.pem" + client_allowed_sans: + - "bad" + +auth_excluded_paths: +- "/somepath" diff --git a/web/tls_config.go b/web/tls_config.go index 87856505..a080de46 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -30,6 +30,7 @@ import ( "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" + x509_authentication "github.com/prometheus/exporter-toolkit/web/authentication/x509" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" ) @@ -136,9 +137,46 @@ func getTLSConfig(configPath string) (*tls.Config, error) { return ConfigToTLSConfig(&c.TLSConfig) } +func GetClientCAs(clientCAsPath string) (*x509.CertPool, error) { + clientCAPool := x509.NewCertPool() + if clientCAsPath == "" { + return clientCAPool, nil + } + + clientCAFile, err := os.ReadFile(clientCAsPath) + if err != nil { + return nil, err + } + + clientCAPool.AppendCertsFromPEM(clientCAFile) + + return clientCAPool, nil +} + +func ParseClientAuth(s string) (tls.ClientAuthType, error) { + switch s { + case "RequestClientCert": + return tls.RequestClientCert, nil + case "RequireAnyClientCert", "RequireClientCert": // Preserved for backwards compatibility. + return tls.RequireAnyClientCert, nil + case "VerifyClientCertIfGiven": + return tls.VerifyClientCertIfGiven, nil + case "RequireAndVerifyClientCert": + return tls.RequireAndVerifyClientCert, nil + case "", "NoClientCert": + return tls.NoClientCert, nil + default: + return tls.ClientAuthType(0), errors.New("Invalid ClientAuth: " + s) + } +} + +func isTLSEnabled(c *TLSConfig) bool { + return c.TLSCertPath != "" || c.TLSKeyPath != "" || c.ClientAuth != "" || c.ClientCAs != "" +} + // ConfigToTLSConfig generates the golang tls.Config from the TLSConfig struct. func ConfigToTLSConfig(c *TLSConfig) (*tls.Config, error) { - if c.TLSCertPath == "" && c.TLSKeyPath == "" && c.ClientAuth == "" && c.ClientCAs == "" { + if !isTLSEnabled(c) { return nil, errNoTLSConfig } @@ -189,38 +227,26 @@ func ConfigToTLSConfig(c *TLSConfig) (*tls.Config, error) { cfg.CurvePreferences = cp } - if c.ClientCAs != "" { - clientCAPool := x509.NewCertPool() - clientCAFile, err := os.ReadFile(c.ClientCAs) - if err != nil { - return nil, err - } - clientCAPool.AppendCertsFromPEM(clientCAFile) - cfg.ClientCAs = clientCAPool + clientCAs, err := GetClientCAs(c.ClientCAs) + if err != nil { + return nil, err } + cfg.ClientCAs = clientCAs - if c.ClientAllowedSans != nil { - // verify that the client cert contains an allowed SAN - cfg.VerifyPeerCertificate = c.VerifyPeerCertificate + clientAuth, err := ParseClientAuth(c.ClientAuth) + if err != nil { + return nil, err } + cfg.ClientAuth = clientAuth - switch c.ClientAuth { - case "RequestClientCert": - cfg.ClientAuth = tls.RequestClientCert - case "RequireAnyClientCert", "RequireClientCert": // Preserved for backwards compatibility. - cfg.ClientAuth = tls.RequireAnyClientCert - case "VerifyClientCertIfGiven": - cfg.ClientAuth = tls.VerifyClientCertIfGiven - case "RequireAndVerifyClientCert": - cfg.ClientAuth = tls.RequireAndVerifyClientCert - case "", "NoClientCert": - cfg.ClientAuth = tls.NoClientCert - default: - return nil, errors.New("Invalid ClientAuth: " + c.ClientAuth) + if c.ClientCAs != "" && clientAuth == tls.NoClientCert { + return nil, errors.New("Client CA's have been configured without a Client Auth Policy") } - if c.ClientCAs != "" && cfg.ClientAuth == tls.NoClientCert { - return nil, errors.New("Client CA's have been configured without a Client Auth Policy") + switch clientAuth { + case tls.RequireAnyClientCert, tls.VerifyClientCertIfGiven, tls.RequireAndVerifyClientCert: + // Cert verification is delegated to the authentication middleware. + cfg.ClientAuth = tls.RequestClientCert } return cfg, nil @@ -272,6 +298,24 @@ func ListenAndServe(server *http.Server, flags *FlagConfig, logger log.Logger) e return ServeMultiple(listeners, server, flags, logger) } +func isClientCertRequired(c tls.ClientAuthType) bool { + switch c { + case tls.RequireAnyClientCert, tls.RequireAndVerifyClientCert: + return true + } + + return false +} + +func isClientCertVerificationRequired(c tls.ClientAuthType) bool { + switch c { + case tls.VerifyClientCertIfGiven, tls.RequireAndVerifyClientCert: + return true + } + + return false +} + 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) @@ -283,6 +327,45 @@ func withRequestAuthentication(handler http.Handler, webConfigPath string, logge authenticators := make([]authentication.Authenticator, 0) + if isTLSEnabled(&c.TLSConfig) { + clientAuth, err := ParseClientAuth(c.TLSConfig.ClientAuth) + if err != nil { + level.Error(logger).Log("msg", "Error parsing ClientAuth", "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if clientAuth != tls.NoClientCert { + requireClientCertsFn := func() bool { + return isClientCertRequired(clientAuth) + } + + var verifyOptionsFn func() x509.VerifyOptions + if isClientCertVerificationRequired(clientAuth) { + clientCAs, err := GetClientCAs(c.TLSConfig.ClientCAs) + if err != nil { + level.Error(logger).Log("msg", "Error getting ClientCAs", "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + verifyOptionsFn = func() x509.VerifyOptions { + opts := x509_authentication.DefaultVerifyOptions() + opts.Roots = clientCAs + return opts + } + } + + var verifyPeerCertificateFn func([][]byte, [][]*x509.Certificate) error + if len(c.TLSConfig.ClientAllowedSans) > 0 { + verifyPeerCertificateFn = c.TLSConfig.VerifyPeerCertificate + } + + x509Authenticator := x509_authentication.NewX509Authenticator(requireClientCertsFn, verifyOptionsFn, verifyPeerCertificateFn) + authenticators = append(authenticators, x509Authenticator) + } + } + if len(c.Users) > 0 { basicAuthAuthenticator := basicauth_authentication.NewBasicAuthAuthenticator(c.Users) authenticators = append(authenticators, basicAuthAuthenticator) diff --git a/web/tls_config_test.go b/web/tls_config_test.go index 0d155dc7..3117763b 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -65,10 +65,10 @@ var ( "No HTTP2 cipher": regexp.MustCompile(`TLSConfig.CipherSuites is missing an HTTP/2-required`), // The first token is returned by Go <= 1.17 and the second token is returned by Go >= 1.18. "Incompatible TLS version": regexp.MustCompile(`protocol version not supported|no supported versions satisfy MinVersion and MaxVersion`), - "Bad certificate": regexp.MustCompile(`bad certificate`), + "Bad certificate": regexp.MustCompile(`Unauthorized`), "Invalid value": regexp.MustCompile(`invalid value for`), "Invalid header": regexp.MustCompile(`HTTP header ".*" can not be configured`), - "Invalid client cert": regexp.MustCompile(`bad certificate`), + "Invalid client cert": regexp.MustCompile(`Unauthorized`), } ) @@ -364,6 +364,52 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client2_selfsigned", ExpectedError: ErrorMap["Invalid client cert"], }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path not matching, certificate not present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + URI: "/someotherpath", + ExpectedError: ErrorMap["Unauthorized"], + }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path not matching, certificate present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + URI: "/someotherpath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path matching, certificate not present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path matching, wrong certificate present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with VerifyPeerCertificate and auth_excluded_paths (path matching, present invalid SAN DNS entries)`, + YAMLConfigPath: "testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml", + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with VerifyPeerCertificate and auth_excluded_paths (path not matching, present invalid SAN DNS entries)`, + YAMLConfigPath: "testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml", + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + URI: "/someotherpath", + ExpectedError: ErrorMap["Invalid client cert"], + }, } for _, testInputs := range testTables { t.Run(testInputs.Name, testInputs.Test)