diff --git a/network/depcheck_test.go b/network/depcheck_test.go index aeb905c02d..19865e460d 100644 --- a/network/depcheck_test.go +++ b/network/depcheck_test.go @@ -26,5 +26,6 @@ func TestNoDeps(t *testing.T) { depcheck.AssertNoDependency(t, map[string][]string{ "knative.dev/pkg/network": depcheck.KnownHeavyDependencies, "knative.dev/pkg/network/handlers": depcheck.KnownHeavyDependencies, + "knative.dev/pkg/network/tls": depcheck.KnownHeavyDependencies, }) } diff --git a/network/tls/config.go b/network/tls/config.go new file mode 100644 index 0000000000..6cd205baeb --- /dev/null +++ b/network/tls/config.go @@ -0,0 +1,156 @@ +/* +Copyright 2026 The Knative 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 tls + +import ( + cryptotls "crypto/tls" + "fmt" + "os" + "strings" +) + +// Environment variable name suffixes for TLS configuration. +// Use with a prefix to namespace them, e.g. "WEBHOOK_" + MinVersionEnvKey +// reads the WEBHOOK_TLS_MIN_VERSION variable. +const ( + MinVersionEnvKey = "TLS_MIN_VERSION" + MaxVersionEnvKey = "TLS_MAX_VERSION" + CipherSuitesEnvKey = "TLS_CIPHER_SUITES" + CurvePreferencesEnvKey = "TLS_CURVE_PREFERENCES" +) + +// DefaultConfigFromEnv returns a tls.Config with secure defaults. +// The prefix is prepended to each standard env-var suffix; +// for example with prefix "WEBHOOK_" the function reads +// WEBHOOK_TLS_MIN_VERSION, WEBHOOK_TLS_MAX_VERSION, etc. +func DefaultConfigFromEnv(prefix string) (*cryptotls.Config, error) { + cfg := &cryptotls.Config{ + MinVersion: cryptotls.VersionTLS13, + } + + if v := os.Getenv(prefix + MinVersionEnvKey); v != "" { + ver, err := parseVersion(v) + if err != nil { + return nil, fmt.Errorf("invalid %s%s %q: %w", prefix, MinVersionEnvKey, v, err) + } + cfg.MinVersion = ver + } + + if v := os.Getenv(prefix + MaxVersionEnvKey); v != "" { + ver, err := parseVersion(v) + if err != nil { + return nil, fmt.Errorf("invalid %s%s %q: %w", prefix, MaxVersionEnvKey, v, err) + } + cfg.MaxVersion = ver + } + + if v := os.Getenv(prefix + CipherSuitesEnvKey); v != "" { + suites, err := parseCipherSuites(v) + if err != nil { + return nil, fmt.Errorf("invalid %s%s: %w", prefix, CipherSuitesEnvKey, err) + } + cfg.CipherSuites = suites + } + + if v := os.Getenv(prefix + CurvePreferencesEnvKey); v != "" { + curves, err := parseCurvePreferences(v) + if err != nil { + return nil, fmt.Errorf("invalid %s%s: %w", prefix, CurvePreferencesEnvKey, err) + } + cfg.CurvePreferences = curves + } + + return cfg, nil +} + +// parseVersion converts a TLS version string to the corresponding +// crypto/tls constant. Accepted values are "1.2" and "1.3". +func parseVersion(v string) (uint16, error) { + switch v { + case "1.2": + return cryptotls.VersionTLS12, nil + case "1.3": + return cryptotls.VersionTLS13, nil + default: + return 0, fmt.Errorf("unsupported TLS version %q: must be %q or %q", v, "1.2", "1.3") + } +} + +// parseCipherSuites parses a comma-separated list of TLS cipher-suite names +// (e.g. "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") +// into a slice of cipher-suite IDs. Names must match those returned by +// crypto/tls.CipherSuiteName. +func parseCipherSuites(s string) ([]uint16, error) { + lookup := cipherSuiteLookup() + parts := strings.Split(s, ",") + suites := make([]uint16, 0, len(parts)) + + for _, name := range parts { + name = strings.TrimSpace(name) + if name == "" { + continue + } + id, ok := lookup[name] + if !ok { + return nil, fmt.Errorf("unknown cipher suite %q", name) + } + suites = append(suites, id) + } + + return suites, nil +} + +// parseCurvePreferences parses a comma-separated list of elliptic-curve names +// (e.g. "X25519,CurveP256") into a slice of crypto/tls.CurveID values. +// Both Go constant names (CurveP256) and standard names (P-256) are accepted. +func parseCurvePreferences(s string) ([]cryptotls.CurveID, error) { + parts := strings.Split(s, ",") + curves := make([]cryptotls.CurveID, 0, len(parts)) + + for _, name := range parts { + name = strings.TrimSpace(name) + if name == "" { + continue + } + id, ok := curvesByName[name] + if !ok { + return nil, fmt.Errorf("unknown curve %q", name) + } + curves = append(curves, id) + } + + return curves, nil +} + +func cipherSuiteLookup() map[string]uint16 { + m := make(map[string]uint16) + for _, cs := range cryptotls.CipherSuites() { + m[cs.Name] = cs.ID + } + return m +} + +var curvesByName = map[string]cryptotls.CurveID{ + "CurveP256": cryptotls.CurveP256, + "CurveP384": cryptotls.CurveP384, + "CurveP521": cryptotls.CurveP521, + "X25519": cryptotls.X25519, + "X25519MLKEM768": cryptotls.X25519MLKEM768, + "P-256": cryptotls.CurveP256, + "P-384": cryptotls.CurveP384, + "P-521": cryptotls.CurveP521, +} diff --git a/network/tls/config_test.go b/network/tls/config_test.go new file mode 100644 index 0000000000..3ee91da727 --- /dev/null +++ b/network/tls/config_test.go @@ -0,0 +1,352 @@ +/* +Copyright 2026 The Knative 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 tls + +import ( + cryptotls "crypto/tls" + "testing" +) + +func Test_parseVersion(t *testing.T) { + tests := []struct { + name string + input string + want uint16 + wantErr bool + }{ + {name: "TLS 1.2", input: "1.2", want: cryptotls.VersionTLS12}, + {name: "TLS 1.3", input: "1.3", want: cryptotls.VersionTLS13}, + {name: "unsupported version", input: "1.0", wantErr: true}, + {name: "unsupported version 1.1", input: "1.1", wantErr: true}, + {name: "trailing space", input: "1.2 ", wantErr: true}, + {name: "empty string", input: "", wantErr: true}, + {name: "garbage", input: "abc", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseVersion(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("parseVersion(%q) = %d, want error", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("parseVersion(%q) unexpected error: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("parseVersion(%q) = %d, want %d", tc.input, got, tc.want) + } + }) + } +} + +func Test_parseCipherSuites(t *testing.T) { + tests := []struct { + name string + input string + want []uint16 + wantErr bool + }{ + { + name: "single suite", + input: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + want: []uint16{cryptotls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, + }, + { + name: "multiple suites", + input: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + want: []uint16{ + cryptotls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + cryptotls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + }, + { + name: "whitespace trimmed", + input: " TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 , TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 ", + want: []uint16{ + cryptotls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + cryptotls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + }, + { + name: "empty parts skipped", + input: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,,", + want: []uint16{cryptotls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, + }, + { + name: "unknown suite", + input: "DOES_NOT_EXIST", + wantErr: true, + }, + { + name: "empty string", + want: []uint16{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseCipherSuites(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("parseCipherSuites(%q) = %v, want error", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("parseCipherSuites(%q) unexpected error: %v", tc.input, err) + } + if len(got) != len(tc.want) { + t.Fatalf("parseCipherSuites(%q) returned %d suites, want %d", tc.input, len(got), len(tc.want)) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Errorf("parseCipherSuites(%q)[%d] = %d, want %d", tc.input, i, got[i], tc.want[i]) + } + } + }) + } +} + +func Test_parseCurvePreferences(t *testing.T) { + tests := []struct { + name string + input string + want []cryptotls.CurveID + wantErr bool + }{ + { + name: "Go constant name X25519", + input: "X25519", + want: []cryptotls.CurveID{cryptotls.X25519}, + }, + { + name: "Go constant name CurveP256", + input: "CurveP256", + want: []cryptotls.CurveID{cryptotls.CurveP256}, + }, + { + name: "standard name P-256", + input: "P-256", + want: []cryptotls.CurveID{cryptotls.CurveP256}, + }, + { + name: "multiple curves with mixed naming", + input: "X25519,P-256,CurveP384", + want: []cryptotls.CurveID{ + cryptotls.X25519, + cryptotls.CurveP256, + cryptotls.CurveP384, + }, + }, + { + name: "whitespace trimmed", + input: " X25519 , CurveP256 ", + want: []cryptotls.CurveID{ + cryptotls.X25519, + cryptotls.CurveP256, + }, + }, + { + name: "all curves by standard name", + input: "P-256,P-384,P-521,X25519", + want: []cryptotls.CurveID{ + cryptotls.CurveP256, + cryptotls.CurveP384, + cryptotls.CurveP521, + cryptotls.X25519, + }, + }, + { + name: "post-quantum hybrid X25519MLKEM768", + input: "X25519MLKEM768", + want: []cryptotls.CurveID{cryptotls.X25519MLKEM768}, + }, + { + name: "unknown curve", + input: "CurveP128", + wantErr: true, + }, + { + name: "empty string", + want: []cryptotls.CurveID{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseCurvePreferences(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("parseCurvePreferences(%q) = %v, want error", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("parseCurvePreferences(%q) unexpected error: %v", tc.input, err) + } + if len(got) != len(tc.want) { + t.Fatalf("parseCurvePreferences(%q) returned %d curves, want %d", tc.input, len(got), len(tc.want)) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Errorf("parseCurvePreferences(%q)[%d] = %d, want %d", tc.input, i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestDefaultConfigFromEnv(t *testing.T) { + t.Run("no env vars returns TLS 1.3 default", func(t *testing.T) { + cfg, err := DefaultConfigFromEnv("") + if err != nil { + t.Fatal("unexpected error:", err) + } + if cfg.MinVersion != cryptotls.VersionTLS13 { + t.Errorf("MinVersion = %d, want %d", cfg.MinVersion, cryptotls.VersionTLS13) + } + if cfg.MaxVersion != 0 { + t.Errorf("MaxVersion = %d, want 0", cfg.MaxVersion) + } + if cfg.CipherSuites != nil { + t.Errorf("CipherSuites = %v, want nil", cfg.CipherSuites) + } + if cfg.CurvePreferences != nil { + t.Errorf("CurvePreferences = %v, want nil", cfg.CurvePreferences) + } + }) + + t.Run("min version from env overrides default", func(t *testing.T) { + t.Setenv(MinVersionEnvKey, "1.2") + cfg, err := DefaultConfigFromEnv("") + if err != nil { + t.Fatal("unexpected error:", err) + } + if cfg.MinVersion != cryptotls.VersionTLS12 { + t.Errorf("MinVersion = %d, want %d", cfg.MinVersion, cryptotls.VersionTLS12) + } + }) + + t.Run("max version from env", func(t *testing.T) { + t.Setenv(MaxVersionEnvKey, "1.3") + cfg, err := DefaultConfigFromEnv("") + if err != nil { + t.Fatal("unexpected error:", err) + } + if cfg.MaxVersion != cryptotls.VersionTLS13 { + t.Errorf("MaxVersion = %d, want %d", cfg.MaxVersion, cryptotls.VersionTLS13) + } + }) + + t.Run("cipher suites from env", func(t *testing.T) { + t.Setenv(CipherSuitesEnvKey, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + cfg, err := DefaultConfigFromEnv("") + if err != nil { + t.Fatal("unexpected error:", err) + } + if len(cfg.CipherSuites) != 1 || cfg.CipherSuites[0] != cryptotls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 { + t.Errorf("CipherSuites = %v, want [%d]", cfg.CipherSuites, cryptotls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) + } + }) + + t.Run("curve preferences from env", func(t *testing.T) { + t.Setenv(CurvePreferencesEnvKey, "X25519,CurveP256") + cfg, err := DefaultConfigFromEnv("") + if err != nil { + t.Fatal("unexpected error:", err) + } + if len(cfg.CurvePreferences) != 2 { + t.Fatalf("CurvePreferences has %d entries, want 2", len(cfg.CurvePreferences)) + } + if cfg.CurvePreferences[0] != cryptotls.X25519 { + t.Errorf("CurvePreferences[0] = %d, want %d", cfg.CurvePreferences[0], cryptotls.X25519) + } + if cfg.CurvePreferences[1] != cryptotls.CurveP256 { + t.Errorf("CurvePreferences[1] = %d, want %d", cfg.CurvePreferences[1], cryptotls.CurveP256) + } + }) + + t.Run("prefix is prepended to env key", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_MIN_VERSION", "1.2") + cfg, err := DefaultConfigFromEnv("WEBHOOK_") + if err != nil { + t.Fatal("unexpected error:", err) + } + if cfg.MinVersion != cryptotls.VersionTLS12 { + t.Errorf("MinVersion = %d, want %d", cfg.MinVersion, cryptotls.VersionTLS12) + } + }) + + t.Run("all env vars set", func(t *testing.T) { + t.Setenv(MinVersionEnvKey, "1.2") + t.Setenv(MaxVersionEnvKey, "1.3") + t.Setenv(CipherSuitesEnvKey, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + t.Setenv(CurvePreferencesEnvKey, "X25519,P-256") + + cfg, err := DefaultConfigFromEnv("") + if err != nil { + t.Fatal("unexpected error:", err) + } + if cfg.MinVersion != cryptotls.VersionTLS12 { + t.Errorf("MinVersion = %d, want %d", cfg.MinVersion, cryptotls.VersionTLS12) + } + if cfg.MaxVersion != cryptotls.VersionTLS13 { + t.Errorf("MaxVersion = %d, want %d", cfg.MaxVersion, cryptotls.VersionTLS13) + } + if len(cfg.CipherSuites) != 2 { + t.Fatalf("CipherSuites has %d entries, want 2", len(cfg.CipherSuites)) + } + if len(cfg.CurvePreferences) != 2 { + t.Fatalf("CurvePreferences has %d entries, want 2", len(cfg.CurvePreferences)) + } + }) + + t.Run("invalid min version", func(t *testing.T) { + t.Setenv(MinVersionEnvKey, "1.0") + _, err := DefaultConfigFromEnv("") + if err == nil { + t.Fatal("expected error for invalid min version") + } + }) + + t.Run("invalid max version", func(t *testing.T) { + t.Setenv(MaxVersionEnvKey, "bad") + _, err := DefaultConfigFromEnv("") + if err == nil { + t.Fatal("expected error for invalid max version") + } + }) + + t.Run("invalid cipher suite", func(t *testing.T) { + t.Setenv(CipherSuitesEnvKey, "NOT_A_REAL_CIPHER") + _, err := DefaultConfigFromEnv("") + if err == nil { + t.Fatal("expected error for invalid cipher suite") + } + }) + + t.Run("invalid curve", func(t *testing.T) { + t.Setenv(CurvePreferencesEnvKey, "NotACurve") + _, err := DefaultConfigFromEnv("") + if err == nil { + t.Fatal("expected error for invalid curve") + } + }) +} diff --git a/webhook/env.go b/webhook/env.go index e622f5f97b..6d3d32203f 100644 --- a/webhook/env.go +++ b/webhook/env.go @@ -72,6 +72,8 @@ func SecretNameFromEnv(defaultSecretName string) string { return secret } +// Deprecated: Use knative.dev/pkg/network/tls.DefaultConfigFromEnv instead. +// TLS configuration is now read automatically inside webhook.New via the shared tls package. func TLSMinVersionFromEnv(defaultTLSMinVersion uint16) uint16 { switch tlsMinVersion := os.Getenv(tlsMinVersionEnvKey); tlsMinVersion { case "1.2": diff --git a/webhook/webhook.go b/webhook/webhook.go index badc7fef83..e8895db75e 100644 --- a/webhook/webhook.go +++ b/webhook/webhook.go @@ -33,6 +33,7 @@ import ( kubeinformerfactory "knative.dev/pkg/injection/clients/namespacedkube/informers/factory" "knative.dev/pkg/network" "knative.dev/pkg/network/handlers" + knativetls "knative.dev/pkg/network/tls" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/metric" @@ -46,7 +47,15 @@ import ( "knative.dev/pkg/system" ) -// Options contains the configuration for the webhook +// Options contains the configuration for the webhook. +// +// TLS fields (TLSMinVersion, TLSMaxVersion, TLSCipherSuites, TLSCurvePreferences) +// are resolved with the following precedence: +// 1. Values set explicitly in Options (programmatic). +// 2. WEBHOOK_TLS_* environment variables (WEBHOOK_TLS_MIN_VERSION, +// WEBHOOK_TLS_MAX_VERSION, WEBHOOK_TLS_CIPHER_SUITES, WEBHOOK_TLS_CURVE_PREFERENCES). +// 3. Defaults (TLS 1.3 minimum version; zero values for the rest, meaning the +// Go standard library picks its defaults). type Options struct { // TLSMinVersion contains the minimum TLS version that is acceptable to communicate with the API server. // TLS 1.3 is the minimum version if not specified otherwise. @@ -180,11 +189,29 @@ func New( logger := logging.FromContext(ctx) - defaultTLSMinVersion := uint16(tls.VersionTLS13) - if opts.TLSMinVersion == 0 { - opts.TLSMinVersion = TLSMinVersionFromEnv(defaultTLSMinVersion) - } else if opts.TLSMinVersion != tls.VersionTLS12 && opts.TLSMinVersion != tls.VersionTLS13 { - return nil, fmt.Errorf("unsupported TLS version: %d", opts.TLSMinVersion) + tlsCfg, err := knativetls.DefaultConfigFromEnv("WEBHOOK_") + if err != nil { + return nil, fmt.Errorf("reading TLS configuration from environment: %w", err) + } + + if opts.TLSMinVersion != 0 { + tlsCfg.MinVersion = opts.TLSMinVersion + } + if opts.TLSMaxVersion != 0 { + tlsCfg.MaxVersion = opts.TLSMaxVersion + } + if opts.TLSCipherSuites != nil { + tlsCfg.CipherSuites = opts.TLSCipherSuites + } + if opts.TLSCurvePreferences != nil { + tlsCfg.CurvePreferences = opts.TLSCurvePreferences + } + + if tlsCfg.MinVersion != tls.VersionTLS12 && tlsCfg.MinVersion != tls.VersionTLS13 { + return nil, fmt.Errorf("unsupported TLS minimum version %d: must be TLS 1.2 or TLS 1.3", tlsCfg.MinVersion) + } + if tlsCfg.MaxVersion != 0 && tlsCfg.MinVersion > tlsCfg.MaxVersion { + return nil, fmt.Errorf("TLS minimum version (%#x) is greater than maximum version (%#x)", tlsCfg.MinVersion, tlsCfg.MaxVersion) } syncCtx, cancel := context.WithCancel(context.Background()) @@ -204,42 +231,35 @@ func New( // a new secret informer from it. secretInformer := kubeinformerfactory.Get(ctx).Core().V1().Secrets() - //nolint:gosec // operator configures TLS min version (default is 1.3) - webhook.tlsConfig = &tls.Config{ - MinVersion: opts.TLSMinVersion, - MaxVersion: opts.TLSMaxVersion, - CipherSuites: opts.TLSCipherSuites, - CurvePreferences: opts.TLSCurvePreferences, - - // If we return (nil, error) the client sees - 'tls: internal error" - // If we return (nil, nil) the client sees - 'tls: no certificates configured' - // - // We'll return (nil, nil) when we don't find a certificate - GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - secret, err := secretInformer.Lister().Secrets(system.Namespace()).Get(opts.SecretName) - if err != nil { - logger.Errorw("failed to fetch secret", zap.Error(err)) - return nil, nil - } - webOpts := GetOptions(ctx) - sKey, sCert := getSecretDataKeyNamesOrDefault(webOpts.ServerPrivateKeyName, webOpts.ServerCertificateName) - serverKey, ok := secret.Data[sKey] - if !ok { - logger.Warn("server key missing") - return nil, nil - } - serverCert, ok := secret.Data[sCert] - if !ok { - logger.Warn("server cert missing") - return nil, nil - } - cert, err := tls.X509KeyPair(serverCert, serverKey) - if err != nil { - return nil, err - } - return &cert, nil - }, + // If we return (nil, error) the client sees - 'tls: internal error' + // If we return (nil, nil) the client sees - 'tls: no certificates configured' + // + // We'll return (nil, nil) when we don't find a certificate + tlsCfg.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + secret, err := secretInformer.Lister().Secrets(system.Namespace()).Get(opts.SecretName) + if err != nil { + logger.Errorw("failed to fetch secret", zap.Error(err)) + return nil, nil + } + webOpts := GetOptions(ctx) + sKey, sCert := getSecretDataKeyNamesOrDefault(webOpts.ServerPrivateKeyName, webOpts.ServerCertificateName) + serverKey, ok := secret.Data[sKey] + if !ok { + logger.Warn("server key missing") + return nil, nil + } + serverCert, ok := secret.Data[sCert] + if !ok { + logger.Warn("server cert missing") + return nil, nil + } + cert, err := tls.X509KeyPair(serverCert, serverKey) + if err != nil { + return nil, err + } + return &cert, nil } + webhook.tlsConfig = tlsCfg } webhook.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/webhook/webhook_test.go b/webhook/webhook_test.go index d4bb521dff..cb72a759b9 100644 --- a/webhook/webhook_test.go +++ b/webhook/webhook_test.go @@ -103,11 +103,17 @@ func newAdmissionControllerWebhook(t *testing.T, options Options, acs ...interfa func TestTLSMinVersionWebhookOption(t *testing.T) { opts := newDefaultOptions() - t.Run("when TLSMinVersion is not configured, and the default is used", func(t *testing.T) { - _, err := newAdmissionControllerWebhook(t, opts) + t.Run("when TLSMinVersion is not configured, default is TLS 1.3", func(t *testing.T) { + wh, err := newAdmissionControllerWebhook(t, opts) if err != nil { t.Fatal("Unexpected error", err) } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if wh.tlsConfig.MinVersion != tls.VersionTLS13 { + t.Errorf("Expected default MinVersion to be TLS 1.3 (%#x), got %#x", tls.VersionTLS13, wh.tlsConfig.MinVersion) + } }) t.Run("when the TLS minimum version configured is supported", func(t *testing.T) { opts.TLSMinVersion = tls.VersionTLS12 @@ -169,6 +175,36 @@ func TestTLSMaxVersionWebhookOption(t *testing.T) { }) } +func TestTLSMinMaxVersionValidation(t *testing.T) { + t.Run("max version less than min version returns error", func(t *testing.T) { + opts := newDefaultOptions() + opts.TLSMinVersion = tls.VersionTLS13 + opts.TLSMaxVersion = tls.VersionTLS12 + _, err := newAdmissionControllerWebhook(t, opts) + if err == nil { + t.Fatal("Expected error when TLS max version is less than min version") + } + }) + t.Run("max version equal to min version is ok", func(t *testing.T) { + opts := newDefaultOptions() + opts.TLSMinVersion = tls.VersionTLS13 + opts.TLSMaxVersion = tls.VersionTLS13 + _, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error:", err) + } + }) + t.Run("max version zero skips validation", func(t *testing.T) { + opts := newDefaultOptions() + opts.TLSMinVersion = tls.VersionTLS13 + opts.TLSMaxVersion = 0 + _, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error:", err) + } + }) +} + func TestTLSCipherSuitesWebhookOption(t *testing.T) { opts := newDefaultOptions() t.Run("when TLSCipherSuites is not configured", func(t *testing.T) { @@ -242,6 +278,156 @@ func TestTLSCurvePreferencesWebhookOption(t *testing.T) { }) } +func TestTLSConfigFromEnvironment(t *testing.T) { + t.Run("env min version used when opts min version is zero", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_MIN_VERSION", "1.2") + opts := newDefaultOptions() + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if wh.tlsConfig.MinVersion != tls.VersionTLS12 { + t.Errorf("Expected MinVersion from env to be TLS 1.2, got %d", wh.tlsConfig.MinVersion) + } + }) + + t.Run("opts min version takes precedence over env", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_MIN_VERSION", "1.2") + opts := newDefaultOptions() + opts.TLSMinVersion = tls.VersionTLS13 + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if wh.tlsConfig.MinVersion != tls.VersionTLS13 { + t.Errorf("Expected MinVersion from opts (TLS 1.3), got %d", wh.tlsConfig.MinVersion) + } + }) + + t.Run("env max version used when opts max version is zero", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_MAX_VERSION", "1.3") + opts := newDefaultOptions() + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if wh.tlsConfig.MaxVersion != tls.VersionTLS13 { + t.Errorf("Expected MaxVersion from env to be TLS 1.3, got %d", wh.tlsConfig.MaxVersion) + } + }) + + t.Run("opts max version takes precedence over env", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_MAX_VERSION", "1.2") + opts := newDefaultOptions() + opts.TLSMaxVersion = tls.VersionTLS13 + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if wh.tlsConfig.MaxVersion != tls.VersionTLS13 { + t.Errorf("Expected MaxVersion from opts (TLS 1.3), got %d", wh.tlsConfig.MaxVersion) + } + }) + + t.Run("env cipher suites used when opts cipher suites is nil", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_CIPHER_SUITES", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + opts := newDefaultOptions() + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if len(wh.tlsConfig.CipherSuites) != 1 { + t.Fatalf("Expected 1 cipher suite from env, got %d", len(wh.tlsConfig.CipherSuites)) + } + if wh.tlsConfig.CipherSuites[0] != tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 { + t.Errorf("Expected CipherSuites from env, got %v", wh.tlsConfig.CipherSuites) + } + }) + + t.Run("opts cipher suites take precedence over env", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_CIPHER_SUITES", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + opts := newDefaultOptions() + opts.TLSCipherSuites = []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384} + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if len(wh.tlsConfig.CipherSuites) != 1 { + t.Fatalf("Expected 1 cipher suite from opts, got %d", len(wh.tlsConfig.CipherSuites)) + } + if wh.tlsConfig.CipherSuites[0] != tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 { + t.Errorf("Expected CipherSuites from opts, got %v", wh.tlsConfig.CipherSuites) + } + }) + + t.Run("env curve preferences used when opts curve preferences is nil", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_CURVE_PREFERENCES", "X25519,CurveP256") + opts := newDefaultOptions() + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if len(wh.tlsConfig.CurvePreferences) != 2 { + t.Fatalf("Expected 2 curve preferences from env, got %d", len(wh.tlsConfig.CurvePreferences)) + } + if wh.tlsConfig.CurvePreferences[0] != tls.X25519 { + t.Errorf("Expected CurvePreferences[0] = X25519, got %d", wh.tlsConfig.CurvePreferences[0]) + } + if wh.tlsConfig.CurvePreferences[1] != tls.CurveP256 { + t.Errorf("Expected CurvePreferences[1] = CurveP256, got %d", wh.tlsConfig.CurvePreferences[1]) + } + }) + + t.Run("opts curve preferences take precedence over env", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_CURVE_PREFERENCES", "X25519") + opts := newDefaultOptions() + opts.TLSCurvePreferences = []tls.CurveID{tls.CurveP384} + wh, err := newAdmissionControllerWebhook(t, opts) + if err != nil { + t.Fatal("Unexpected error", err) + } + if wh.tlsConfig == nil { + t.Fatal("Expected tlsConfig to be set") + } + if len(wh.tlsConfig.CurvePreferences) != 1 { + t.Fatalf("Expected 1 curve preference from opts, got %d", len(wh.tlsConfig.CurvePreferences)) + } + if wh.tlsConfig.CurvePreferences[0] != tls.CurveP384 { + t.Errorf("Expected CurvePreferences from opts, got %v", wh.tlsConfig.CurvePreferences) + } + }) + + t.Run("invalid env TLS config returns error", func(t *testing.T) { + t.Setenv("WEBHOOK_TLS_MIN_VERSION", "bad") + opts := newDefaultOptions() + _, err := newAdmissionControllerWebhook(t, opts) + if err == nil { + t.Fatal("Expected error for invalid env TLS min version") + } + }) +} + func TestTLSConfigCombinedOptions(t *testing.T) { opts := newDefaultOptions() t.Run("when all TLS options are configured together", func(t *testing.T) {