From d5ac1235d1f68408b96d5a02abc33a27c03877bd Mon Sep 17 00:00:00 2001 From: Joel Smith Date: Tue, 7 Apr 2026 14:35:38 -0600 Subject: [PATCH] Allow more control of TLS versions & ciphers Add 2 new env vars KEDA_HTTP_TLS_CIPHER_LIST and KEDA_SERVICE_TLS_CIPHER_LIST to provide fine-grained control to restrict the cipher suites used by the webhook and gRPC servers as well as TLS clients in the scalers. Also add KEDA_SERVICE_MIN_TLS_VERSION to mirror the behovior of KEDA_HTTP_MIN_TLS_VERSION for the gRPC server. Signed-off-by: Joel Smith --- CHANGELOG.md | 1 + cmd/webhooks/main.go | 3 +- pkg/metricsservice/utils/tls.go | 5 +- pkg/util/tls_config.go | 80 +++++++++++++++++++--- pkg/util/tls_config_test.go | 117 ++++++++++++++++++++++++++++---- 5 files changed, 181 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea7e99e481..8f1c8d302b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio - **General**: Add CRD-level validation markers (Minimum, MinLength, MinItems, Enum) for ScaledObject, ScaledJob, ScaleTriggers, and TriggerAuthentication API types ([#7533](https://github.com/kedacore/keda/pull/7533)) - **General**: Add `--leader-election-id` flag to allow configuring the leader election Lease name ([#7564](https://github.com/kedacore/keda/issues/7564)) +- **General**: Allow more control of TLS versions & ciphers via `KEDA_HTTP_TLS_CIPHER_LIST`, `KEDA_SERVICE_TLS_CIPHER_LIST` and `KEDA_SERVICE_MIN_TLS_VERSION` env vars ([#7617](https://github.com/kedacore/keda/pull/7617)) - **General**: Make APIService cert injections optional ([#7559](https://github.com/kedacore/keda/pull/7559)) - **Elasticsearch Scaler**: Add HTTP status check for Elasticsearch errors ([#7480](https://github.com/kedacore/keda/pull/7480)) - **Kubernetes Workload Scaler**: Add `groupByNode` parameter ([#7628](https://github.com/kedacore/keda/issues/7628)) diff --git a/cmd/webhooks/main.go b/cmd/webhooks/main.go index ab18a2d3d6d..8362d58c0a7 100644 --- a/cmd/webhooks/main.go +++ b/cmd/webhooks/main.go @@ -98,7 +98,8 @@ func main() { CertDir: certDir, TLSOpts: []func(tlsConfig *tls.Config){ func(tlsConfig *tls.Config) { - tlsConfig.MinVersion = kedautil.GetMinTLSVersion() + tlsConfig.MinVersion = kedautil.GetServiceMinTLSVersion() + tlsConfig.CipherSuites = kedautil.GetServiceTLSCipherList() }, }, }), diff --git a/pkg/metricsservice/utils/tls.go b/pkg/metricsservice/utils/tls.go index 48135e19470..4c37ede1338 100644 --- a/pkg/metricsservice/utils/tls.go +++ b/pkg/metricsservice/utils/tls.go @@ -29,6 +29,8 @@ import ( "github.com/fsnotify/fsnotify" "google.golang.org/grpc/credentials" logf "sigs.k8s.io/controller-runtime/pkg/log" + + kedautil "github.com/kedacore/keda/v2/pkg/util" ) var log = logf.Log.WithName("grpc_server_certificates") @@ -127,7 +129,8 @@ func LoadGrpcTLSCredentials(ctx context.Context, certDir string, server bool) (c // Create the credentials and return it config := &tls.Config{ - MinVersion: tls.VersionTLS13, + MinVersion: kedautil.GetServiceMinTLSVersion(), + CipherSuites: kedautil.GetServiceTLSCipherList(), GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { certMutex.RLock() defer certMutex.RUnlock() diff --git a/pkg/util/tls_config.go b/pkg/util/tls_config.go index fd1cdb11345..1ea23c89089 100644 --- a/pkg/util/tls_config.go +++ b/pkg/util/tls_config.go @@ -22,18 +22,37 @@ import ( "encoding/pem" "fmt" "os" + "strings" "github.com/youmark/pkcs8" ctrl "sigs.k8s.io/controller-runtime" ) var minTLSVersion uint16 +var tlsCipherList []uint16 +var serviceMinTLSVersion uint16 +var serviceTLSCipherList []uint16 func init() { var err error - if minTLSVersion, err = initMinTLSVersion(); err != nil { - ctrl.Log.WithName("tls_setup").Info(err.Error()) + if minTLSVersion, err = ParseTLSVersion(os.Getenv("KEDA_HTTP_MIN_TLS_VERSION"), tls.VersionTLS12); err != nil { + ctrl.Log.WithName("tls_setup").Info("Error parsing environment variable KEDA_HTTP_MIN_TLS_VERSION", "value", os.Getenv("KEDA_HTTP_MIN_TLS_VERSION"), "error", err.Error()) + } + tlsCipherList = ParseTLSCipherList(os.Getenv("KEDA_HTTP_TLS_CIPHER_LIST")) + + if envvar, found := os.LookupEnv("KEDA_SERVICE_MIN_TLS_VERSION"); found { + if serviceMinTLSVersion, err = ParseTLSVersion(envvar, tls.VersionTLS13); err != nil { + ctrl.Log.WithName("tls_setup").Info("Error parsing environment variable KEDA_HTTP_MIN_TLS_VERSION", "value", os.Getenv("KEDA_SERVICE_MIN_TLS_VERSION"), "error", err.Error()) + } + } else { + serviceMinTLSVersion = minTLSVersion // fall back since the old behavior was for the webhook to use KEDA_HTTP_MIN_TLS_VERSION + } + + if envvar, found := os.LookupEnv("KEDA_SERVICE_TLS_CIPHER_LIST"); found { + serviceTLSCipherList = ParseTLSCipherList(envvar) + } else { + serviceTLSCipherList = tlsCipherList } } @@ -80,6 +99,7 @@ func CreateTLSClientConfig(unsafeSsl bool) *tls.Config { InsecureSkipVerify: unsafeSsl, RootCAs: getRootCAs(), MinVersion: GetMinTLSVersion(), + CipherSuites: GetTLSCipherList(), } } @@ -88,25 +108,63 @@ func GetMinTLSVersion() uint16 { return minTLSVersion } -func initMinTLSVersion() (uint16, error) { - version, _ := os.LookupEnv("KEDA_HTTP_MIN_TLS_VERSION") +// GetTLSCipherList returns the TLS cipher list based on configurations +func GetTLSCipherList() []uint16 { + return tlsCipherList +} + +// GetServiceMinTLSVersion return the minimum TLS version that KEDA services are configured to use +func GetServiceMinTLSVersion() uint16 { + return serviceMinTLSVersion +} + +// GetServiceTLSCipherList return the TLS ciphersuites that KEDA services are configured to use +func GetServiceTLSCipherList() []uint16 { + return serviceTLSCipherList +} + +// ParseTLSCipherList parses a colon or comma-separated list of TLS cipher suite names +// (as returned by crypto/tls CipherSuites()) into a slice of cipher suite IDs. +// Unknown names are logged. Returns nil if no valid ciphers are found. +func ParseTLSCipherList(ciphers string) []uint16 { + reverseCipherMap := make(map[string]uint16) + for _, c := range tls.CipherSuites() { + reverseCipherMap[c.Name] = c.ID + } + var ciphersuites []uint16 + for c := range strings.SplitSeq(strings.ReplaceAll(ciphers, ",", ":"), ":") { + c = strings.TrimSpace(c) + if id, ok := reverseCipherMap[c]; ok { + ciphersuites = append(ciphersuites, id) + } else { + ctrl.Log.WithName("tls_setup").Info("Unrecognized TLS ciphersuite name while parsing list of ciphersuites", "value", c) + } + } + if len(ciphersuites) == 0 { + return nil + } + return ciphersuites +} +// ParseTLSVersion converts a TLS version string to a TLS version value +func ParseTLSVersion(version string, defaultVersion uint16) (uint16, error) { + var ver uint16 switch version { case "": - minTLSVersion = tls.VersionTLS12 + ver = defaultVersion case "TLS10": - minTLSVersion = tls.VersionTLS10 + ver = tls.VersionTLS10 case "TLS11": - minTLSVersion = tls.VersionTLS11 + ver = tls.VersionTLS11 case "TLS12": - minTLSVersion = tls.VersionTLS12 + ver = tls.VersionTLS12 case "TLS13": - minTLSVersion = tls.VersionTLS13 + ver = tls.VersionTLS13 default: - return tls.VersionTLS12, fmt.Errorf("%s is not a valid value, using `TLS12`. Allowed values are: `TLS13`,`TLS12`,`TLS11`,`TLS10`", version) + return defaultVersion, fmt.Errorf("%s is not a valid value, using `%s`. Allowed values are: `TLS13`,`TLS12`,`TLS11`,`TLS10`", version, strings.ReplaceAll(strings.ReplaceAll(tls.VersionName(defaultVersion), " ", ""), ".", "")) } - return minTLSVersion, nil + return ver, nil } func decryptClientKey(clientKey, clientKeyPassword string) ([]byte, error) { diff --git a/pkg/util/tls_config_test.go b/pkg/util/tls_config_test.go index 792965d80f0..db8651f32ad 100644 --- a/pkg/util/tls_config_test.go +++ b/pkg/util/tls_config_test.go @@ -254,49 +254,142 @@ func TestNewTLSConfig_WithPassword(t *testing.T) { } type minTLSVersionTestData struct { - envSet bool envValue string expectedVersion uint16 } var minTLSVersionTestDatas = []minTLSVersionTestData{ { - envSet: true, envValue: "TLS10", expectedVersion: tls.VersionTLS10, }, { - envSet: true, envValue: "TLS11", expectedVersion: tls.VersionTLS11, }, { - envSet: true, envValue: "TLS12", expectedVersion: tls.VersionTLS12, }, { - envSet: true, envValue: "TLS13", expectedVersion: tls.VersionTLS13, }, { - envSet: false, + envValue: "", expectedVersion: tls.VersionTLS12, }, } func TestResolveMinTLSVersion(t *testing.T) { - defer os.Unsetenv("KEDA_HTTP_MIN_TLS_VERSION") for _, testData := range minTLSVersionTestDatas { - os.Unsetenv("KEDA_HTTP_MIN_TLS_VERSION") - if testData.envSet { - os.Setenv("KEDA_HTTP_MIN_TLS_VERSION", testData.envValue) - } - minVersion, _ := initMinTLSVersion() + minVersion, _ := ParseTLSVersion(testData.envValue, tls.VersionTLS12) if testData.expectedVersion != minVersion { t.Error("Failed to resolve minTLSVersion correctly", "wants", testData.expectedVersion, "got", minVersion) } } } + +func TestParseTLSCipherList(t *testing.T) { + // Collect valid cipher names from the runtime for use in tests + allCiphers := tls.CipherSuites() + if len(allCiphers) == 0 { + t.Skip("no cipher suites available") + } + first := allCiphers[0] + second := allCiphers[len(allCiphers)-1] + + tests := []struct { + name string + input string + wantNil bool + wantIDs []uint16 + }{ + { + name: "empty string returns nil", + input: "", + wantNil: true, + }, + { + name: "unknown cipher names return nil", + input: "NOT_A_CIPHER:ALSO_NOT_A_CIPHER", + wantNil: true, + }, + { + name: "single valid cipher colon-separated", + input: first.Name, + wantIDs: []uint16{first.ID}, + }, + { + name: "two valid ciphers colon-separated", + input: first.Name + ":" + second.Name, + wantIDs: []uint16{first.ID, second.ID}, + }, + { + name: "two valid ciphers comma-separated", + input: first.Name + "," + second.Name, + wantIDs: []uint16{first.ID, second.ID}, + }, + { + name: "valid cipher mixed with unknown cipher", + input: first.Name + ":NOT_A_CIPHER", + wantIDs: []uint16{first.ID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseTLSCipherList(tt.input) + if tt.wantNil { + if got != nil { + t.Errorf("expected nil, got %v", got) + } + return + } + if len(got) != len(tt.wantIDs) { + t.Fatalf("expected %d cipher(s), got %d: %v", len(tt.wantIDs), len(got), got) + } + for i, id := range tt.wantIDs { + if got[i] != id { + t.Errorf("cipher[%d]: expected 0x%04x, got 0x%04x", i, id, got[i]) + } + } + }) + } +} + +func TestParseTLSCipherListEnvVar(t *testing.T) { + allCiphers := tls.CipherSuites() + if len(allCiphers) == 0 { + t.Skip("no cipher suites available") + } + first := allCiphers[0] + + t.Run("env var unset returns nil", func(t *testing.T) { + os.Unsetenv("KEDA_HTTP_TLS_CIPHER_LIST") + if got := ParseTLSCipherList(os.Getenv("KEDA_HTTP_TLS_CIPHER_LIST")); got != nil { + t.Errorf("expected nil, got %v", got) + } + }) + + t.Run("env var set returns ciphers", func(t *testing.T) { + os.Setenv("KEDA_HTTP_TLS_CIPHER_LIST", first.Name) + defer os.Unsetenv("KEDA_HTTP_TLS_CIPHER_LIST") + got := ParseTLSCipherList(os.Getenv("KEDA_HTTP_TLS_CIPHER_LIST")) + if len(got) != 1 || got[0] != first.ID { + t.Errorf("expected [0x%04x], got %v", first.ID, got) + } + }) + + t.Run("all available ciphers colon-separated", func(t *testing.T) { + names := make([]string, len(allCiphers)) + for i, c := range allCiphers { + names[i] = c.Name + } + got := ParseTLSCipherList(strings.Join(names, ":")) + if len(got) != len(allCiphers) { + t.Errorf("expected %d ciphers, got %d", len(allCiphers), len(got)) + } + }) +}