diff --git a/docs/user-guide/nginx-configuration/annotations.md b/docs/user-guide/nginx-configuration/annotations.md index 8958ccfee..6309601b2 100755 --- a/docs/user-guide/nginx-configuration/annotations.md +++ b/docs/user-guide/nginx-configuration/annotations.md @@ -28,6 +28,7 @@ You can add these Kubernetes annotations to specific Ingress objects to customiz |[nginx.ingress.kubernetes.io/auth-tls-verify-client](#client-certificate-authentication)|string| |[nginx.ingress.kubernetes.io/auth-tls-error-page](#client-certificate-authentication)|string| |[nginx.ingress.kubernetes.io/auth-tls-pass-certificate-to-upstream](#client-certificate-authentication)|"true" or "false"| +|[nginx.ingress.kubernetes.io/auth-tls-match-cn](#client-certificate-authentication)|string| |[nginx.ingress.kubernetes.io/auth-url](#external-authentication)|string| |[nginx.ingress.kubernetes.io/auth-cache-key](#external-authentication)|string| |[nginx.ingress.kubernetes.io/auth-cache-duration](#external-authentication)|string| @@ -264,6 +265,7 @@ You can further customize client certificate authentication and behavior with th * `optional_no_ca`: Do optional client certificate validation, but do not fail the request when the client certificate is not signed by the CAs from `auth-tls-secret`. Certificate verification result is sent to the upstream service. * `nginx.ingress.kubernetes.io/auth-tls-error-page`: The URL/Page that user should be redirected in case of a Certificate Authentication Error * `nginx.ingress.kubernetes.io/auth-tls-pass-certificate-to-upstream`: Indicates if the received certificates should be passed or not to the upstream server in the header `ssl-client-cert`. Possible values are "true" or "false" (default). +* `nginx.ingress.kubernetes.io/auth-tls-match-cn`: Adds a sanity check for the CN of the client certificate that is sent over using a string / regex starting with "CN=", example: `"CN=myvalidclient"`. If the certificate CN sent during mTLS does not match your string / regex it will fail with status code 403. Another way of using this is by adding multiple options in your regex, example: `"CN=(option1|option2|myvalidclient)"`. In this case, as long as one of the options in the brackets matches the certificate CN then you will receive a 200 status code. The following headers are sent to the upstream service according to the `auth-tls-*` annotations: diff --git a/internal/ingress/annotations/authtls/main.go b/internal/ingress/annotations/authtls/main.go index cbe014c4a..2efd6d176 100644 --- a/internal/ingress/annotations/authtls/main.go +++ b/internal/ingress/annotations/authtls/main.go @@ -18,6 +18,7 @@ package authtls import ( "fmt" + networking "k8s.io/api/networking/v1" "regexp" @@ -35,6 +36,7 @@ const ( var ( authVerifyClientRegex = regexp.MustCompile(`on|off|optional|optional_no_ca`) + commonNameRegex = regexp.MustCompile(`CN=`) ) // Config contains the AuthSSLCert used for mutual authentication @@ -45,6 +47,7 @@ type Config struct { ValidationDepth int `json:"validationDepth"` ErrorPage string `json:"errorPage"` PassCertToUpstream bool `json:"passCertToUpstream"` + MatchCN string `json:"matchCN"` AuthTLSError string } @@ -127,5 +130,10 @@ func (a authTLS) Parse(ing *networking.Ingress) (interface{}, error) { config.PassCertToUpstream = false } + config.MatchCN, err = parser.GetStringAnnotation("auth-tls-match-cn", ing) + if err != nil || !commonNameRegex.MatchString(config.MatchCN) { + config.MatchCN = "" + } + return config, nil } diff --git a/internal/ingress/annotations/authtls/main_test.go b/internal/ingress/annotations/authtls/main_test.go index f7649fe1c..569f3865b 100644 --- a/internal/ingress/annotations/authtls/main_test.go +++ b/internal/ingress/annotations/authtls/main_test.go @@ -128,11 +128,15 @@ func TestAnnotations(t *testing.T) { if u.PassCertToUpstream != false { t.Errorf("expected %v but got %v", false, u.PassCertToUpstream) } + if u.MatchCN != "" { + t.Errorf("expected empty string, but got %v", u.MatchCN) + } data[parser.GetAnnotationWithPrefix("auth-tls-verify-client")] = "off" data[parser.GetAnnotationWithPrefix("auth-tls-verify-depth")] = "2" data[parser.GetAnnotationWithPrefix("auth-tls-error-page")] = "ok.com/error" data[parser.GetAnnotationWithPrefix("auth-tls-pass-certificate-to-upstream")] = "true" + data[parser.GetAnnotationWithPrefix("auth-tls-match-cn")] = "CN=hello-app" ing.SetAnnotations(data) @@ -161,6 +165,9 @@ func TestAnnotations(t *testing.T) { if u.PassCertToUpstream != true { t.Errorf("expected %v but got %v", true, u.PassCertToUpstream) } + if u.MatchCN != "CN=hello-app" { + t.Errorf("expected %v but got %v", "CN=hello-app", u.MatchCN) + } } func TestInvalidAnnotations(t *testing.T) { @@ -195,6 +202,7 @@ func TestInvalidAnnotations(t *testing.T) { data[parser.GetAnnotationWithPrefix("auth-tls-verify-client")] = "w00t" data[parser.GetAnnotationWithPrefix("auth-tls-verify-depth")] = "abcd" data[parser.GetAnnotationWithPrefix("auth-tls-pass-certificate-to-upstream")] = "nahh" + data[parser.GetAnnotationWithPrefix("auth-tls-match-cn")] = "" ing.SetAnnotations(data) i, err := NewParser(fakeSecret).Parse(ing) @@ -215,6 +223,9 @@ func TestInvalidAnnotations(t *testing.T) { if u.PassCertToUpstream != false { t.Errorf("expected %v but got %v", false, u.PassCertToUpstream) } + if u.MatchCN != "" { + t.Errorf("expected empty string but got %v", u.MatchCN) + } } diff --git a/rootfs/etc/nginx/template/nginx.tmpl b/rootfs/etc/nginx/template/nginx.tmpl index 1ad8458c5..a181dd22a 100755 --- a/rootfs/etc/nginx/template/nginx.tmpl +++ b/rootfs/etc/nginx/template/nginx.tmpl @@ -946,6 +946,14 @@ stream { set $proxy_upstream_name "-"; + {{ if not ( empty $server.CertificateAuth.MatchCN ) }} + {{ if gt (len $server.CertificateAuth.MatchCN) 0 }} + if ( $ssl_client_s_dn !~ {{ $server.CertificateAuth.MatchCN }} ) { + return 403 "client certificate unauthorized"; + } + {{ end }} + {{ end }} + {{ if eq $server.Hostname "_" }} ssl_reject_handshake {{ if $all.Cfg.SSLRejectHandshake }}on{{ else }}off{{ end }}; {{ end }} diff --git a/test/e2e/annotations/authtls.go b/test/e2e/annotations/authtls.go index 790165475..dbf4f2a76 100644 --- a/test/e2e/annotations/authtls.go +++ b/test/e2e/annotations/authtls.go @@ -262,6 +262,93 @@ var _ = framework.DescribeAnnotation("auth-tls-*", func() { Status(http.StatusOK) }) + + ginkgo.It("should return 403 using auth-tls-match-cn with no matching CN from client", func() { + host := "authtls.foo.com" + nameSpace := f.Namespace + + clientConfig, err := framework.CreateIngressMASecret( + f.KubeClientSet, + host, + host, + nameSpace) + assert.Nil(ginkgo.GinkgoT(), err) + + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/auth-tls-secret": nameSpace + "/" + host, + "nginx.ingress.kubernetes.io/auth-tls-verify-client": "on", + "nginx.ingress.kubernetes.io/auth-tls-match-cn": "CN=notgonnamatch", + } + + f.EnsureIngress(framework.NewSingleIngressWithTLS(host, "/", host, []string{host}, nameSpace, framework.EchoService, 80, annotations)) + + assertSslClientCertificateConfig(f, host, "on", "1") + + f.HTTPTestClientWithTLSConfig(clientConfig). + GET("/"). + WithURL(f.GetURL(framework.HTTPS)). + WithHeader("Host", host). + Expect(). + Status(http.StatusForbidden) + }) + + ginkgo.It("should return 200 using auth-tls-match-cn with matching CN from client", func() { + host := "authtls.foo.com" + nameSpace := f.Namespace + + clientConfig, err := framework.CreateIngressMASecret( + f.KubeClientSet, + host, + host, + nameSpace) + assert.Nil(ginkgo.GinkgoT(), err) + + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/auth-tls-secret": nameSpace + "/" + host, + "nginx.ingress.kubernetes.io/auth-tls-verify-client": "on", + "nginx.ingress.kubernetes.io/auth-tls-match-cn": "CN=authtls", + } + + f.EnsureIngress(framework.NewSingleIngressWithTLS(host, "/", host, []string{host}, nameSpace, framework.EchoService, 80, annotations)) + + assertSslClientCertificateConfig(f, host, "on", "1") + + f.HTTPTestClientWithTLSConfig(clientConfig). + GET("/"). + WithURL(f.GetURL(framework.HTTPS)). + WithHeader("Host", host). + Expect(). + Status(http.StatusOK) + }) + + ginkgo.It("should return 200 using auth-tls-match-cn where atleast one of the regex options matches CN from client", func() { + host := "authtls.foo.com" + nameSpace := f.Namespace + + clientConfig, err := framework.CreateIngressMASecret( + f.KubeClientSet, + host, + host, + nameSpace) + assert.Nil(ginkgo.GinkgoT(), err) + + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/auth-tls-secret": nameSpace + "/" + host, + "nginx.ingress.kubernetes.io/auth-tls-verify-client": "on", + "nginx.ingress.kubernetes.io/auth-tls-match-cn": "CN=(itwillmatch|withthenextoption|authtls)", + } + + f.EnsureIngress(framework.NewSingleIngressWithTLS(host, "/", host, []string{host}, nameSpace, framework.EchoService, 80, annotations)) + + assertSslClientCertificateConfig(f, host, "on", "1") + + f.HTTPTestClientWithTLSConfig(clientConfig). + GET("/"). + WithURL(f.GetURL(framework.HTTPS)). + WithHeader("Host", host). + Expect(). + Status(http.StatusOK) + }) }) func assertSslClientCertificateConfig(f *framework.Framework, host string, verifyClient string, verifyDepth string) {