diff --git a/docs/web-configuration.md b/docs/web-configuration.md index a7823349d..8ae42a4e2 100644 --- a/docs/web-configuration.md +++ b/docs/web-configuration.md @@ -125,6 +125,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/handler.go b/web/handler.go index 51da762c9..6c281d541 100644 --- a/web/handler.go +++ b/web/handler.go @@ -16,12 +16,9 @@ package web import ( - "encoding/hex" "fmt" "log/slog" "net/http" - "strings" - "sync" "golang.org/x/crypto/bcrypt" ) @@ -79,10 +76,6 @@ type webHandler struct { tlsConfigPath string handler http.Handler logger *slog.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 80d594a9e..ccc934aa7 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/internal/authentication/authenticator.go b/web/internal/authentication/authenticator.go new file mode 100644 index 000000000..bf5086c99 --- /dev/null +++ b/web/internal/authentication/authenticator.go @@ -0,0 +1,58 @@ +// 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 ( + "log/slog" + "net/http" +) + +// HTTPChallenge contains information which can used by an HTTP server to challenge a client request using a challenge-response authentication framework. +// https://datatracker.ietf.org/doc/html/rfc7235#section-2.1 +type HTTPChallenge struct { + Scheme string +} + +type Authenticator interface { + Authenticate(*http.Request) (bool, string, *HTTPChallenge, error) +} + +type AuthenticatorFunc func(r *http.Request) (bool, string, *HTTPChallenge, error) + +func (f AuthenticatorFunc) Authenticate(r *http.Request) (bool, string, *HTTPChallenge, error) { + return f(r) +} + +func WithAuthentication(handler http.Handler, authenticator Authenticator, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ok, denyReason, httpChallenge, err := authenticator.Authenticate(r) + if err != nil { + logger.Error("Unable to authenticate", "URI", r.RequestURI, "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if ok { + handler.ServeHTTP(w, r) + return + } + + if httpChallenge != nil { + w.Header().Set("WWW-Authenticate", httpChallenge.Scheme) + } + + logger.Warn("Unauthenticated request", "URI", r.RequestURI, "denyReason", denyReason) + http.Error(w, denyReason, http.StatusUnauthorized) + }) +} diff --git a/web/internal/authentication/authenticator_test.go b/web/internal/authentication/authenticator_test.go new file mode 100644 index 000000000..6b726debd --- /dev/null +++ b/web/internal/authentication/authenticator_test.go @@ -0,0 +1,116 @@ +// 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" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication/testhelpers" +) + +func TestWithAuthentication(t *testing.T) { + t.Parallel() + + logger := testhelpers.NewNoOpLogger() + + tt := []struct { + Name string + Authenticator Authenticator + ExpectedStatusCode int + ExpectedBody string + ExpectedWWWAuthenticateHeader string + }{ + { + Name: "Accepting authenticator", + Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) { + return true, "", nil, nil + }), + ExpectedStatusCode: http.StatusOK, + ExpectedBody: "", + ExpectedWWWAuthenticateHeader: "", + }, + { + Name: "Denying authenticator without http challenge", + Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) { + return false, "deny reason", nil, nil + }), + ExpectedStatusCode: http.StatusUnauthorized, + ExpectedBody: "deny reason\n", + ExpectedWWWAuthenticateHeader: "", + }, + { + Name: "Denying authenticator with http challenge", + Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) { + httpChallenge := &HTTPChallenge{ + Scheme: "test", + } + return false, "deny reason", httpChallenge, nil + }), + ExpectedStatusCode: http.StatusUnauthorized, + ExpectedBody: "deny reason\n", + ExpectedWWWAuthenticateHeader: "test", + }, + { + Name: "Erroring authenticator", + Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) { + return false, "", nil, errors.New("error authenticating") + }), + ExpectedStatusCode: http.StatusInternalServerError, + ExpectedBody: "Internal Server Error\n", + ExpectedWWWAuthenticateHeader: "", + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + req := testhelpers.MakeDefaultRequest(t) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + authHandler := WithAuthentication(handler, tc.Authenticator, logger) + authHandler.ServeHTTP(rr, req) + gotResult := rr.Result() + + gotBodyBytes, err := io.ReadAll(gotResult.Body) + if err != nil { + t.Fatalf("unexpected error reading response body: %v", err) + } + gotBody := string(gotBodyBytes) + + gotWWWAuthenticateHeader := gotResult.Header.Get("WWW-Authenticate") + + if tc.ExpectedStatusCode != gotResult.StatusCode { + t.Errorf("Expected status code %q, got %q", tc.ExpectedStatusCode, gotResult.StatusCode) + } + + if tc.ExpectedBody != gotBody { + t.Errorf("Expected body %q, got %q", tc.ExpectedBody, gotBody) + } + + if !reflect.DeepEqual(tc.ExpectedWWWAuthenticateHeader, gotWWWAuthenticateHeader) { + t.Errorf("Expected 'WWW-Authenticate' header %v, got %v", tc.ExpectedWWWAuthenticateHeader, gotWWWAuthenticateHeader) + } + }) + } +} diff --git a/web/internal/authentication/basicauth/basicauth.go b/web/internal/authentication/basicauth/basicauth.go new file mode 100644 index 000000000..d18f59006 --- /dev/null +++ b/web/internal/authentication/basicauth/basicauth.go @@ -0,0 +1,91 @@ +// 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/internal/authentication" + "golang.org/x/crypto/bcrypt" +) + +const denyReasonUnauthorized = "Unauthorized" + +// BasicAuthAuthenticator authenticates requests using basic auth. +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, *authentication.HTTPChallenge, error) { + httpChallenge := &authentication.HTTPChallenge{ + Scheme: "Basic", + } + + user, pass, auth := r.BasicAuth() + + if !auth { + return false, denyReasonUnauthorized, httpChallenge, 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, nil + } + + return false, denyReasonUnauthorized, httpChallenge, nil +} + +func NewBasicAuthAuthenticator(users map[string]config.Secret) authentication.Authenticator { + return &BasicAuthAuthenticator{ + cache: newCache(), + users: users, + } +} + +var _ authentication.Authenticator = &BasicAuthAuthenticator{} diff --git a/web/internal/authentication/basicauth/basicauth_test.go b/web/internal/authentication/basicauth/basicauth_test.go new file mode 100644 index 000000000..bf4dc424b --- /dev/null +++ b/web/internal/authentication/basicauth/basicauth_test.go @@ -0,0 +1,286 @@ +// 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 ( + "net/http" + "net/http/httptest" + "reflect" + "sync" + "testing" + + config_util "github.com/prometheus/common/config" + "github.com/prometheus/exporter-toolkit/web/internal/authentication" + "github.com/prometheus/exporter-toolkit/web/internal/authentication/testhelpers" +) + +func TestBasicAuthAuthenticator_Authenticate(t *testing.T) { + t.Parallel() + + tt := []struct { + Name string + + Users map[string]config_util.Secret + + RequestFn func(*testing.T) *http.Request + + ExpectAuthenticated bool + ExpectedDenyReason string + ExpectedHTTPChallenge *authentication.HTTPChallenge + ExpectedError error + }{ + { + Name: "No basic auth", + Users: map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + }, + RequestFn: testhelpers.MakeDefaultRequest, + ExpectAuthenticated: false, + ExpectedDenyReason: "Unauthorized", + ExpectedHTTPChallenge: &authentication.HTTPChallenge{ + Scheme: "Basic", + }, + ExpectedError: nil, + }, + { + Name: "Existing user, correct password", + Users: map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + }, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.SetBasicAuth("alice", "alice123") + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedHTTPChallenge: nil, + ExpectedError: nil, + }, + { + Name: "Existing user, incorrect password", + Users: map[string]config_util.Secret{ + "alice": "$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + }, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.SetBasicAuth("alice", "alice1234") + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "Unauthorized", + ExpectedHTTPChallenge: &authentication.HTTPChallenge{ + Scheme: "Basic", + }, + ExpectedError: nil, + }, + { + Name: "Nonexisting user", + Users: map[string]config_util.Secret{ + "bob": "$2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR.", + "carol": "$2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu", + }, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.SetBasicAuth("alice", "alice123") + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "Unauthorized", + ExpectedHTTPChallenge: &authentication.HTTPChallenge{ + Scheme: "Basic", + }, + ExpectedError: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + req := tc.RequestFn(t) + + a := NewBasicAuthAuthenticator(tc.Users) + authenticated, denyReason, httpChallenge, err := a.Authenticate(req) + + if !reflect.DeepEqual(err, tc.ExpectedError) { + t.Fatalf("Expected error %v, got %v", tc.ExpectedError, err) + } + + if tc.ExpectAuthenticated != authenticated { + t.Errorf("Expected authenticated %t, got %t", tc.ExpectAuthenticated, authenticated) + } + + if tc.ExpectedDenyReason != denyReason { + t.Errorf("Expected deny reason %q, got %q", tc.ExpectedDenyReason, denyReason) + } + + if !reflect.DeepEqual(httpChallenge, tc.ExpectedHTTPChallenge) { + t.Errorf("Expected http challenge %v, got %v", tc.ExpectedHTTPChallenge, httpChallenge) + } + }) + } +} + +// TestWithAuthentication_BasicAuthAuthenticator_Cache validates that the cache is working by calling a password +// protected endpoint multiple times. +func TestWithAuthentication_BasicAuthAuthenticator_Cache(t *testing.T) { + t.Parallel() + + logger := testhelpers.NewNoOpLogger() + + 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, logger) + + login := func(username, password string, expectedStatusCode int) { + req := testhelpers.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) { + t.Parallel() + + logger := testhelpers.NewNoOpLogger() + + 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, logger) + + expectedStatusCode := http.StatusUnauthorized + login := func() { + req := testhelpers.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) { + t.Parallel() + + logger := testhelpers.NewNoOpLogger() + + 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, logger) + + expectedStatusCode := http.StatusUnauthorized + login := func(username, password string) { + req := testhelpers.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") +} diff --git a/web/cache.go b/web/internal/authentication/basicauth/cache.go similarity index 99% rename from web/cache.go rename to web/internal/authentication/basicauth/cache.go index 252928eea..c3339d3fe 100644 --- a/web/cache.go +++ b/web/internal/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/internal/authentication/basicauth/cache_test.go similarity index 98% rename from web/cache_test.go rename to web/internal/authentication/basicauth/cache_test.go index 4ba1eff9a..8c1adf13c 100644 --- a/web/cache_test.go +++ b/web/internal/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/internal/authentication/chain/chain.go b/web/internal/authentication/chain/chain.go new file mode 100644 index 000000000..7382b2551 --- /dev/null +++ b/web/internal/authentication/chain/chain.go @@ -0,0 +1,47 @@ +// 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" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication" +) + +// ChainAuthenticator allows for composing multiple authenticators to be used in sequence. +type ChainAuthenticator []authentication.Authenticator + +// Authenticate sequentially authenticates the requests against the chained authenticators. +// A request passes authentication when all composed authenticators accept it. +// If either denies in the process, it returns early and the following authenticators are not invoked. +func (c ChainAuthenticator) Authenticate(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + for _, a := range c { + ok, denyReason, httpChallenge, err := a.Authenticate(r) + if err != nil { + return false, "", nil, err + } + + if !ok { + return false, denyReason, httpChallenge, nil + } + } + + return true, "", nil, nil +} + +func NewChainAuthenticator(authenticators []authentication.Authenticator) authentication.Authenticator { + return ChainAuthenticator(authenticators) +} + +var _ authentication.Authenticator = &ChainAuthenticator{} diff --git a/web/internal/authentication/chain/chain_test.go b/web/internal/authentication/chain/chain_test.go new file mode 100644 index 000000000..c93aaf139 --- /dev/null +++ b/web/internal/authentication/chain/chain_test.go @@ -0,0 +1,235 @@ +// 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" + "reflect" + "testing" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication" + "github.com/prometheus/exporter-toolkit/web/internal/authentication/testhelpers" +) + +func TestChainAuthenticator_Authenticate(t *testing.T) { + t.Parallel() + + firstAuthenticatorErr := errors.New("first authenticator error") + firstAuthenticatorHTTPChallenge := &authentication.HTTPChallenge{ + Scheme: "FirstAuthenticator", + } + + secondAuthenticatorErr := errors.New("second authenticator error") + secondAuthenticatorHTTPChallenge := &authentication.HTTPChallenge{ + Scheme: "SecondAuthenticator", + } + + tt := []struct { + Name string + + AuthenticatorsFn func(t *testing.T) []authentication.Authenticator + + ExpectAuthenticated bool + ExpectedDenyReason string + ExpectedHTTPChallenge *authentication.HTTPChallenge + ExpectedError error + }{ + { + Name: "First authenticator denies, second accepts, only first authenticator is called, chain denies", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return false, "First authenticator denied the request.", nil, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + t.Fatalf("Expected second authenticator not to be called, but it was.") + return true, "", nil, nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "First authenticator denied the request.", + ExpectedHTTPChallenge: nil, + ExpectedError: nil, + }, + { + Name: "First authenticator denies and returns http challenge, second accepts, only first authenticator is called, chain denies and propagates http challenge", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return false, "First authenticator denied the request.", firstAuthenticatorHTTPChallenge, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + t.Fatalf("Expected second authenticator not to be called, but it was.") + return true, "", nil, nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "First authenticator denied the request.", + ExpectedHTTPChallenge: firstAuthenticatorHTTPChallenge, + ExpectedError: nil, + }, + { + Name: "First authenticator denies, second denies, only first authenticator is called, chain denies", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return false, "First authenticator denied the request.", nil, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + t.Fatalf("Expected second authenticator not to be called, but it was.") + return true, "Second authenticator denied the request.", nil, nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "First authenticator denied the request.", + ExpectedHTTPChallenge: nil, + ExpectedError: nil, + }, + { + Name: "First authenticator denies, second denies, both return http challenges, only first authenticator is called, chain denies and only propagates first http challenge", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return false, "First authenticator denied the request.", firstAuthenticatorHTTPChallenge, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + t.Fatalf("Expected second authenticator not to be called, but it was.") + return true, "Second authenticator denied the request.", secondAuthenticatorHTTPChallenge, nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "First authenticator denied the request.", + ExpectedHTTPChallenge: firstAuthenticatorHTTPChallenge, + 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, *authentication.HTTPChallenge, error) { + return true, "", nil, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return false, "Second authenticator denied the request.", nil, nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "Second authenticator denied the request.", + ExpectedHTTPChallenge: nil, + ExpectedError: nil, + }, + { + Name: "First authenticator accepts, second is called, denies and returns http challenge, chain denies and propagates http challenge", + AuthenticatorsFn: func(t *testing.T) []authentication.Authenticator { + return []authentication.Authenticator{ + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return true, "", nil, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return false, "Second authenticator denied the request.", secondAuthenticatorHTTPChallenge, nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "Second authenticator denied the request.", + ExpectedHTTPChallenge: secondAuthenticatorHTTPChallenge, + 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, *authentication.HTTPChallenge, error) { + return true, "", nil, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return true, "", nil, nil + }), + } + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedHTTPChallenge: nil, + 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, *authentication.HTTPChallenge, error) { + return false, "", nil, firstAuthenticatorErr + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + t.Fatalf("Expected second authenticator not to be called, but it was.") + return true, "", nil, nil + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "", + ExpectedHTTPChallenge: nil, + 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, *authentication.HTTPChallenge, error) { + return true, "", nil, nil + }), + authentication.AuthenticatorFunc(func(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + return false, "", nil, secondAuthenticatorErr + }), + } + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "", + ExpectedHTTPChallenge: nil, + ExpectedError: secondAuthenticatorErr, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + req := testhelpers.MakeDefaultRequest(t) + + a := NewChainAuthenticator(tc.AuthenticatorsFn(t)) + authenticated, denyReason, httpChallenge, err := a.Authenticate(req) + + if !reflect.DeepEqual(err, tc.ExpectedError) { + t.Errorf("Expected error %v, got %v", tc.ExpectedError, err) + } + + if tc.ExpectAuthenticated != authenticated { + t.Errorf("Expected authenticated %t, got %t", tc.ExpectAuthenticated, authenticated) + } + + if tc.ExpectedDenyReason != denyReason { + t.Errorf("Expected deny reason %q, got %q", tc.ExpectedDenyReason, denyReason) + } + + if !reflect.DeepEqual(httpChallenge, tc.ExpectedHTTPChallenge) { + t.Errorf("Expected http challenge %v, got %v", tc.ExpectedHTTPChallenge, httpChallenge) + } + }) + } +} diff --git a/web/internal/authentication/exceptor.go b/web/internal/authentication/exceptor.go new file mode 100644 index 000000000..8a4a74430 --- /dev/null +++ b/web/internal/authentication/exceptor.go @@ -0,0 +1,68 @@ +// 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 ( + "log/slog" + "net/http" +) + +// Exceptor allows for selectively excluding HTTP requests from an operation. +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) +} + +// WithExceptor implements an HTTP middleware which determines whether a request should bypass authentication based on the exclusion criteria defined by the exceptor. +func WithExceptor(handler http.Handler, authenticator Authenticator, exceptor Exceptor, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if exceptor.IsExcepted(r) { + logger.Debug("Excepting request from authentication", "URI", r.RequestURI) + handler.ServeHTTP(w, r) + return + } + + authHandler := WithAuthentication(handler, authenticator, logger) + authHandler.ServeHTTP(w, r) + }) +} + +// PathExceptor implements the exclusion logic based on a predetermined set of URL paths. +// A request is excepted when its URL path matches one of the excluded paths exactly. +type PathExceptor struct { + excludedPaths map[string]bool +} + +// IsExcepted determines that a request is excepted when its URL path matches one of the excluded paths exactly. +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/internal/authentication/exceptor_test.go b/web/internal/authentication/exceptor_test.go new file mode 100644 index 000000000..558a332f9 --- /dev/null +++ b/web/internal/authentication/exceptor_test.go @@ -0,0 +1,134 @@ +// 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" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication/testhelpers" +) + +func TestPathAuthenticationExceptor_IsExcepted(t *testing.T) { + t.Parallel() + + tt := []struct { + Name string + ExcludedPaths []string + URI string + ExpectedExcepted bool + }{ + { + Name: "Path is not excepted when it doesn't match an excluded path", + ExcludedPaths: []string{"/somepath"}, + URI: "/someotherpath", + ExpectedExcepted: false, + }, + { + Name: "Path is not excepted when its prefix matches an excluded path", + ExcludedPaths: []string{"/"}, + URI: "/somepath", + ExpectedExcepted: false, + }, + { + Name: "Path is excepted when it exactly matches the only excluded path", + ExcludedPaths: []string{"/somepath"}, + URI: "/somepath", + ExpectedExcepted: true, + }, + { + Name: "Path is excepted when it exactly matches one of the excluded paths", + ExcludedPaths: []string{"/somepath", "/someotherpath"}, + URI: "/somepath", + ExpectedExcepted: true, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + req, _ := http.NewRequest(http.MethodGet, tc.URI, nil) + + exceptor := NewPathExceptor(tc.ExcludedPaths) + excepted := exceptor.IsExcepted(req) + + if tc.ExpectedExcepted && !excepted { + t.Fatal("Expected path to be excepted, but it wasn't") + } + + if !tc.ExpectedExcepted && excepted { + t.Fatal("Expected path not to be excepted, but it was") + } + }) + } +} + +func TestWithAuthenticationExceptor(t *testing.T) { + t.Parallel() + + logger := testhelpers.NewNoOpLogger() + + tt := []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 _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + req := testhelpers.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, *HTTPChallenge, error) { + authenticatorCalled = true + return false, "", nil, nil + }) + + rr := httptest.NewRecorder() + exceptorHandler := WithExceptor(handler, authenticator, tc.Exceptor, logger) + exceptorHandler.ServeHTTP(rr, req) + + if tc.ExpectedAuthenticatorCalled && !authenticatorCalled { + t.Error("Expected authenticator to be called, but it wasn't") + } + + if !tc.ExpectedAuthenticatorCalled && authenticatorCalled { + t.Error("Expected authenticator not to be called, but it was") + } + }) + } +} diff --git a/web/internal/authentication/testhelpers/helpers.go b/web/internal/authentication/testhelpers/helpers.go new file mode 100644 index 000000000..b8bc580ff --- /dev/null +++ b/web/internal/authentication/testhelpers/helpers.go @@ -0,0 +1,56 @@ +// 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 testhelpers + +import ( + "context" + "log/slog" + "net/http" + "testing" +) + +func NewNoOpLogger() *slog.Logger { + return slog.New(&noOpHandler{}) +} + +type noOpHandler struct{} + +var _ slog.Handler = &noOpHandler{} + +func (h *noOpHandler) Enabled(_ context.Context, _ slog.Level) bool { + return false +} + +func (h *noOpHandler) Handle(_ context.Context, _ slog.Record) error { + return nil +} + +func (h *noOpHandler) WithAttrs(_ []slog.Attr) slog.Handler { + return h +} + +func (h *noOpHandler) WithGroup(_ string) slog.Handler { + return h +} + +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/internal/authentication/x509/testdata/client2_selfsigned.pem b/web/internal/authentication/x509/testdata/client2_selfsigned.pem new file mode 100644 index 000000000..be1426c4a --- /dev/null +++ b/web/internal/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/internal/authentication/x509/testdata/client_selfsigned.pem b/web/internal/authentication/x509/testdata/client_selfsigned.pem new file mode 100644 index 000000000..d25ddca8b --- /dev/null +++ b/web/internal/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/internal/authentication/x509/x509.go b/web/internal/authentication/x509/x509.go new file mode 100644 index 000000000..19b528f46 --- /dev/null +++ b/web/internal/authentication/x509/x509.go @@ -0,0 +1,115 @@ +// 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" + "errors" + "net/http" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication" +) + +const ( + denyReasonCertificateRequired = "certificate required" + denyReasonBadCertificate = "bad certificate" + denyReasonUnknownCA = "unknown certificate authority" + denyReasonCertificateExpired = "expired certificate" +) + +// X509Authenticator allows for client certificate verification at HTTP level for X.509 certificates. +// The purpose behind it is to delegate or extend the TLS certificate verification beyond the standard TLS handshake. +type X509Authenticator struct { + // requireClientCerts specifies whether client certificates are required. + // This vaguely corresponds to crypto/tls ClientAuthType: https://pkg.go.dev/crypto/tls#ClientAuthType. + // If true, it is equivalent to RequireAnyClientCert or RequireAndVerifyClientCert. + requireClientCerts bool + + // verifyOptions returns VerifyOptions used to obtain parameters for Certificate.Verify. + // Optional: if not provided, the client cert is not verified and hence it does not have to be valid. + verifyOptions func() x509.VerifyOptions + + // verifyPeerCertificate corresponds to `VerifyPeerCertificate` from crypto/tls Config: https://pkg.go.dev/crypto/tls#Config. + // It bears the same semantics. + // Optional: if not provided, it is not invoked on any of the peer certificates. + verifyPeerCertificate func([][]byte, [][]*x509.Certificate) error +} + +// Authenticate performs client cert verification by mimicking the steps the server would normally take during the standard TLS handshake in crypto/tls. +// https://cs.opensource.google/go/go/+/refs/tags/go1.23.2:src/crypto/tls/handshake_server.go;l=874-950 +func (x *X509Authenticator) Authenticate(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + if r.TLS == nil { + return false, "", nil, errors.New("no tls connection state in request") + } + + if len(r.TLS.PeerCertificates) == 0 && x.requireClientCerts { + if r.TLS.Version == tls.VersionTLS13 { + return false, denyReasonCertificateRequired, nil, nil + } + + return false, denyReasonBadCertificate, nil, nil + } + + var verifiedChains [][]*x509.Certificate + if len(r.TLS.PeerCertificates) > 0 && x.verifyOptions != nil { + opts := x.verifyOptions() + 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 { + if errors.As(err, &x509.UnknownAuthorityError{}) { + return false, denyReasonUnknownCA, nil, nil + } + + var errCertificateInvalid x509.CertificateInvalidError + if errors.As(err, &errCertificateInvalid) && errCertificateInvalid.Reason == x509.Expired { + return false, denyReasonCertificateExpired, nil, nil + } + + return false, denyReasonBadCertificate, nil, nil + } + + verifiedChains = chains + } + + if x.verifyPeerCertificate != nil { + rawCerts := make([][]byte, 0, len(r.TLS.PeerCertificates)) + for _, c := range r.TLS.PeerCertificates { + rawCerts = append(rawCerts, c.Raw) + } + + err := x.verifyPeerCertificate(rawCerts, verifiedChains) + if err != nil { + return false, denyReasonBadCertificate, nil, nil + } + } + + return true, "", nil, nil +} + +func NewX509Authenticator(requireClientCerts bool, verifyOptions func() x509.VerifyOptions, verifyPeerCertificate func([][]byte, [][]*x509.Certificate) error) authentication.Authenticator { + return &X509Authenticator{ + requireClientCerts: requireClientCerts, + verifyOptions: verifyOptions, + verifyPeerCertificate: verifyPeerCertificate, + } +} + +var _ authentication.Authenticator = &X509Authenticator{} diff --git a/web/internal/authentication/x509/x509_test.go b/web/internal/authentication/x509/x509_test.go new file mode 100644 index 000000000..2bc474566 --- /dev/null +++ b/web/internal/authentication/x509/x509_test.go @@ -0,0 +1,355 @@ +// 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" + "reflect" + "testing" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication/testhelpers" +) + +//go:embed testdata/client_selfsigned.pem +var clientSelfsignedPEM []byte + +//go:embed testdata/client2_selfsigned.pem +var client2SelfsignedPEM []byte + +func TestX509Authenticator_Authenticate(t *testing.T) { + t.Parallel() + + tt := []struct { + Name string + + RequireClientCerts bool + VerifyOptions func() x509.VerifyOptions + VerifyPeerCertificate func([][]byte, [][]*x509.Certificate) error + + RequestFn func(*testing.T) *http.Request + + ExpectAuthenticated bool + ExpectedDenyReason string + ExpectedError error + }{ + { + Name: "No TLS connection state in request", + RequireClientCerts: false, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: testhelpers.MakeDefaultRequest, + ExpectAuthenticated: false, + ExpectedDenyReason: "", + ExpectedError: errors.New("no tls connection state in request"), + }, + { + Name: "Certs not required, certs not provided", + RequireClientCerts: false, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{} + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, certs not provided", + RequireClientCerts: true, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{} + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "bad certificate", + ExpectedError: nil, + }, + { + Name: "Certs required, certs not provided, VersionTLS13", + RequireClientCerts: true, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + Version: tls.VersionTLS13, + } + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "certificate required", + ExpectedError: nil, + }, + { + Name: "Certs not required, no verify, selfsigned cert provided", + RequireClientCerts: false, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, no verify, selfsigned cert provided", + RequireClientCerts: true, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs not required, verify, selfsigned cert provided", + RequireClientCerts: false, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs not required, verify, no certs provided", + RequireClientCerts: false, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{} + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, verify, selfsigned cert provided", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, verify, cert signed by an unknown CA provided", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, client2SelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "unknown certificate authority", + ExpectedError: nil, + }, + { + Name: "Certs required, verify, selfsigned cert provided, verify peer certificate func returns an error", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: func(_ [][]byte, _ [][]*x509.Certificate) error { + return errors.New("invalid peer certificate") + }, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "bad certificate", + ExpectedError: nil, + }, + { + Name: "RequireAndVerifyClientCert, selfsigned certs, verify peer certificate func does not return an error", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: func(_ [][]byte, _ [][]*x509.Certificate) error { + return nil + }, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + req := tc.RequestFn(t) + + a := NewX509Authenticator(tc.RequireClientCerts, tc.VerifyOptions, tc.VerifyPeerCertificate) + authenticated, denyReason, httpChallenge, err := a.Authenticate(req) + + if !reflect.DeepEqual(err, tc.ExpectedError) { + t.Fatalf("Expected error %v, got %v", tc.ExpectedError, err) + } + + if httpChallenge != nil { + t.Errorf("Expected http challenge to be nil, got %v", httpChallenge) + } + + if tc.ExpectedDenyReason != denyReason { + t.Errorf("Expected deny reason %q, got %q", tc.ExpectedDenyReason, denyReason) + } + + if tc.ExpectAuthenticated != authenticated { + t.Errorf("Expected authenticated %t, got %t", tc.ExpectAuthenticated, authenticated) + } + }) + } +} + +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 +} + +// baseVerifyOptions require certificates to be valid for client auth (x509.ExtKeyUsageClientAuth). +func baseVerifyOptions() x509.VerifyOptions { + return x509.VerifyOptions{ + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } +} 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 000000000..2518daf3a --- /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 000000000..5157e3a8b --- /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/testdata/web_config_users.authexcludedpaths.good.yml b/web/testdata/web_config_users.authexcludedpaths.good.yml new file mode 100644 index 000000000..6d4cbb382 --- /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/testdata/web_config_users_noTLS.authexcludedpaths.good.yml b/web/testdata/web_config_users_noTLS.authexcludedpaths.good.yml new file mode 100644 index 000000000..f258b4ac1 --- /dev/null +++ b/web/testdata/web_config_users_noTLS.authexcludedpaths.good.yml @@ -0,0 +1,8 @@ +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 0730a938f..f1a65bafb 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -30,6 +30,10 @@ import ( "github.com/coreos/go-systemd/v22/activation" "github.com/mdlayher/vsock" config_util "github.com/prometheus/common/config" + "github.com/prometheus/exporter-toolkit/web/internal/authentication" + basicauth_authentication "github.com/prometheus/exporter-toolkit/web/internal/authentication/basicauth" + chain_authentication "github.com/prometheus/exporter-toolkit/web/internal/authentication/chain" + x509_authentication "github.com/prometheus/exporter-toolkit/web/internal/authentication/x509" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" ) @@ -40,9 +44,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 { @@ -138,11 +143,15 @@ func getTLSConfig(configPath string) (*tls.Config, error) { return ConfigToTLSConfig(&c.TLSConfig) } +func isTLSEnabled(c *TLSConfig) bool { + return c.TLSCertPath != "" || c.TLSCert != "" || + c.TLSKeyPath != "" || c.TLSKey != "" || + c.ClientCAs != "" || c.ClientCAsText != "" || + c.ClientAuth != "" +} + func validateTLSPaths(c *TLSConfig) error { - if c.TLSCertPath == "" && c.TLSCert == "" && - c.TLSKeyPath == "" && c.TLSKey == "" && - c.ClientCAs == "" && c.ClientCAsText == "" && - c.ClientAuth == "" { + if !isTLSEnabled(c) { return errNoTLSConfig } @@ -157,6 +166,40 @@ func validateTLSPaths(c *TLSConfig) error { return nil } +func getClientCAs(clientCAsPath, clientCAsText string) (*x509.CertPool, error) { + clientCAPool := x509.NewCertPool() + + if clientCAsPath != "" { + clientCAFile, err := os.ReadFile(clientCAsPath) + if err != nil { + return nil, err + } + + clientCAPool.AppendCertsFromPEM(clientCAFile) + } else if clientCAsText != "" { + clientCAPool.AppendCertsFromPEM([]byte(clientCAsText)) + } + + 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) + } +} + // ConfigToTLSConfig generates the golang tls.Config from the TLSConfig struct. func ConfigToTLSConfig(c *TLSConfig) (*tls.Config, error) { if err := validateTLSPaths(c); err != nil { @@ -223,44 +266,31 @@ 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 - } else if c.ClientCAsText != "" { - clientCAPool := x509.NewCertPool() - clientCAPool.AppendCertsFromPEM([]byte(c.ClientCAsText)) - cfg.ClientCAs = clientCAPool - } - - if c.ClientAllowedSans != nil { - // verify that the client cert contains an allowed SAN - cfg.VerifyPeerCertificate = c.VerifyPeerCertificate + clientCAs, err := getClientCAs(c.ClientCAs, c.ClientCAsText) + if err != nil { + return nil, err } + cfg.ClientCAs = clientCAs - 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) + clientAuth, err := parseClientAuth(c.ClientAuth) + if err != nil { + return nil, err } + cfg.ClientAuth = clientAuth if (c.ClientCAs != "" || c.ClientCAsText != "") && 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 + + default: + // No changes to client auth type required. + } + return cfg, nil } @@ -341,6 +371,94 @@ func parseVsockPort(address string) (uint32, error) { return uint32(port), nil } +func isClientCertRequired(c tls.ClientAuthType) bool { + switch c { + case tls.RequireAnyClientCert, tls.RequireAndVerifyClientCert: + return true + + default: + return false + } +} + +func isClientCertVerificationRequired(c tls.ClientAuthType) bool { + switch c { + case tls.VerifyClientCertIfGiven, tls.RequireAndVerifyClientCert: + return true + + default: + return false + } +} + +func withRequestAuthentication(handler http.Handler, webConfigPath string, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := getConfig(webConfigPath) + if err != nil { + logger.Error("Unable to parse configuration", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + authenticators := make([]authentication.Authenticator, 0) + + if isTLSEnabled(&c.TLSConfig) { + clientAuth, err := parseClientAuth(c.TLSConfig.ClientAuth) + if err != nil { + logger.Error("Unable to parse ClientAuth", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if clientAuth != tls.NoClientCert { + requireClientCerts := isClientCertRequired(clientAuth) + + var verifyOptions func() x509.VerifyOptions + if isClientCertVerificationRequired(clientAuth) { + clientCAs, err := getClientCAs(c.TLSConfig.ClientCAs, c.TLSConfig.ClientCAsText) + if err != nil { + logger.Error("Unable to get ClientCAs", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + verifyOptions = func() x509.VerifyOptions { + return x509.VerifyOptions{ + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + Roots: clientCAs, + } + } + } + + var verifyPeerCertificate func([][]byte, [][]*x509.Certificate) error + if len(c.TLSConfig.ClientAllowedSans) > 0 { + verifyPeerCertificate = c.TLSConfig.VerifyPeerCertificate + } + + x509Authenticator := x509_authentication.NewX509Authenticator(requireClientCerts, verifyOptions, verifyPeerCertificate) + authenticators = append(authenticators, x509Authenticator) + } + } + + 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 *slog.Logger) error { @@ -361,6 +479,8 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. handler = server.Handler } + authHandler := withRequestAuthentication(handler, tlsConfigPath, logger) + c, err := getConfig(tlsConfigPath) if err != nil { return err @@ -369,8 +489,7 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. 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 b28c66711..9b8e02a6a 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -25,6 +25,7 @@ import ( "log/slog" "net" "net/http" + "net/url" "os" "regexp" "sync" @@ -98,6 +99,7 @@ type TestInputs struct { Username string Password string ClientCertificate string + URI string } func TestYAMLFiles(t *testing.T) { @@ -364,6 +366,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["Certificate required"], + }, + { + 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) @@ -504,7 +552,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) } @@ -688,6 +740,64 @@ func TestUsers(t *testing.T) { Password: "nonexistent", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `with incorrect basic auth and auth_excluded_paths (path not matching)`, + YAMLConfigPath: "testdata/web_config_users_noTLS.authexcludedpaths.good.yml", + URI: "/someotherpath", + Username: "nonexistent", + Password: "nonexistent", + ExpectedError: ErrorMap["Unauthorized"], + }, + { + Name: `with correct basic auth and auth_excluded_paths (path not matching)`, + YAMLConfigPath: "testdata/web_config_users_noTLS.authexcludedpaths.good.yml", + URI: "/someotherpath", + Username: "dave", + Password: "dave123", + ExpectedError: nil, + }, + { + Name: `without basic auth and auth_excluded_paths (path matching)`, + YAMLConfigPath: "testdata/web_config_users_noTLS.authexcludedpaths.good.yml", + Username: "", + Password: "", + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `without incorrect basic auth and auth_excluded_paths (path matching)`, + YAMLConfigPath: "testdata/web_config_users_noTLS.authexcludedpaths.good.yml", + Username: "nonexistent", + Password: "nonexistent", + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `with correct basic auth and auth_excluded_paths (path matching)`, + YAMLConfigPath: "testdata/web_config_users_noTLS.authexcludedpaths.good.yml", + Username: "dave", + Password: "dave123", + URI: "/somepath", + ExpectedError: nil, + }, + { + 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)