diff --git a/apis/projectcontour/v1/httpproxy.go b/apis/projectcontour/v1/httpproxy.go index 2304d27cda8..eb53a01566d 100644 --- a/apis/projectcontour/v1/httpproxy.go +++ b/apis/projectcontour/v1/httpproxy.go @@ -1131,6 +1131,14 @@ type DownstreamValidation struct { // certificate chain will be subject to validation by CRL. // +optional OnlyVerifyLeafCertCrl bool `json:"crlOnlyVerifyLeafCert"` + + // OptionalClientCertificate when set to true will request a client certificate + // but allow the connection to continue if the client does not provide one. + // If a client certificate is sent, it will be verified according to the + // other properties, which includes disabling validation if + // SkipClientCertValidation is set. Defaults to false. + // +optional + OptionalClientCertificate bool `json:"optionalClientCertificate"` } // HTTPProxyStatus reports the current state of the HTTPProxy. diff --git a/changelogs/unreleased/4796-gautierdelorme-minor.md b/changelogs/unreleased/4796-gautierdelorme-minor.md new file mode 100644 index 00000000000..0ea08b5a282 --- /dev/null +++ b/changelogs/unreleased/4796-gautierdelorme-minor.md @@ -0,0 +1,6 @@ +## Optional Client Certificate Validation + +By default, when client certificate validation is configured, client certificates are required. +However, some applications might support different authentication schemes. +You can now set the `httpproxy.spec.virtualhost.tls.clientValidation.optionalClientCertificate` field to `true`. A client certificate will be requested, but the connection is allowed to continue if the client does not provide one. +If a client certificate is sent, it will be verified according to the other properties, which includes disabling validations if `httpproxy.spec.virtualhost.tls.clientValidation.skipClientCertValidation` is set. diff --git a/examples/contour/01-crds.yaml b/examples/contour/01-crds.yaml index f23726a0930..138c81a936c 100644 --- a/examples/contour/01-crds.yaml +++ b/examples/contour/01-crds.yaml @@ -5766,6 +5766,14 @@ spec: secrets are limited to 1MiB in size. minLength: 1 type: string + optionalClientCertificate: + description: OptionalClientCertificate when set to true + will request a client certificate but allow the connection + to continue if the client does not provide one. If a + client certificate is sent, it will be verified according + to the other properties, which includes disabling validation + if SkipClientCertValidation is set. Defaults to false. + type: boolean skipClientCertValidation: description: SkipClientCertValidation disables downstream client certificate validation. Defaults to false. This diff --git a/examples/render/contour-deployment.yaml b/examples/render/contour-deployment.yaml index b61b359c7e6..78be7e3221d 100644 --- a/examples/render/contour-deployment.yaml +++ b/examples/render/contour-deployment.yaml @@ -5975,6 +5975,14 @@ spec: secrets are limited to 1MiB in size. minLength: 1 type: string + optionalClientCertificate: + description: OptionalClientCertificate when set to true + will request a client certificate but allow the connection + to continue if the client does not provide one. If a + client certificate is sent, it will be verified according + to the other properties, which includes disabling validation + if SkipClientCertValidation is set. Defaults to false. + type: boolean skipClientCertValidation: description: SkipClientCertValidation disables downstream client certificate validation. Defaults to false. This diff --git a/examples/render/contour-gateway-provisioner.yaml b/examples/render/contour-gateway-provisioner.yaml index 13c563a9ab3..bd6b7f9b928 100644 --- a/examples/render/contour-gateway-provisioner.yaml +++ b/examples/render/contour-gateway-provisioner.yaml @@ -5780,6 +5780,14 @@ spec: secrets are limited to 1MiB in size. minLength: 1 type: string + optionalClientCertificate: + description: OptionalClientCertificate when set to true + will request a client certificate but allow the connection + to continue if the client does not provide one. If a + client certificate is sent, it will be verified according + to the other properties, which includes disabling validation + if SkipClientCertValidation is set. Defaults to false. + type: boolean skipClientCertValidation: description: SkipClientCertValidation disables downstream client certificate validation. Defaults to false. This diff --git a/examples/render/contour-gateway.yaml b/examples/render/contour-gateway.yaml index 7851d6a1557..631c3c34be6 100644 --- a/examples/render/contour-gateway.yaml +++ b/examples/render/contour-gateway.yaml @@ -5981,6 +5981,14 @@ spec: secrets are limited to 1MiB in size. minLength: 1 type: string + optionalClientCertificate: + description: OptionalClientCertificate when set to true + will request a client certificate but allow the connection + to continue if the client does not provide one. If a + client certificate is sent, it will be verified according + to the other properties, which includes disabling validation + if SkipClientCertValidation is set. Defaults to false. + type: boolean skipClientCertValidation: description: SkipClientCertValidation disables downstream client certificate validation. Defaults to false. This diff --git a/examples/render/contour.yaml b/examples/render/contour.yaml index 634873c3c11..5658dcc515b 100644 --- a/examples/render/contour.yaml +++ b/examples/render/contour.yaml @@ -5975,6 +5975,14 @@ spec: secrets are limited to 1MiB in size. minLength: 1 type: string + optionalClientCertificate: + description: OptionalClientCertificate when set to true + will request a client certificate but allow the connection + to continue if the client does not provide one. If a + client certificate is sent, it will be verified according + to the other properties, which includes disabling validation + if SkipClientCertValidation is set. Defaults to false. + type: boolean skipClientCertValidation: description: SkipClientCertValidation disables downstream client certificate validation. Defaults to false. This diff --git a/internal/dag/builder_test.go b/internal/dag/builder_test.go index b5b26f06214..b128e16c604 100644 --- a/internal/dag/builder_test.go +++ b/internal/dag/builder_test.go @@ -6521,6 +6521,35 @@ func TestDAGInsert(t *testing.T) { }, } + // proxy24 is downstream validation, optional cert validation + proxy24 := &contour_api_v1.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-com", + Namespace: "default", + }, + Spec: contour_api_v1.HTTPProxySpec{ + VirtualHost: &contour_api_v1.VirtualHost{ + Fqdn: "example.com", + TLS: &contour_api_v1.TLS{ + SecretName: sec1.Name, + ClientValidation: &contour_api_v1.DownstreamValidation{ + CACertificate: cert1.Name, + OptionalClientCertificate: true, + }, + }, + }, + Routes: []contour_api_v1.Route{{ + Conditions: []contour_api_v1.MatchCondition{{ + Prefix: "/", + }}, + Services: []contour_api_v1.Service{{ + Name: s1.Name, + Port: 8080, + }}, + }}, + }, + } + // invalid because tcpproxy both includes another and // has a list of services. proxy37 := &contour_api_v1.HTTPProxy{ @@ -9774,6 +9803,38 @@ func TestDAGInsert(t *testing.T) { }, ), }, + "insert httpproxy w/ tls termination with optional client validation": { + objs: []interface{}{ + proxy24, s1, sec1, cert1, crl, + }, + want: listeners( + &Listener{ + Name: HTTP_LISTENER_NAME, + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("example.com", routeUpgrade("/", service(s1))), + ), + }, &Listener{ + Name: HTTPS_LISTENER_NAME, + Port: 443, + SecureVirtualHosts: securevirtualhosts( + &SecureVirtualHost{ + VirtualHost: VirtualHost{ + Name: "example.com", + Routes: routes( + routeUpgrade("/", service(s1))), + }, + MinTLSVersion: "1.2", + Secret: secret(sec1), + DownstreamValidation: &PeerValidationContext{ + CACertificate: &Secret{Object: cert1}, + OptionalClientCertificate: true, + }, + }, + ), + }, + ), + }, "insert httpproxy with downstream verification, missing ca certificate": { objs: []interface{}{ proxy18, s1, sec1, diff --git a/internal/dag/dag.go b/internal/dag/dag.go index 53e60635a49..32395c8d38d 100644 --- a/internal/dag/dag.go +++ b/internal/dag/dag.go @@ -540,6 +540,9 @@ type PeerValidationContext struct { // OnlyVerifyLeafCertCrl when set to true, only the certificate at the end of the // certificate chain will be subject to validation by CRL. OnlyVerifyLeafCertCrl bool + // OptionalClientCertificate when set to true will ensure Envoy does not require + // that the client sends a certificate but if one is sent it will process it. + OptionalClientCertificate bool } // GetCACertificate returns the CA certificate from PeerValidationContext. diff --git a/internal/dag/httpproxy_processor.go b/internal/dag/httpproxy_processor.go index 92b1ca73bd3..c5ae593fa53 100644 --- a/internal/dag/httpproxy_processor.go +++ b/internal/dag/httpproxy_processor.go @@ -262,7 +262,8 @@ func (p *HTTPProxyProcessor) computeHTTPProxy(proxy *contour_api_v1.HTTPProxy) { // Fill in DownstreamValidation when external client validation is enabled. if tls.ClientValidation != nil { dv := &PeerValidationContext{ - SkipClientCertValidation: tls.ClientValidation.SkipClientCertValidation, + SkipClientCertValidation: tls.ClientValidation.SkipClientCertValidation, + OptionalClientCertificate: tls.ClientValidation.OptionalClientCertificate, } if tls.ClientValidation.CACertificate != "" { secretName := k8s.NamespacedNameFrom(tls.ClientValidation.CACertificate, k8s.DefaultNamespace(proxy.Namespace)) diff --git a/internal/envoy/v3/auth.go b/internal/envoy/v3/auth.go index 5ba085320b1..eab2022a374 100644 --- a/internal/envoy/v3/auth.go +++ b/internal/envoy/v3/auth.go @@ -125,7 +125,7 @@ func DownstreamTLSContext(serverSecret *dag.Secret, tlsMinProtoVersion envoy_v3_ peerValidationContext.GetCRL(), peerValidationContext.OnlyVerifyLeafCertCrl) if vc != nil { context.CommonTlsContext.ValidationContextType = vc - context.RequireClientCertificate = protobuf.Bool(true) + context.RequireClientCertificate = protobuf.Bool(!peerValidationContext.OptionalClientCertificate) } } diff --git a/internal/envoy/v3/listener_test.go b/internal/envoy/v3/listener_test.go index d9aef3ce289..64ae3a883c8 100644 --- a/internal/envoy/v3/listener_test.go +++ b/internal/envoy/v3/listener_test.go @@ -323,6 +323,20 @@ func TestDownstreamTLSContext(t *testing.T) { }, }, } + peerValidationContextOptionalClientCertValidationWithCA := &dag.PeerValidationContext{ + CACertificate: &dag.Secret{ + Object: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Data: map[string][]byte{ + dag.CACertificateKey: ca, + }, + }, + }, + OptionalClientCertificate: true, + } peerValidationContextWithCRLCheck := &dag.PeerValidationContext{ CACertificate: &dag.Secret{ Object: &v1.Secret{ @@ -465,6 +479,18 @@ func TestDownstreamTLSContext(t *testing.T) { RequireClientCertificate: protobuf.Bool(true), }, }, + "optional client cert validation with ca": { + DownstreamTLSContext(serverSecret, envoy_tls_v3.TlsParameters_TLSv1_2, cipherSuites, peerValidationContextOptionalClientCertValidationWithCA, "h2", "http/1.1"), + &envoy_tls_v3.DownstreamTlsContext{ + CommonTlsContext: &envoy_tls_v3.CommonTlsContext{ + TlsParams: tlsParams, + TlsCertificateSdsSecretConfigs: tlsCertificateSdsSecretConfigs, + AlpnProtocols: alpnProtocols, + ValidationContextType: validationContext, + }, + RequireClientCertificate: protobuf.Bool(false), + }, + }, "Downstream validation with CRL check": { DownstreamTLSContext(serverSecret, envoy_tls_v3.TlsParameters_TLSv1_2, cipherSuites, peerValidationContextWithCRLCheck, "h2", "http/1.1"), &envoy_tls_v3.DownstreamTlsContext{ diff --git a/internal/featuretests/v3/downstreamvalidation_test.go b/internal/featuretests/v3/downstreamvalidation_test.go index 73f8f2b4a7f..9b022a6cdba 100644 --- a/internal/featuretests/v3/downstreamvalidation_test.go +++ b/internal/featuretests/v3/downstreamvalidation_test.go @@ -320,4 +320,53 @@ func TestDownstreamTLSCertificateValidation(t *testing.T) { statsListener(), ), }).Status(proxy5).IsValid() + + proxy6 := fixture.NewProxy("example.com"). + WithSpec(contour_api_v1.HTTPProxySpec{ + VirtualHost: &contour_api_v1.VirtualHost{ + Fqdn: "example.com", + TLS: &contour_api_v1.TLS{ + SecretName: serverTLSSecret.Name, + ClientValidation: &contour_api_v1.DownstreamValidation{ + CACertificate: clientCASecret.Name, + OptionalClientCertificate: true, + }, + }, + }, + Routes: []contour_api_v1.Route{{ + Services: []contour_api_v1.Service{{ + Name: "kuard", + Port: 8080, + }}, + }}, + }) + rh.OnUpdate(proxy5, proxy6) + + ingressHTTPSOptionalVerify := &envoy_listener_v3.Listener{ + Name: "ingress_https", + Address: envoy_v3.SocketAddress("0.0.0.0", 8443), + ListenerFilters: envoy_v3.ListenerFilters( + envoy_v3.TLSInspector(), + ), + FilterChains: appendFilterChains( + filterchaintls("example.com", serverTLSSecret, + httpsFilterFor("example.com"), + &dag.PeerValidationContext{ + CACertificate: &dag.Secret{ + Object: clientCASecret, + }, + OptionalClientCertificate: true, + }, + "h2", "http/1.1", + ), + ), + SocketOptions: envoy_v3.TCPKeepaliveSocketOptions(), + } + c.Request(listenerType).Equals(&envoy_discovery_v3.DiscoveryResponse{ + Resources: resources(t, + defaultHTTPListener(), + ingressHTTPSOptionalVerify, + statsListener(), + ), + }).Status(proxy6).IsValid() } diff --git a/site/content/docs/main/config/api-reference.html b/site/content/docs/main/config/api-reference.html index 9be7712ace6..71f05232a0b 100644 --- a/site/content/docs/main/config/api-reference.html +++ b/site/content/docs/main/config/api-reference.html @@ -977,6 +977,23 @@

DownstreamValidation certificate chain will be subject to validation by CRL.

+ + +optionalClientCertificate +
+ +bool + + + +(Optional) +

OptionalClientCertificate when set to true will request a client certificate +but allow the connection to continue if the client does not provide one. +If a client certificate is sent, it will be verified according to the +other properties, which includes disabling validation if +SkipClientCertValidation is set. Defaults to false.

+ +

ExtensionServiceReference diff --git a/site/content/docs/main/config/tls-termination.md b/site/content/docs/main/config/tls-termination.md index da3d4cb3808..b552481a2ba 100644 --- a/site/content/docs/main/config/tls-termination.md +++ b/site/content/docs/main/config/tls-termination.md @@ -166,6 +166,27 @@ Its mandatory attribute `caSecret` contains a name of an existing Kubernetes Sec The data value of the key `ca.crt` must be a PEM-encoded certificate bundle and it must contain all the trusted CA certificates that are to be used for validating the client certificate. If the Opaque Secret also contains one of either `tls.crt` or `tls.key` keys, it will be ignored. +By default, client certificates are required but some applications might support different authentication schemes. In that case you can set the `optionalClientCertificate` field to `true`. A client certificate will be requested, but the connection is allowed to continue if the client does not provide one. If a client certificate is sent, it will be verified according to the other properties, which includes disabling validations if `skipClientCertValidation` is set. + +```yaml +apiVersion: projectcontour.io/v1 +kind: HTTPProxy +metadata: + name: with-optional-client-auth +spec: + virtualhost: + fqdn: www.example.com + tls: + secretName: secret + clientValidation: + caSecret: client-root-ca + optionalClientCertificate: true + routes: + - services: + - name: s1 + port: 80 +``` + When using external authorization, it may be desirable to use an external authorization server to validate client certificates on requests, rather than the Envoy proxy. ```yaml diff --git a/test/e2e/httpproxy/client_cert_auth_test.go b/test/e2e/httpproxy/client_cert_auth_test.go index 17ee2f8318e..11d1457685b 100644 --- a/test/e2e/httpproxy/client_cert_auth_test.go +++ b/test/e2e/httpproxy/client_cert_auth_test.go @@ -228,6 +228,50 @@ func testClientCertAuth(namespace string) { } require.NoError(t, f.Client.Create(context.TODO(), echoWithAuthSkipVerifyWithCACert)) + f.Fixtures.Echo.Deploy(namespace, "echo-with-optional-auth") + + // Get a server certificate for echo-with-optional-auth. + echoWithOptionalAuth := &certmanagerv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "echo-with-optional-auth-cert", + }, + Spec: certmanagerv1.CertificateSpec{ + + Usages: []certmanagerv1.KeyUsage{ + certmanagerv1.UsageServerAuth, + }, + DNSNames: []string{"echo-with-optional-auth.projectcontour.io"}, + SecretName: "echo-with-optional-auth", + IssuerRef: certmanagermetav1.ObjectReference{ + Name: "ca-projectcontour-io", + }, + }, + } + require.NoError(t, f.Client.Create(context.TODO(), echoWithOptionalAuth)) + + f.Fixtures.Echo.Deploy(namespace, "echo-with-optional-auth-no-ca") + + // Get a server certificate for echo-with-optional-auth-no-ca. + echoWithOptionalAuthNoCA := &certmanagerv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "echo-with-optional-auth-no-ca-cert", + }, + Spec: certmanagerv1.CertificateSpec{ + + Usages: []certmanagerv1.KeyUsage{ + certmanagerv1.UsageServerAuth, + }, + DNSNames: []string{"echo-with-optional-auth-no-ca.projectcontour.io"}, + SecretName: "echo-with-optional-auth-no-ca", + IssuerRef: certmanagermetav1.ObjectReference{ + Name: "ca-projectcontour-io", + }, + }, + } + require.NoError(t, f.Client.Create(context.TODO(), echoWithOptionalAuthNoCA)) + // Get a client certificate. clientCert := &certmanagerv1.Certificate{ ObjectMeta: metav1.ObjectMeta{ @@ -394,6 +438,68 @@ func testClientCertAuth(namespace string) { } f.CreateHTTPProxyAndWaitFor(authSkipVerifyWithCAProxy, e2e.HTTPProxyValid) + // This proxy requests a client certificate but only verifies it if sent. + optionalAuthProxy := &contourv1.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "echo-with-optional-auth", + }, + Spec: contourv1.HTTPProxySpec{ + VirtualHost: &contourv1.VirtualHost{ + Fqdn: "echo-with-optional-auth.projectcontour.io", + TLS: &contourv1.TLS{ + SecretName: "echo-with-optional-auth", + ClientValidation: &contourv1.DownstreamValidation{ + OptionalClientCertificate: true, + CACertificate: "echo-with-auth", + }, + }, + }, + Routes: []contourv1.Route{ + { + Services: []contourv1.Service{ + { + Name: "echo-with-optional-auth", + Port: 80, + }, + }, + }, + }, + }, + } + f.CreateHTTPProxyAndWaitFor(optionalAuthProxy, e2e.HTTPProxyValid) + + // This proxy requests a client certificate but doesn't verify it if sent. + optionalAuthNoCAProxy := &contourv1.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "echo-with-optional-auth-no-ca", + }, + Spec: contourv1.HTTPProxySpec{ + VirtualHost: &contourv1.VirtualHost{ + Fqdn: "echo-with-optional-auth-no-ca.projectcontour.io", + TLS: &contourv1.TLS{ + SecretName: "echo-with-optional-auth-no-ca", + ClientValidation: &contourv1.DownstreamValidation{ + OptionalClientCertificate: true, + SkipClientCertValidation: true, + }, + }, + }, + Routes: []contourv1.Route{ + { + Services: []contourv1.Service{ + { + Name: "echo-with-optional-auth-no-ca", + Port: 80, + }, + }, + }, + }, + }, + } + f.CreateHTTPProxyAndWaitFor(optionalAuthNoCAProxy, e2e.HTTPProxyValid) + // get the valid & invalid client certs validClientCert, _ := f.Certs.GetTLSCertificate(namespace, clientCert.Spec.SecretName) invalidClientCert, _ := f.Certs.GetTLSCertificate(namespace, clientCertInvalid.Spec.SecretName) @@ -466,6 +572,37 @@ func testClientCertAuth(namespace string) { clientCert: &invalidClientCert, wantErr: "", }, + + "echo-with-optional-auth without a client cert should succeed": { + host: optionalAuthProxy.Spec.VirtualHost.Fqdn, + clientCert: nil, + wantErr: "", + }, + "echo-with-optional-auth with echo-client-cert should succeed": { + host: optionalAuthProxy.Spec.VirtualHost.Fqdn, + clientCert: &validClientCert, + wantErr: "", + }, + "echo-with-optional-auth with echo-client-cert-invalid should error": { + host: optionalAuthProxy.Spec.VirtualHost.Fqdn, + clientCert: &invalidClientCert, + wantErr: "tls: unknown certificate authority", + }, + "echo-with-optional-auth-no-ca without a client cert should succeed": { + host: optionalAuthNoCAProxy.Spec.VirtualHost.Fqdn, + clientCert: nil, + wantErr: "", + }, + "echo-with-optional-auth-no-ca with echo-client-cert should succeed": { + host: optionalAuthNoCAProxy.Spec.VirtualHost.Fqdn, + clientCert: &validClientCert, + wantErr: "", + }, + "echo-with-optional-auth-no-ca with echo-client-cert-invalid should succeed": { + host: optionalAuthNoCAProxy.Spec.VirtualHost.Fqdn, + clientCert: &invalidClientCert, + wantErr: "", + }, } for name, tc := range cases {