From b7b85175f6dab7bb92ff3790aa99578ed51c727e Mon Sep 17 00:00:00 2001 From: Maxime LUCE Date: Wed, 23 Sep 2020 17:41:52 +0200 Subject: [PATCH] Add annotation to configure CORS Access-Control-Expose-Headers --- .../nginx-configuration/annotations.md | 7 +++++++ internal/ingress/annotations/annotations_test.go | 12 +++++++----- internal/ingress/annotations/cors/main.go | 12 ++++++++++++ internal/ingress/annotations/cors/main_test.go | 10 ++++++++++ rootfs/etc/nginx/template/nginx.tmpl | 2 ++ test/e2e/annotations/cors.go | 16 ++++++++++++++++ 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/user-guide/nginx-configuration/annotations.md b/docs/user-guide/nginx-configuration/annotations.md index 58af29736..274eccd96 100755 --- a/docs/user-guide/nginx-configuration/annotations.md +++ b/docs/user-guide/nginx-configuration/annotations.md @@ -48,6 +48,7 @@ You can add these Kubernetes annotations to specific Ingress objects to customiz |[nginx.ingress.kubernetes.io/cors-allow-origin](#enable-cors)|string| |[nginx.ingress.kubernetes.io/cors-allow-methods](#enable-cors)|string| |[nginx.ingress.kubernetes.io/cors-allow-headers](#enable-cors)|string| +|[nginx.ingress.kubernetes.io/cors-expose-headers](#enable-cors)|string| |[nginx.ingress.kubernetes.io/cors-allow-credentials](#enable-cors)|"true" or "false"| |[nginx.ingress.kubernetes.io/cors-max-age](#enable-cors)|number| |[nginx.ingress.kubernetes.io/force-ssl-redirect](#server-side-https-enforcement-through-redirect)|"true" or "false"| @@ -336,6 +337,12 @@ CORS can be controlled with the following annotations: - Default: `DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization` - Example: `nginx.ingress.kubernetes.io/cors-allow-headers: "X-Forwarded-For, X-app123-XPTO"` +* `nginx.ingress.kubernetes.io/cors-expose-headers` + controls which headers are exposed to response. This is a multi-valued field, separated by ',' and accepts + letters, numbers, _, - and *. + - Default: *empty* + - Example: `nginx.ingress.kubernetes.io/cors-expose-headers: "*, X-CustomResponseHeader"` + * `nginx.ingress.kubernetes.io/cors-allow-origin` controls what's the accepted Origin for CORS. This is a single field value, with the following format: `http(s)://origin-site.com` or `http(s)://origin-site.com:port` diff --git a/internal/ingress/annotations/annotations_test.go b/internal/ingress/annotations/annotations_test.go index 0c9bcc9b2..2e4da69a6 100644 --- a/internal/ingress/annotations/annotations_test.go +++ b/internal/ingress/annotations/annotations_test.go @@ -37,6 +37,7 @@ var ( annotationCorsEnabled = parser.GetAnnotationWithPrefix("enable-cors") annotationCorsAllowMethods = parser.GetAnnotationWithPrefix("cors-allow-methods") annotationCorsAllowHeaders = parser.GetAnnotationWithPrefix("cors-allow-headers") + annotationCorsExposeHeaders = parser.GetAnnotationWithPrefix("cors-expose-headers") annotationCorsAllowCredentials = parser.GetAnnotationWithPrefix("cors-allow-credentials") backendProtocol = parser.GetAnnotationWithPrefix("backend-protocol") defaultCorsMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS" @@ -201,12 +202,13 @@ func TestCors(t *testing.T) { headers string origin string credentials bool + expose string }{ - {map[string]string{annotationCorsEnabled: "true"}, true, defaultCorsMethods, defaultCorsHeaders, "*", true}, - {map[string]string{annotationCorsEnabled: "true", annotationCorsAllowMethods: "POST, GET, OPTIONS", annotationCorsAllowHeaders: "$nginx_version", annotationCorsAllowCredentials: "false"}, true, "POST, GET, OPTIONS", defaultCorsHeaders, "*", false}, - {map[string]string{annotationCorsEnabled: "true", annotationCorsAllowCredentials: "false"}, true, defaultCorsMethods, defaultCorsHeaders, "*", false}, - {map[string]string{}, false, defaultCorsMethods, defaultCorsHeaders, "*", true}, - {nil, false, defaultCorsMethods, defaultCorsHeaders, "*", true}, + {map[string]string{annotationCorsEnabled: "true"}, true, defaultCorsMethods, defaultCorsHeaders, "*", true, ""}, + {map[string]string{annotationCorsEnabled: "true", annotationCorsAllowMethods: "POST, GET, OPTIONS", annotationCorsAllowHeaders: "$nginx_version", annotationCorsAllowCredentials: "false", annotationCorsExposeHeaders: "X-CustomResponseHeader"}, true, "POST, GET, OPTIONS", defaultCorsHeaders, "*", false, "X-CustomResponseHeader"}, + {map[string]string{annotationCorsEnabled: "true", annotationCorsAllowCredentials: "false"}, true, defaultCorsMethods, defaultCorsHeaders, "*", false, ""}, + {map[string]string{}, false, defaultCorsMethods, defaultCorsHeaders, "*", true, ""}, + {nil, false, defaultCorsMethods, defaultCorsHeaders, "*", true, ""}, } for _, foo := range fooAnns { diff --git a/internal/ingress/annotations/cors/main.go b/internal/ingress/annotations/cors/main.go index b2c0bf778..2f1a0a37b 100644 --- a/internal/ingress/annotations/cors/main.go +++ b/internal/ingress/annotations/cors/main.go @@ -43,6 +43,9 @@ var ( // Headers must contain valid values only (X-HEADER12, X-ABC) // May contain or not spaces between each Header corsHeadersRegex = regexp.MustCompile(`^([A-Za-z0-9\-\_]+,?\s?)+$`) + // Expose Headers must contain valid values only (*, X-HEADER12, X-ABC) + // May contain or not spaces between each Header + corsExposeHeadersRegex = regexp.MustCompile(`^(([A-Za-z0-9\-\_]+|\*),?\s?)+$`) ) type cors struct { @@ -56,6 +59,7 @@ type Config struct { CorsAllowMethods string `json:"corsAllowMethods"` CorsAllowHeaders string `json:"corsAllowHeaders"` CorsAllowCredentials bool `json:"corsAllowCredentials"` + CorsExposeHeaders string `json:"corsExposeHeaders"` CorsMaxAge int `json:"corsMaxAge"` } @@ -75,6 +79,9 @@ func (c1 *Config) Equal(c2 *Config) bool { if c1.CorsMaxAge != c2.CorsMaxAge { return false } + if c1.CorsExposeHeaders != c2.CorsExposeHeaders { + return false + } if c1.CorsAllowCredentials != c2.CorsAllowCredentials { return false } @@ -125,6 +132,11 @@ func (c cors) Parse(ing *networking.Ingress) (interface{}, error) { config.CorsAllowCredentials = true } + config.CorsExposeHeaders, err = parser.GetStringAnnotation("cors-expose-headers", ing) + if err != nil || !corsExposeHeadersRegex.MatchString(config.CorsExposeHeaders) { + config.CorsExposeHeaders = "" + } + config.CorsMaxAge, err = parser.GetIntAnnotation("cors-max-age", ing) if err != nil { config.CorsMaxAge = defaultCorsMaxAge diff --git a/internal/ingress/annotations/cors/main_test.go b/internal/ingress/annotations/cors/main_test.go index 029216598..6f75ce6a7 100644 --- a/internal/ingress/annotations/cors/main_test.go +++ b/internal/ingress/annotations/cors/main_test.go @@ -73,6 +73,7 @@ func TestIngressCorsConfigValid(t *testing.T) { data[parser.GetAnnotationWithPrefix("cors-allow-credentials")] = "false" data[parser.GetAnnotationWithPrefix("cors-allow-methods")] = "GET, PATCH" data[parser.GetAnnotationWithPrefix("cors-allow-origin")] = "https://origin123.test.com:4443" + data[parser.GetAnnotationWithPrefix("cors-expose-headers")] = "*, X-CustomResponseHeader" data[parser.GetAnnotationWithPrefix("cors-max-age")] = "600" ing.SetAnnotations(data) @@ -106,6 +107,10 @@ func TestIngressCorsConfigValid(t *testing.T) { t.Errorf("expected %v but returned %v", data[parser.GetAnnotationWithPrefix("cors-allow-origin")], nginxCors.CorsAllowOrigin) } + if nginxCors.CorsExposeHeaders != "*, X-CustomResponseHeader" { + t.Errorf("expected %v but returned %v", data[parser.GetAnnotationWithPrefix("cors-expose-headers")], nginxCors.CorsExposeHeaders) + } + if nginxCors.CorsMaxAge != 600 { t.Errorf("expected %v but returned %v", data[parser.GetAnnotationWithPrefix("cors-max-age")], nginxCors.CorsMaxAge) } @@ -122,6 +127,7 @@ func TestIngressCorsConfigInvalid(t *testing.T) { data[parser.GetAnnotationWithPrefix("cors-allow-credentials")] = "no" data[parser.GetAnnotationWithPrefix("cors-allow-methods")] = "GET, PATCH, $nginx" data[parser.GetAnnotationWithPrefix("cors-allow-origin")] = "origin123.test.com:4443" + data[parser.GetAnnotationWithPrefix("cors-expose-headers")] = "@alright, #ingress" data[parser.GetAnnotationWithPrefix("cors-max-age")] = "abcd" ing.SetAnnotations(data) @@ -155,6 +161,10 @@ func TestIngressCorsConfigInvalid(t *testing.T) { t.Errorf("expected %v but returned %v", "*", nginxCors.CorsAllowOrigin) } + if nginxCors.CorsExposeHeaders != "" { + t.Errorf("expected %v but returned %v", "", nginxCors.CorsExposeHeaders) + } + if nginxCors.CorsMaxAge != defaultCorsMaxAge { t.Errorf("expected %v but returned %v", defaultCorsMaxAge, nginxCors.CorsMaxAge) } diff --git a/rootfs/etc/nginx/template/nginx.tmpl b/rootfs/etc/nginx/template/nginx.tmpl index 98e3d77a0..406cf4f20 100755 --- a/rootfs/etc/nginx/template/nginx.tmpl +++ b/rootfs/etc/nginx/template/nginx.tmpl @@ -830,6 +830,7 @@ stream { {{ if $cors.CorsAllowCredentials }} more_set_headers 'Access-Control-Allow-Credentials: {{ $cors.CorsAllowCredentials }}'; {{ end }} more_set_headers 'Access-Control-Allow-Methods: {{ $cors.CorsAllowMethods }}'; more_set_headers 'Access-Control-Allow-Headers: {{ $cors.CorsAllowHeaders }}'; + {{ if not (empty $cors.CorsExposeHeaders) }} more_set_headers 'Access-Control-Expose-Headers: {{ $cors.CorsExposeHeaders }}'; {{ end }} more_set_headers 'Access-Control-Max-Age: {{ $cors.CorsMaxAge }}'; more_set_headers 'Content-Type: text/plain charset=UTF-8'; more_set_headers 'Content-Length: 0'; @@ -840,6 +841,7 @@ stream { {{ if $cors.CorsAllowCredentials }} more_set_headers 'Access-Control-Allow-Credentials: {{ $cors.CorsAllowCredentials }}'; {{ end }} more_set_headers 'Access-Control-Allow-Methods: {{ $cors.CorsAllowMethods }}'; more_set_headers 'Access-Control-Allow-Headers: {{ $cors.CorsAllowHeaders }}'; + {{ if not (empty $cors.CorsExposeHeaders) }} more_set_headers 'Access-Control-Expose-Headers: {{ $cors.CorsExposeHeaders }}'; {{ end }} {{ end }} diff --git a/test/e2e/annotations/cors.go b/test/e2e/annotations/cors.go index bb2b3dcc5..e4cdd32b4 100644 --- a/test/e2e/annotations/cors.go +++ b/test/e2e/annotations/cors.go @@ -136,4 +136,20 @@ var _ = framework.DescribeAnnotation("cors-*", func() { return strings.Contains(server, "more_set_headers 'Access-Control-Allow-Headers: DNT, User-Agent';") }) }) + + ginkgo.It("should expose headers for cors", func() { + host := "cors.foo.com" + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/enable-cors": "true", + "nginx.ingress.kubernetes.io/cors-expose-headers": "X-CustomResponseHeader, X-CustomSecondHeader", + } + + ing := framework.NewSingleIngress(host, "/", host, f.Namespace, framework.EchoService, 80, annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return strings.Contains(server, "more_set_headers 'Access-Control-Expose-Headers: X-CustomResponseHeader, X-CustomSecondHeader';") + }) + }) })