diff --git a/docs/user-guide/nginx-configuration/annotations.md b/docs/user-guide/nginx-configuration/annotations.md index 877654d12..60480398f 100755 --- a/docs/user-guide/nginx-configuration/annotations.md +++ b/docs/user-guide/nginx-configuration/annotations.md @@ -50,6 +50,7 @@ You can add these Kubernetes annotations to specific Ingress objects to customiz |[nginx.ingress.kubernetes.io/client-body-buffer-size](#client-body-buffer-size)|string| |[nginx.ingress.kubernetes.io/configuration-snippet](#configuration-snippet)|string| |[nginx.ingress.kubernetes.io/custom-http-errors](#custom-http-errors)|[]int| +|[nginx.ingress.kubernetes.io/custom-response-headers](#custom-response-headers)|string| |[nginx.ingress.kubernetes.io/default-backend](#default-backend)|string| |[nginx.ingress.kubernetes.io/enable-cors](#enable-cors)|"true" or "false"| |[nginx.ingress.kubernetes.io/cors-allow-origin](#enable-cors)|string| @@ -329,6 +330,15 @@ Example usage: nginx.ingress.kubernetes.io/custom-http-errors: "404,415" ``` +### Custom Response Headers +This annotation is of the form `nginx.ingress.kubernetes.io/custom-response-headers: "
"` to specify a custom response header. To specify multiple headers, you can use newline to seperate multiple response headers: +```yaml +nginx.ingress.kubernetes.io/custom-response-headers: | + Content-Type: application/json + Cache-Control: no-cache +``` +This annotation uses `more_set_headers` nginx directive. + ### Default Backend This annotation is of the form `nginx.ingress.kubernetes.io/default-backend: ` to specify a custom default backend. This `` is a reference to a service inside of the same namespace in which you are applying this annotation. This annotation overrides the global default backend. In case the service has [multiple ports](https://kubernetes.io/docs/concepts/services-networking/service/#multi-port-services), the first one is the one which will receive the backend traffic. diff --git a/internal/ingress/annotations/annotations.go b/internal/ingress/annotations/annotations.go index 38843c2db..4a88a0607 100644 --- a/internal/ingress/annotations/annotations.go +++ b/internal/ingress/annotations/annotations.go @@ -19,6 +19,7 @@ package annotations import ( "github.com/imdario/mergo" "k8s.io/ingress-nginx/internal/ingress/annotations/canary" + "k8s.io/ingress-nginx/internal/ingress/annotations/customresponseheaders" "k8s.io/ingress-nginx/internal/ingress/annotations/modsecurity" "k8s.io/ingress-nginx/internal/ingress/annotations/opentelemetry" "k8s.io/ingress-nginx/internal/ingress/annotations/proxyssl" @@ -75,47 +76,48 @@ const DeniedKeyName = "Denied" // Ingress defines the valid annotations present in one NGINX Ingress rule type Ingress struct { metav1.ObjectMeta - BackendProtocol string - Aliases []string - BasicDigestAuth auth.Config - Canary canary.Config - CertificateAuth authtls.Config - ClientBodyBufferSize string - ConfigurationSnippet string - Connection connection.Config - CorsConfig cors.Config - CustomHTTPErrors []int - DefaultBackend *apiv1.Service - FastCGI fastcgi.Config - Denied *string - ExternalAuth authreq.Config - EnableGlobalAuth bool - HTTP2PushPreload bool - Opentracing opentracing.Config - Opentelemetry opentelemetry.Config - Proxy proxy.Config - ProxySSL proxyssl.Config - RateLimit ratelimit.Config - GlobalRateLimit globalratelimit.Config - Redirect redirect.Config - Rewrite rewrite.Config - Satisfy string - ServerSnippet string - ServiceUpstream bool - SessionAffinity sessionaffinity.Config - SSLPassthrough bool - UsePortInRedirects bool - UpstreamHashBy upstreamhashby.Config - LoadBalancing string - UpstreamVhost string - Denylist ipdenylist.SourceRange - XForwardedPrefix string - SSLCipher sslcipher.Config - Logs log.Config - ModSecurity modsecurity.Config - Mirror mirror.Config - StreamSnippet string - Allowlist ipallowlist.SourceRange + BackendProtocol string + Aliases []string + BasicDigestAuth auth.Config + Canary canary.Config + CertificateAuth authtls.Config + ClientBodyBufferSize string + ConfigurationSnippet string + Connection connection.Config + CorsConfig cors.Config + CustomHTTPErrors []int + CustomResponseHeaders customresponseheaders.Config + DefaultBackend *apiv1.Service + FastCGI fastcgi.Config + Denied *string + ExternalAuth authreq.Config + EnableGlobalAuth bool + HTTP2PushPreload bool + Opentracing opentracing.Config + Opentelemetry opentelemetry.Config + Proxy proxy.Config + ProxySSL proxyssl.Config + RateLimit ratelimit.Config + GlobalRateLimit globalratelimit.Config + Redirect redirect.Config + Rewrite rewrite.Config + Satisfy string + ServerSnippet string + ServiceUpstream bool + SessionAffinity sessionaffinity.Config + SSLPassthrough bool + UsePortInRedirects bool + UpstreamHashBy upstreamhashby.Config + LoadBalancing string + UpstreamVhost string + Denylist ipdenylist.SourceRange + XForwardedPrefix string + SSLCipher sslcipher.Config + Logs log.Config + ModSecurity modsecurity.Config + Mirror mirror.Config + StreamSnippet string + Allowlist ipallowlist.SourceRange } // Extractor defines the annotation parsers to be used in the extraction of annotations @@ -127,46 +129,47 @@ type Extractor struct { func NewAnnotationExtractor(cfg resolver.Resolver) Extractor { return Extractor{ map[string]parser.IngressAnnotation{ - "Aliases": alias.NewParser(cfg), - "BasicDigestAuth": auth.NewParser(auth.AuthDirectory, cfg), - "Canary": canary.NewParser(cfg), - "CertificateAuth": authtls.NewParser(cfg), - "ClientBodyBufferSize": clientbodybuffersize.NewParser(cfg), - "ConfigurationSnippet": snippet.NewParser(cfg), - "Connection": connection.NewParser(cfg), - "CorsConfig": cors.NewParser(cfg), - "CustomHTTPErrors": customhttperrors.NewParser(cfg), - "DefaultBackend": defaultbackend.NewParser(cfg), - "FastCGI": fastcgi.NewParser(cfg), - "ExternalAuth": authreq.NewParser(cfg), - "EnableGlobalAuth": authreqglobal.NewParser(cfg), - "HTTP2PushPreload": http2pushpreload.NewParser(cfg), - "Opentracing": opentracing.NewParser(cfg), - "Opentelemetry": opentelemetry.NewParser(cfg), - "Proxy": proxy.NewParser(cfg), - "ProxySSL": proxyssl.NewParser(cfg), - "RateLimit": ratelimit.NewParser(cfg), - "GlobalRateLimit": globalratelimit.NewParser(cfg), - "Redirect": redirect.NewParser(cfg), - "Rewrite": rewrite.NewParser(cfg), - "Satisfy": satisfy.NewParser(cfg), - "ServerSnippet": serversnippet.NewParser(cfg), - "ServiceUpstream": serviceupstream.NewParser(cfg), - "SessionAffinity": sessionaffinity.NewParser(cfg), - "SSLPassthrough": sslpassthrough.NewParser(cfg), - "UsePortInRedirects": portinredirect.NewParser(cfg), - "UpstreamHashBy": upstreamhashby.NewParser(cfg), - "LoadBalancing": loadbalancing.NewParser(cfg), - "UpstreamVhost": upstreamvhost.NewParser(cfg), - "Allowlist": ipallowlist.NewParser(cfg), - "Denylist": ipdenylist.NewParser(cfg), - "XForwardedPrefix": xforwardedprefix.NewParser(cfg), - "SSLCipher": sslcipher.NewParser(cfg), - "Logs": log.NewParser(cfg), - "BackendProtocol": backendprotocol.NewParser(cfg), - "ModSecurity": modsecurity.NewParser(cfg), - "Mirror": mirror.NewParser(cfg), - "StreamSnippet": streamsnippet.NewParser(cfg), + "Aliases": alias.NewParser(cfg), + "BasicDigestAuth": auth.NewParser(auth.AuthDirectory, cfg), + "Canary": canary.NewParser(cfg), + "CertificateAuth": authtls.NewParser(cfg), + "ClientBodyBufferSize": clientbodybuffersize.NewParser(cfg), + "ConfigurationSnippet": snippet.NewParser(cfg), + "Connection": connection.NewParser(cfg), + "CorsConfig": cors.NewParser(cfg), + "CustomHTTPErrors": customhttperrors.NewParser(cfg), + "CustomResponseHeaders": customresponseheaders.NewParser(cfg), + "DefaultBackend": defaultbackend.NewParser(cfg), + "FastCGI": fastcgi.NewParser(cfg), + "ExternalAuth": authreq.NewParser(cfg), + "EnableGlobalAuth": authreqglobal.NewParser(cfg), + "HTTP2PushPreload": http2pushpreload.NewParser(cfg), + "Opentracing": opentracing.NewParser(cfg), + "Opentelemetry": opentelemetry.NewParser(cfg), + "Proxy": proxy.NewParser(cfg), + "ProxySSL": proxyssl.NewParser(cfg), + "RateLimit": ratelimit.NewParser(cfg), + "GlobalRateLimit": globalratelimit.NewParser(cfg), + "Redirect": redirect.NewParser(cfg), + "Rewrite": rewrite.NewParser(cfg), + "Satisfy": satisfy.NewParser(cfg), + "ServerSnippet": serversnippet.NewParser(cfg), + "ServiceUpstream": serviceupstream.NewParser(cfg), + "SessionAffinity": sessionaffinity.NewParser(cfg), + "SSLPassthrough": sslpassthrough.NewParser(cfg), + "UsePortInRedirects": portinredirect.NewParser(cfg), + "UpstreamHashBy": upstreamhashby.NewParser(cfg), + "LoadBalancing": loadbalancing.NewParser(cfg), + "UpstreamVhost": upstreamvhost.NewParser(cfg), + "Allowlist": ipallowlist.NewParser(cfg), + "Denylist": ipdenylist.NewParser(cfg), + "XForwardedPrefix": xforwardedprefix.NewParser(cfg), + "SSLCipher": sslcipher.NewParser(cfg), + "Logs": log.NewParser(cfg), + "BackendProtocol": backendprotocol.NewParser(cfg), + "ModSecurity": modsecurity.NewParser(cfg), + "Mirror": mirror.NewParser(cfg), + "StreamSnippet": streamsnippet.NewParser(cfg), }, } } diff --git a/internal/ingress/annotations/annotations_test.go b/internal/ingress/annotations/annotations_test.go index 5f8128e0d..89e35ab46 100644 --- a/internal/ingress/annotations/annotations_test.go +++ b/internal/ingress/annotations/annotations_test.go @@ -43,6 +43,7 @@ var ( annotationAffinityCookieName = parser.GetAnnotationWithPrefix("session-cookie-name") annotationUpstreamHashBy = parser.GetAnnotationWithPrefix("upstream-hash-by") annotationCustomHTTPErrors = parser.GetAnnotationWithPrefix("custom-http-errors") + annotationCustomResponseHeaders = parser.GetAnnotationWithPrefix("custom-response-headers") ) type mockCfg struct { @@ -316,3 +317,44 @@ func TestCustomHTTPErrors(t *testing.T) { } } } + +func TestCustomResponseHeaders(t *testing.T) { + ec := NewAnnotationExtractor(mockCfg{}) + ing := buildIngress() + + fooAnns := []struct { + annotations map[string]string + headers map[string]string + }{ + {map[string]string{annotationCustomResponseHeaders: "Content-Type: application/json"}, map[string]string{"Content-Type": "application/json"}}, + {map[string]string{annotationCustomResponseHeaders: `Content-Type: application/json + Accept: application/json + `}, map[string]string{"Content-Type": "application/json", "Accept": "application/json"}}, + {map[string]string{annotationCustomResponseHeaders: `Content-Type application/json + Accept: application/json + `}, map[string]string{}}, + {nil, map[string]string{}}, + } + + for _, foo := range fooAnns { + ing.SetAnnotations(foo.annotations) + rann, err := ec.Extract(ing) + if err != nil { + t.Errorf("error should be null: %v", err) + } + r := rann.CustomResponseHeaders.ResponseHeaders + // Check that expected headers were created + for i := range foo.headers { + if r[i] != foo.headers[i] { + t.Errorf("Returned %v but expected %v", r, foo.headers) + } + } + + // Check that no unexpected headers were created + for i := range r { + if r[i] != foo.headers[i] { + t.Errorf("Returned %v but expected %v", r, foo.headers) + } + } + } +} diff --git a/internal/ingress/annotations/customresponseheaders/main.go b/internal/ingress/annotations/customresponseheaders/main.go new file mode 100644 index 000000000..64e49bad8 --- /dev/null +++ b/internal/ingress/annotations/customresponseheaders/main.go @@ -0,0 +1,143 @@ +/* +Copyright 2023 The Kubernetes 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 customresponseheaders + +import ( + "reflect" + "regexp" + "strings" + + networking "k8s.io/api/networking/v1" + "k8s.io/ingress-nginx/internal/ingress/annotations/parser" + + ing_errors "k8s.io/ingress-nginx/internal/ingress/errors" + "k8s.io/ingress-nginx/internal/ingress/resolver" +) + +var ( + headerRegexp = regexp.MustCompile(`^[a-zA-Z\d\-_]+$`) + //Regex below requires review. Following regex has been picked up from PR: https://github.com/kubernetes/ingress-nginx/pull/9742 + headerValueRegexp = regexp.MustCompile(`^[a-zA-Z\d_ :;.,\\/"'?!(){}\[\]@<>=\-+*#$&\x60|~^%]+$`) + completeHeaderRegexp = regexp.MustCompile(`^[a-zA-Z\d_ :;.,\\/\011\012"'?!(){}\[\]@<>=\-+*#$&\x60|~^%]+$`) +) + +const ( + customResponseHeadersAnnotation = "custom-response-headers" +) + +var customResponseHeadersAnnotations = parser.Annotation{ + Group: "backend", + Annotations: parser.AnnotationFields{ + customResponseHeadersAnnotation: { + Validator: parser.ValidateRegex(completeHeaderRegexp, true), + Scope: parser.AnnotationScopeLocation, + Risk: parser.AnnotationRiskLow, + Documentation: `This annotation will allows setting the custom response headers for the given ingress`, + }, + }, +} + +// Config returns the custom response headers for an Ingress rule +type Config struct { + ResponseHeaders map[string]string `json:"custom-response-headers,omitempty"` +} + +type customresponseheaders struct { + r resolver.Resolver + annotationConfig parser.Annotation +} + +// NewParser creates a new custom response headers annotation parser +func NewParser(r resolver.Resolver) parser.IngressAnnotation { + return customresponseheaders{ + r: r, + annotationConfig: customResponseHeadersAnnotations, + } +} + +// Equal tests for equality between two Configuration types +func (l1 *Config) Equal(l2 *Config) bool { + if l1 == l2 { + return true + } + + if l1 == nil || l2 == nil { + return false + } + + return reflect.DeepEqual(l1.ResponseHeaders, l2.ResponseHeaders) +} + +// Parse parses the annotations contained in the ingress to use +// custom response headers +func (e customresponseheaders) Parse(ing *networking.Ingress) (interface{}, error) { + headersMap := map[string]string{} + responseHeader, err := parser.GetStringAnnotation(customResponseHeadersAnnotation, ing, e.annotationConfig.Annotations) + if err != nil { + return nil, err + } + + headers := strings.Split(responseHeader, "\n") + for i := 0; i < len(headers); i++ { + if len(headers[i]) == 0 { + continue + } + + if !strings.Contains(headers[i], ":") { + return nil, ing_errors.NewLocationDenied("Invalid header format") + } + + headerSplit := strings.SplitN(headers[i], ":", 2) + for j := range headerSplit { + headerSplit[j] = strings.TrimSpace(headerSplit[j]) + } + + if len(headerSplit) < 2 { + return nil, ing_errors.NewLocationDenied("Invalid header size") + } + + if !ValidHeader(headerSplit[0]) { + return nil, ing_errors.NewLocationDenied("Invalid header name") + } + + if !ValidValue(headerSplit[1]) { + return nil, ing_errors.NewLocationDenied("Invalid header value") + } + + headersMap[strings.TrimSpace(headerSplit[0])] = strings.TrimSpace(headerSplit[1]) + } + return &Config{headersMap}, nil +} + +// ValidHeader checks is the provided string satisfies the header's name regex +func ValidHeader(header string) bool { + return headerRegexp.Match([]byte(header)) +} + +// ValidValue checks if the provided string satisfies the header value regex +func ValidValue(header string) bool { + return headerValueRegexp.MatchString(header) +} + +func (e customresponseheaders) GetDocumentation() parser.AnnotationFields { + return e.annotationConfig.Annotations +} + +func (a customresponseheaders) Validate(anns map[string]string) error { + maxrisk := parser.StringRiskToRisk(a.r.GetSecurityConfiguration().AnnotationsRiskLevel) + return parser.CheckAnnotationRisk(anns, maxrisk, customResponseHeadersAnnotations.Annotations) +} diff --git a/internal/ingress/annotations/customresponseheaders/main_test.go b/internal/ingress/annotations/customresponseheaders/main_test.go new file mode 100644 index 000000000..c4e7641f4 --- /dev/null +++ b/internal/ingress/annotations/customresponseheaders/main_test.go @@ -0,0 +1,136 @@ +/* +Copyright 2023 The Kubernetes 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 customresponseheaders + +import ( + "reflect" + "testing" + + api "k8s.io/api/core/v1" + networking "k8s.io/api/networking/v1" + meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/ingress-nginx/internal/ingress/annotations/parser" + "k8s.io/ingress-nginx/internal/ingress/resolver" +) + +func buildIngress() *networking.Ingress { + return &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "foo", + Namespace: api.NamespaceDefault, + }, + Spec: networking.IngressSpec{ + DefaultBackend: &networking.IngressBackend{ + Service: &networking.IngressServiceBackend{ + Name: "default-backend", + Port: networking.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + } +} + +func TestParseInvalidAnnotations(t *testing.T) { + ing := buildIngress() + + _, err := NewParser(&resolver.Mock{}).Parse(ing) + if err == nil { + t.Errorf("expected error parsing ingress with custom-response-headers") + } + + data := map[string]string{} + data[parser.GetAnnotationWithPrefix("custom-response-headers")] = ` + Content-Type application/json + Access-Control-Max-Age: 600 + Nameok k8s.io/ingress-nginx/internal/ingress/annotations/customresponseheaders 0.003s coverage: 88.5% of statements + + ` + ing.SetAnnotations(data) + i, err := NewParser(&resolver.Mock{}).Parse(ing) + if err == nil { + t.Errorf("expected error parsing ingress with custom-response-headers") + } + if i != nil { + t.Errorf("expected %v but got %v", nil, i) + } +} + +func TestParseAnnotations(t *testing.T) { + ing := buildIngress() + + data := map[string]string{} + data[parser.GetAnnotationWithPrefix("custom-response-headers")] = ` + Content-Type: application/json + Access-Control-Max-Age: 600 + ` + ing.SetAnnotations(data) + + i, err := NewParser(&resolver.Mock{}).Parse(ing) + if err != nil { + t.Errorf("unexpected error parsing ingress with custom-response-headers") + } + val, ok := i.(*Config) + if !ok { + t.Errorf("expected a *Config type") + } + + expected_response_headers := map[string]string{} + expected_response_headers["Content-Type"] = "application/json" + expected_response_headers["Access-Control-Max-Age"] = "600" + + c := &Config{expected_response_headers} + + if !reflect.DeepEqual(c, val) { + t.Errorf("expected %v but got %v", c, val) + } +} + +func TestConfig_Equal(t *testing.T) { + var nilConfig *Config + + config := &Config{ + ResponseHeaders: map[string]string{ + "nginx.ingress.kubernetes.io/custom-response-headers": "Cache-Control: no-cache", + }, + } + + config2 := &Config{ + ResponseHeaders: map[string]string{ + "nginx.ingress.kubernetes.io/custom-response-headers": "Cache-Control: cache", + }, + } + + configCopy := &Config{ + ResponseHeaders: map[string]string{ + "nginx.ingress.kubernetes.io/custom-response-headers": "Cache-Control: no-cache", + }, + } + + if config.Equal(config2) { + t.Errorf("config2 should not be equal to config") + } + + if !config.Equal(configCopy) { + t.Errorf("config should not be equal to configCopy") + } + + if config.Equal(nilConfig) { + t.Errorf("config should not be equal to nilConfig") + } +} diff --git a/internal/ingress/controller/config/config.go b/internal/ingress/controller/config/config.go index 0dafa78a2..7fb4a99b3 100644 --- a/internal/ingress/controller/config/config.go +++ b/internal/ingress/controller/config/config.go @@ -207,6 +207,9 @@ type Configuration struct { // DisableIpv6 disable listening on ipv6 address DisableIpv6 bool `json:"disable-ipv6,omitempty"` + // DisableCustomResponseHeaders disable custom response headers annotation + DisableCustomResponseHeaders bool `json:"disable-custom-response-headers,omitempty"` + // EnableUnderscoresInHeaders enables underscores in header names // http://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers // By default this is disabled @@ -969,6 +972,7 @@ func NewDefault() Configuration { SSLRedirect: true, CustomHTTPErrors: []int{}, DenylistSourceRange: []string{}, + CustomResponseHeaders: map[string]string{}, WhitelistSourceRange: []string{}, SkipAccessLogURLs: []string{}, LimitRate: 0, diff --git a/internal/ingress/controller/controller.go b/internal/ingress/controller/controller.go index f2ad4639b..07c53646c 100644 --- a/internal/ingress/controller/controller.go +++ b/internal/ingress/controller/controller.go @@ -1527,6 +1527,7 @@ func locationApplyAnnotations(loc *ingress.Location, anns *annotations.Ingress) loc.BackendProtocol = anns.BackendProtocol loc.FastCGI = anns.FastCGI loc.CustomHTTPErrors = anns.CustomHTTPErrors + loc.CustomResponseHeaders = anns.CustomResponseHeaders loc.ModSecurity = anns.ModSecurity loc.Satisfy = anns.Satisfy loc.Mirror = anns.Mirror diff --git a/internal/ingress/defaults/main.go b/internal/ingress/defaults/main.go index 0526d443e..e299d54c2 100644 --- a/internal/ingress/defaults/main.go +++ b/internal/ingress/defaults/main.go @@ -31,6 +31,11 @@ type Backend struct { // By default this is disabled CustomHTTPErrors []int `json:"custom-http-errors"` + // Defines custom response HTTP headers which should be passed to more_set_headers directive + // https://github.com/openresty/headers-more-nginx-module#more_set_headers + // By default this is empty + CustomResponseHeaders map[string]string `json:"custom-response-headers"` + // toggles whether or not to remove trailing slashes during TLS redirects PreserveTrailingSlash bool `json:"preserve-trailing-slash"` diff --git a/pkg/apis/ingress/types.go b/pkg/apis/ingress/types.go index 0742e9f3b..996fa1ffb 100644 --- a/pkg/apis/ingress/types.go +++ b/pkg/apis/ingress/types.go @@ -27,6 +27,7 @@ import ( "k8s.io/ingress-nginx/internal/ingress/annotations/authtls" "k8s.io/ingress-nginx/internal/ingress/annotations/connection" "k8s.io/ingress-nginx/internal/ingress/annotations/cors" + "k8s.io/ingress-nginx/internal/ingress/annotations/customresponseheaders" "k8s.io/ingress-nginx/internal/ingress/annotations/fastcgi" "k8s.io/ingress-nginx/internal/ingress/annotations/globalratelimit" "k8s.io/ingress-nginx/internal/ingress/annotations/ipallowlist" @@ -360,6 +361,9 @@ type Location struct { // Opentelemetry allows the global opentelemetry setting to be overridden for a location // +optional Opentelemetry opentelemetry.Config `json:"opentelemetry"` + // CustomResponseHeaders allows to pass custom response headers. + // +optional + CustomResponseHeaders customresponseheaders.Config `json:"custom-response-headers,omitempty"` } // SSLPassthroughBackend describes a SSL upstream server configured diff --git a/pkg/apis/ingress/types_equals.go b/pkg/apis/ingress/types_equals.go index 45f0eedba..4d86057ec 100644 --- a/pkg/apis/ingress/types_equals.go +++ b/pkg/apis/ingress/types_equals.go @@ -470,6 +470,10 @@ func (l1 *Location) Equal(l2 *Location) bool { return false } + if !l1.CustomResponseHeaders.Equal(&l2.CustomResponseHeaders) { + return false + } + return true } diff --git a/rootfs/etc/nginx/template/nginx.tmpl b/rootfs/etc/nginx/template/nginx.tmpl index ccd7b4411..2654ed584 100644 --- a/rootfs/etc/nginx/template/nginx.tmpl +++ b/rootfs/etc/nginx/template/nginx.tmpl @@ -1513,6 +1513,15 @@ stream { fastcgi_param {{ $k }} {{ $v | quote }}; {{ end }} + {{ if $location.CustomResponseHeaders }} + # Custom Response Headers + {{ if not $all.Cfg.DisableCustomResponseHeaders }} + {{ range $k, $v := $location.CustomResponseHeaders.ResponseHeaders }} + more_set_headers {{ printf "%s: %s" $k $v | escapeLiteralDollar | quote }}; + {{ end }} + {{ end }} + {{ end }} + {{ if not (empty $location.Redirect.URL) }} return {{ $location.Redirect.Code }} {{ $location.Redirect.URL }}; {{ end }} diff --git a/test/e2e/annotations/customresponseheaders.go b/test/e2e/annotations/customresponseheaders.go new file mode 100644 index 000000000..f6143bab4 --- /dev/null +++ b/test/e2e/annotations/customresponseheaders.go @@ -0,0 +1,98 @@ +/* +Copyright 2023 The Kubernetes 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 annotations + +import ( + "net/http" + "strings" + + "github.com/onsi/ginkgo/v2" + + "k8s.io/ingress-nginx/test/e2e/framework" +) + +var _ = framework.DescribeAnnotation("custom-response-headers", func() { + f := framework.NewDefaultFramework("custom-response-headers") + + ginkgo.BeforeEach(func() { + f.NewEchoDeployment() + }) + + ginkgo.It("should add custom response header via the custom-response-headers annotation", func() { + host := "customresponseheaders.foo.com" + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/custom-response-headers": ` + Content-Type: application/json + `, + } + + ing := framework.NewSingleIngress(host, "/foo", host, f.Namespace, framework.EchoService, 80, annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return strings.Contains(server, `more_set_headers "Content-Type: application/json";`) + }) + + f.HTTPTestClient(). + GET("/foo"). + WithHeader("Host", host). + Expect(). + Status(http.StatusOK).Headers().ContainsKey("Content-Type") + }) + + ginkgo.It("should work without custom-response-headers annotation present on the ingress", func() { + host := "customresponseheaders.foo.com" + ing := framework.NewSingleIngress(host, "/", host, f.Namespace, framework.EchoService, 80, nil) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return strings.Contains(server, "server_name customresponseheaders.foo.com") + }) + + f.HTTPTestClient(). + GET("/"). + WithHeader("Host", host). + Expect(). + Status(http.StatusOK).Headers().NotContainsKey("application/json") + }) + + ginkgo.It("should disable the custom-response-headers annotation using global flag", func() { + host := "customresponseheaders.foo.com" + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/custom-response-headers": ` + Cache-Control: no-cache + `, + } + f.UpdateNginxConfigMapData("disable-custom-response-headers", "true") + + ing := framework.NewSingleIngress(host, "/bar", host, f.Namespace, framework.EchoService, 80, annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return !strings.Contains(server, `more_set_headers "Cache-Control: no-cache";`) + }) + + f.HTTPTestClient(). + GET("/bar"). + WithHeader("Host", host). + Expect(). + Status(http.StatusOK).Headers().NotContainsKey("Cache-Control") + }) +})