diff --git a/web/authentication/x509/testdata/selfsigned.pem b/web/authentication/x509/testdata/selfsigned.pem new file mode 100644 index 00000000..d25ddca8 --- /dev/null +++ b/web/authentication/x509/testdata/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..58ca281b --- /dev/null +++ b/web/authentication/x509/x509_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 x509 + +import ( + "crypto/tls" + "crypto/x509" + _ "embed" + "encoding/pem" + "errors" + "net/http" + "testing" + + "github.com/go-kit/log" +) + +//go:embed testdata/selfsigned.pem +var selfsignedPEM []byte + +func TestX509Authenticator_Authenticate(t *testing.T) { + ts := []struct { + Name string + + RequireClientCertsFn RequireClientCertsFunc + VerifyOptionsFn VerifyOptionsFunc + VerifyPeerCertificateFn VerifyPeerCertificateFunc + + Certs []*x509.Certificate + + ExpectAuthenticated bool + ExpectedResponse 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, + ExpectedResponse: "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, selfsignedPEM), + ExpectAuthenticated: true, + ExpectedError: nil, + }, + { + Name: "Certs required, no verify, selfsigned cert provided", + RequireClientCertsFn: func() bool { + return true + }, + Certs: getCerts(t, selfsignedPEM), + 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, selfsignedPEM) + return opts + }, + Certs: getCerts(t, selfsignedPEM), + 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, selfsignedPEM) + 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, selfsignedPEM) + return opts + }, + Certs: getCerts(t, selfsignedPEM), + ExpectAuthenticated: true, + 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, selfsignedPEM) + return opts + }, + VerifyPeerCertificateFn: func(_ [][]byte, _ [][]*x509.Certificate) error { + return errors.New("invalid peer certificate") + }, + Certs: getCerts(t, selfsignedPEM), + ExpectAuthenticated: false, + ExpectedResponse: "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, selfsignedPEM) + return opts + }, + VerifyPeerCertificateFn: func(_ [][]byte, _ [][]*x509.Certificate) error { + return nil + }, + Certs: getCerts(t, selfsignedPEM), + 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, 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 +} + +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)