diff --git a/.changelog/2711.txt b/.changelog/2711.txt new file mode 100644 index 0000000000..abb0b7e4fb --- /dev/null +++ b/.changelog/2711.txt @@ -0,0 +1,3 @@ +```release-note:feature +api-gateway: translate and validate TLS configuration options, including min/max version and cipher suites, setting Gateway status appropriately +``` diff --git a/control-plane/api-gateway/binding/result.go b/control-plane/api-gateway/binding/result.go index 1953d4836c..e6c0760e7a 100644 --- a/control-plane/api-gateway/binding/result.go +++ b/control-plane/api-gateway/binding/result.go @@ -241,7 +241,11 @@ var ( // Below is where any custom generic listener validation errors should go. // We map anything under here to a custom ListenerConditionReason of Invalid on // an Accepted status type. - errListenerNoTLSPassthrough = errors.New("TLS passthrough is not supported") + errListenerNoTLSPassthrough = errors.New("TLS passthrough is not supported") + errListenerTLSCipherSuiteNotConfigurable = errors.New("tls_min_version does not allow tls_cipher_suites configuration") + errListenerUnsupportedTLSCipherSuite = errors.New("unsupported cipher suite in tls_cipher_suites") + errListenerUnsupportedTLSMaxVersion = errors.New("unsupported tls_max_version") + errListenerUnsupportedTLSMinVersion = errors.New("unsupported tls_min_version") // This custom listener validation error is used to differentiate between an errListenerPortUnavailable because of // direct port conflicts defined by the user (two listeners on the same port) vs a port conflict because we map diff --git a/control-plane/api-gateway/binding/validation.go b/control-plane/api-gateway/binding/validation.go index d5a97499ca..6029c10b24 100644 --- a/control-plane/api-gateway/binding/validation.go +++ b/control-plane/api-gateway/binding/validation.go @@ -41,6 +41,45 @@ var ( gwv1beta1.Kind("HTTPRoute"): {}, gwv1beta1.Kind("TCPRoute"): {}, } + + allSupportedTLSVersions = map[string]struct{}{ + "TLS_AUTO": {}, + "TLSv1_0": {}, + "TLSv1_1": {}, + "TLSv1_2": {}, + "TLSv1_3": {}, + } + + allTLSVersionsWithConfigurableCipherSuites = map[string]struct{}{ + // Remove "" and "TLS_AUTO" if Envoy ever sets TLS 1.3 as default minimum + "": {}, + "TLS_AUTO": {}, + "TLSv1_0": {}, + "TLSv1_1": {}, + "TLSv1_2": {}, + } + + allSupportedTLSCipherSuites = map[string]struct{}{ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": {}, + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": {}, + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": {}, + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": {}, + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": {}, + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": {}, + + // NOTE: the following cipher suites are currently supported by Envoy + // but have been identified as insecure and are pending removal + // https://github.com/envoyproxy/envoy/issues/5399 + "TLS_RSA_WITH_AES_128_GCM_SHA256": {}, + "TLS_RSA_WITH_AES_128_CBC_SHA": {}, + "TLS_RSA_WITH_AES_256_GCM_SHA384": {}, + "TLS_RSA_WITH_AES_256_CBC_SHA": {}, + // https://github.com/envoyproxy/envoy/issues/5400 + "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA": {}, + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA": {}, + "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA": {}, + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA": {}, + } ) // validateRefs validates backend references for a route, determining whether or @@ -167,43 +206,92 @@ func (m mergedListeners) validateHostname(index int, listener gwv1beta1.Listener // validateTLS validates that the TLS configuration for a given listener is valid and that // the certificates that it references exist. func validateTLS(gateway gwv1beta1.Gateway, tls *gwv1beta1.GatewayTLSConfig, resources *common.ResourceMap) (error, error) { - namespace := gateway.Namespace - + // If there's no TLS, there's nothing to validate if tls == nil { return nil, nil } - var err error + // Validate the certificate references and then return any error + // alongside any TLS configuration error that we find below. + refsErr := validateCertificateRefs(gateway, tls.CertificateRefs, resources) + + if tls.Mode != nil && *tls.Mode == gwv1beta1.TLSModePassthrough { + return errListenerNoTLSPassthrough, refsErr + } + + if err := validateTLSOptions(tls.Options); err != nil { + return err, refsErr + } + + return nil, refsErr +} - for _, cert := range tls.CertificateRefs { - // break on the first error +func validateCertificateRefs(gateway gwv1beta1.Gateway, refs []gwv1beta1.SecretObjectReference, resources *common.ResourceMap) error { + for _, cert := range refs { + // Verify that the reference has a group and kind that we support if !common.NilOrEqual(cert.Group, "") || !common.NilOrEqual(cert.Kind, common.KindSecret) { - err = errListenerInvalidCertificateRef_NotSupported - break + return errListenerInvalidCertificateRef_NotSupported } + // Verify that the reference is within the namespace or, + // if cross-namespace, that it's allowed by a ReferenceGrant if !resources.GatewayCanReferenceSecret(gateway, cert) { - err = errRefNotPermitted - break + return errRefNotPermitted } - key := common.IndexedNamespacedNameWithDefault(cert.Name, cert.Namespace, namespace) + // Verify that the referenced resource actually exists + key := common.IndexedNamespacedNameWithDefault(cert.Name, cert.Namespace, gateway.Namespace) secret := resources.Certificate(key) - if secret == nil { - err = errListenerInvalidCertificateRef_NotFound - break + return errListenerInvalidCertificateRef_NotFound } - err = validateCertificateData(*secret) + // Verify that the referenced resource contains the data shape that we expect + if err := validateCertificateData(*secret); err != nil { + return err + } } - if tls.Mode != nil && *tls.Mode == gwv1beta1.TLSModePassthrough { - return errListenerNoTLSPassthrough, err + return nil +} + +func validateTLSOptions(options map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue) error { + if options == nil { + return nil + } + + tlsMinVersionValue := string(options[common.TLSMinVersionAnnotationKey]) + if tlsMinVersionValue != "" { + if _, supported := allSupportedTLSVersions[tlsMinVersionValue]; !supported { + return errListenerUnsupportedTLSMinVersion + } + } + + tlsMaxVersionValue := string(options[common.TLSMaxVersionAnnotationKey]) + if tlsMaxVersionValue != "" { + if _, supported := allSupportedTLSVersions[tlsMaxVersionValue]; !supported { + return errListenerUnsupportedTLSMaxVersion + } } - // TODO: validate tls options - return nil, err + tlsCipherSuitesValue := string(options[common.TLSCipherSuitesAnnotationKey]) + if tlsCipherSuitesValue != "" { + // If a minimum TLS version is configured, verify that it supports configuring cipher suites + if tlsMinVersionValue != "" { + if _, supported := allTLSVersionsWithConfigurableCipherSuites[tlsMinVersionValue]; !supported { + return errListenerTLSCipherSuiteNotConfigurable + } + } + + for _, tlsCipherSuiteValue := range strings.Split(tlsCipherSuitesValue, ",") { + tlsCipherSuite := strings.TrimSpace(tlsCipherSuiteValue) + if _, supported := allSupportedTLSCipherSuites[tlsCipherSuite]; !supported { + return errListenerUnsupportedTLSCipherSuite + } + } + } + + return nil } func validateCertificateData(secret corev1.Secret) error { diff --git a/control-plane/api-gateway/binding/validation_test.go b/control-plane/api-gateway/binding/validation_test.go index 10cdf851c3..1f2b143387 100644 --- a/control-plane/api-gateway/binding/validation_test.go +++ b/control-plane/api-gateway/binding/validation_test.go @@ -510,6 +510,47 @@ func TestValidateTLS(t *testing.T) { expectedResolvedRefsErr: nil, expectedAcceptedErr: nil, }, + "invalid cipher suite": { + gateway: gatewayWithFinalizer(gwv1beta1.GatewaySpec{}), + tls: &gwv1beta1.GatewayTLSConfig{ + Options: map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue{ + common.TLSCipherSuitesAnnotationKey: "invalid", + }, + }, + certificates: nil, + expectedAcceptedErr: errListenerUnsupportedTLSCipherSuite, + }, + "cipher suite not configurable": { + gateway: gatewayWithFinalizer(gwv1beta1.GatewaySpec{}), + tls: &gwv1beta1.GatewayTLSConfig{ + Options: map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue{ + common.TLSMinVersionAnnotationKey: "TLSv1_3", + common.TLSCipherSuitesAnnotationKey: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + }, + }, + certificates: nil, + expectedAcceptedErr: errListenerTLSCipherSuiteNotConfigurable, + }, + "invalid max version": { + gateway: gatewayWithFinalizer(gwv1beta1.GatewaySpec{}), + tls: &gwv1beta1.GatewayTLSConfig{ + Options: map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue{ + common.TLSMaxVersionAnnotationKey: "invalid", + }, + }, + certificates: nil, + expectedAcceptedErr: errListenerUnsupportedTLSMaxVersion, + }, + "invalid min version": { + gateway: gatewayWithFinalizer(gwv1beta1.GatewaySpec{}), + tls: &gwv1beta1.GatewayTLSConfig{ + Options: map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue{ + common.TLSMinVersionAnnotationKey: "invalid", + }, + }, + certificates: nil, + expectedAcceptedErr: errListenerUnsupportedTLSMinVersion, + }, } { t.Run(name, func(t *testing.T) { resources := common.NewResourceMap(common.ResourceTranslator{}, NewReferenceValidator(tt.grants), logrtest.NewTestLogger(t)) diff --git a/control-plane/api-gateway/common/constants.go b/control-plane/api-gateway/common/constants.go index c1ec0685a4..04701662b7 100644 --- a/control-plane/api-gateway/common/constants.go +++ b/control-plane/api-gateway/common/constants.go @@ -7,4 +7,9 @@ const ( GatewayClassControllerName = "consul.hashicorp.com/gateway-controller" AnnotationGatewayClassConfig = "consul.hashicorp.com/gateway-class-config" + + // The following annotation keys are used in the v1beta1.GatewayTLSConfig's Options on a v1beta1.Listener. + TLSCipherSuitesAnnotationKey = "api-gateway.consul.hashicorp.com/tls_cipher_suites" + TLSMaxVersionAnnotationKey = "api-gateway.consul.hashicorp.com/tls_max_version" + TLSMinVersionAnnotationKey = "api-gateway.consul.hashicorp.com/tls_min_version" ) diff --git a/control-plane/api-gateway/common/translation.go b/control-plane/api-gateway/common/translation.go index 2f66749c94..9303540e82 100644 --- a/control-plane/api-gateway/common/translation.go +++ b/control-plane/api-gateway/common/translation.go @@ -85,8 +85,17 @@ func (t ResourceTranslator) toAPIGatewayListener(gateway gwv1beta1.Gateway, list namespace := gateway.Namespace var certificates []api.ResourceReference + var cipherSuites []string + var maxVersion, minVersion string if listener.TLS != nil { + cipherSuitesVal := string(listener.TLS.Options[TLSCipherSuitesAnnotationKey]) + if cipherSuitesVal != "" { + cipherSuites = strings.Split(cipherSuitesVal, ",") + } + maxVersion = string(listener.TLS.Options[TLSMaxVersionAnnotationKey]) + minVersion = string(listener.TLS.Options[TLSMinVersionAnnotationKey]) + for _, ref := range listener.TLS.CertificateRefs { if !resources.GatewayCanReferenceSecret(gateway, ref) { return api.APIGatewayListener{}, false @@ -116,6 +125,9 @@ func (t ResourceTranslator) toAPIGatewayListener(gateway gwv1beta1.Gateway, list Protocol: listenerProtocolMap[strings.ToLower(string(listener.Protocol))], TLS: api.APIGatewayTLSConfiguration{ Certificates: certificates, + CipherSuites: cipherSuites, + MaxVersion: maxVersion, + MinVersion: minVersion, }, }, true } diff --git a/control-plane/api-gateway/common/translation_test.go b/control-plane/api-gateway/common/translation_test.go index daa89a698f..e8caad76ed 100644 --- a/control-plane/api-gateway/common/translation_test.go +++ b/control-plane/api-gateway/common/translation_test.go @@ -11,6 +11,7 @@ import ( "encoding/pem" "fmt" "math/big" + "strings" "testing" "time" @@ -134,6 +135,9 @@ func TestTranslator_ToAPIGateway(t *testing.T) { listenerOneCertK8sNamespace := "one-cert-ns" listenerOneCertConsulNamespace := "one-cert-ns" listenerOneCert := generateTestCertificate(t, "one-cert-ns", "one-cert") + listenerOneMaxVersion := "TLSv1_2" + listenerOneMinVersion := "TLSv1_3" + listenerOneCipherSuites := []string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"} // listener one status listenerOneLastTransmissionTime := time.Now() @@ -157,6 +161,7 @@ func TestTranslator_ToAPIGateway(t *testing.T) { annotations map[string]string expectedGWName string listenerOneK8sCertRefs []gwv1beta1.SecretObjectReference + listenerOneTLSOptions map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue }{ "gw name": { annotations: make(map[string]string), @@ -167,6 +172,11 @@ func TestTranslator_ToAPIGateway(t *testing.T) { Namespace: PointerTo(gwv1beta1.Namespace(listenerOneCertK8sNamespace)), }, }, + listenerOneTLSOptions: map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue{ + TLSMaxVersionAnnotationKey: gwv1beta1.AnnotationValue(listenerOneMaxVersion), + TLSMinVersionAnnotationKey: gwv1beta1.AnnotationValue(listenerOneMinVersion), + TLSCipherSuitesAnnotationKey: gwv1beta1.AnnotationValue(strings.Join(listenerOneCipherSuites, ",")), + }, }, "when k8s has certs that are not referenced in consul": { annotations: make(map[string]string), @@ -181,6 +191,11 @@ func TestTranslator_ToAPIGateway(t *testing.T) { Namespace: PointerTo(gwv1beta1.Namespace(listenerOneCertK8sNamespace)), }, }, + listenerOneTLSOptions: map[gwv1beta1.AnnotationKey]gwv1beta1.AnnotationValue{ + TLSMaxVersionAnnotationKey: gwv1beta1.AnnotationValue(listenerOneMaxVersion), + TLSMinVersionAnnotationKey: gwv1beta1.AnnotationValue(listenerOneMinVersion), + TLSCipherSuitesAnnotationKey: gwv1beta1.AnnotationValue(strings.Join(listenerOneCipherSuites, ",")), + }, }, } @@ -207,6 +222,7 @@ func TestTranslator_ToAPIGateway(t *testing.T) { Protocol: gwv1beta1.ProtocolType(listenerOneProtocol), TLS: &gwv1beta1.GatewayTLSConfig{ CertificateRefs: tc.listenerOneK8sCertRefs, + Options: tc.listenerOneTLSOptions, }, }, { @@ -288,6 +304,9 @@ func TestTranslator_ToAPIGateway(t *testing.T) { Namespace: listenerOneCertConsulNamespace, }, }, + CipherSuites: listenerOneCipherSuites, + MaxVersion: listenerOneMaxVersion, + MinVersion: listenerOneMinVersion, }, }, { @@ -303,6 +322,9 @@ func TestTranslator_ToAPIGateway(t *testing.T) { Namespace: listenerTwoCertConsulNamespace, }, }, + CipherSuites: nil, + MaxVersion: "", + MinVersion: "", }, }, },