Add support for custom response headers annotation
What: Add annotation `nginx.ingress.kubernetes.io/custom-response-headers` to allow users to provide custom response headers to the response for the specific ingress. It uses `more_set_headers` directive to add headers to the response. This will provide more flexibility to users to specify custom response headers than via config map approach. Why: There's no way except the vulnerable server config snippet annotation to supply the custom response headers. Hence, we need a way to specify custom response headers at ingress level. Signed-off-by: Satyam Zode <satyamz@stellar.org>
This commit is contained in:
parent
c9ca168364
commit
6034c82946
12 changed files with 540 additions and 81 deletions
|
@ -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/client-body-buffer-size](#client-body-buffer-size)|string|
|
||||||
|[nginx.ingress.kubernetes.io/configuration-snippet](#configuration-snippet)|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-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/default-backend](#default-backend)|string|
|
||||||
|[nginx.ingress.kubernetes.io/enable-cors](#enable-cors)|"true" or "false"|
|
|[nginx.ingress.kubernetes.io/enable-cors](#enable-cors)|"true" or "false"|
|
||||||
|[nginx.ingress.kubernetes.io/cors-allow-origin](#enable-cors)|string|
|
|[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"
|
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: "<header>"` 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
|
### Default Backend
|
||||||
|
|
||||||
This annotation is of the form `nginx.ingress.kubernetes.io/default-backend: <svc name>` to specify a custom default backend. This `<svc name>` 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.
|
This annotation is of the form `nginx.ingress.kubernetes.io/default-backend: <svc name>` to specify a custom default backend. This `<svc name>` 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.
|
||||||
|
|
|
@ -19,6 +19,7 @@ package annotations
|
||||||
import (
|
import (
|
||||||
"github.com/imdario/mergo"
|
"github.com/imdario/mergo"
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/canary"
|
"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/modsecurity"
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/opentelemetry"
|
"k8s.io/ingress-nginx/internal/ingress/annotations/opentelemetry"
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/proxyssl"
|
"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
|
// Ingress defines the valid annotations present in one NGINX Ingress rule
|
||||||
type Ingress struct {
|
type Ingress struct {
|
||||||
metav1.ObjectMeta
|
metav1.ObjectMeta
|
||||||
BackendProtocol string
|
BackendProtocol string
|
||||||
Aliases []string
|
Aliases []string
|
||||||
BasicDigestAuth auth.Config
|
BasicDigestAuth auth.Config
|
||||||
Canary canary.Config
|
Canary canary.Config
|
||||||
CertificateAuth authtls.Config
|
CertificateAuth authtls.Config
|
||||||
ClientBodyBufferSize string
|
ClientBodyBufferSize string
|
||||||
ConfigurationSnippet string
|
ConfigurationSnippet string
|
||||||
Connection connection.Config
|
Connection connection.Config
|
||||||
CorsConfig cors.Config
|
CorsConfig cors.Config
|
||||||
CustomHTTPErrors []int
|
CustomHTTPErrors []int
|
||||||
DefaultBackend *apiv1.Service
|
CustomResponseHeaders customresponseheaders.Config
|
||||||
FastCGI fastcgi.Config
|
DefaultBackend *apiv1.Service
|
||||||
Denied *string
|
FastCGI fastcgi.Config
|
||||||
ExternalAuth authreq.Config
|
Denied *string
|
||||||
EnableGlobalAuth bool
|
ExternalAuth authreq.Config
|
||||||
HTTP2PushPreload bool
|
EnableGlobalAuth bool
|
||||||
Opentracing opentracing.Config
|
HTTP2PushPreload bool
|
||||||
Opentelemetry opentelemetry.Config
|
Opentracing opentracing.Config
|
||||||
Proxy proxy.Config
|
Opentelemetry opentelemetry.Config
|
||||||
ProxySSL proxyssl.Config
|
Proxy proxy.Config
|
||||||
RateLimit ratelimit.Config
|
ProxySSL proxyssl.Config
|
||||||
GlobalRateLimit globalratelimit.Config
|
RateLimit ratelimit.Config
|
||||||
Redirect redirect.Config
|
GlobalRateLimit globalratelimit.Config
|
||||||
Rewrite rewrite.Config
|
Redirect redirect.Config
|
||||||
Satisfy string
|
Rewrite rewrite.Config
|
||||||
ServerSnippet string
|
Satisfy string
|
||||||
ServiceUpstream bool
|
ServerSnippet string
|
||||||
SessionAffinity sessionaffinity.Config
|
ServiceUpstream bool
|
||||||
SSLPassthrough bool
|
SessionAffinity sessionaffinity.Config
|
||||||
UsePortInRedirects bool
|
SSLPassthrough bool
|
||||||
UpstreamHashBy upstreamhashby.Config
|
UsePortInRedirects bool
|
||||||
LoadBalancing string
|
UpstreamHashBy upstreamhashby.Config
|
||||||
UpstreamVhost string
|
LoadBalancing string
|
||||||
Denylist ipdenylist.SourceRange
|
UpstreamVhost string
|
||||||
XForwardedPrefix string
|
Denylist ipdenylist.SourceRange
|
||||||
SSLCipher sslcipher.Config
|
XForwardedPrefix string
|
||||||
Logs log.Config
|
SSLCipher sslcipher.Config
|
||||||
ModSecurity modsecurity.Config
|
Logs log.Config
|
||||||
Mirror mirror.Config
|
ModSecurity modsecurity.Config
|
||||||
StreamSnippet string
|
Mirror mirror.Config
|
||||||
Allowlist ipallowlist.SourceRange
|
StreamSnippet string
|
||||||
|
Allowlist ipallowlist.SourceRange
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extractor defines the annotation parsers to be used in the extraction of annotations
|
// 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 {
|
func NewAnnotationExtractor(cfg resolver.Resolver) Extractor {
|
||||||
return Extractor{
|
return Extractor{
|
||||||
map[string]parser.IngressAnnotation{
|
map[string]parser.IngressAnnotation{
|
||||||
"Aliases": alias.NewParser(cfg),
|
"Aliases": alias.NewParser(cfg),
|
||||||
"BasicDigestAuth": auth.NewParser(auth.AuthDirectory, cfg),
|
"BasicDigestAuth": auth.NewParser(auth.AuthDirectory, cfg),
|
||||||
"Canary": canary.NewParser(cfg),
|
"Canary": canary.NewParser(cfg),
|
||||||
"CertificateAuth": authtls.NewParser(cfg),
|
"CertificateAuth": authtls.NewParser(cfg),
|
||||||
"ClientBodyBufferSize": clientbodybuffersize.NewParser(cfg),
|
"ClientBodyBufferSize": clientbodybuffersize.NewParser(cfg),
|
||||||
"ConfigurationSnippet": snippet.NewParser(cfg),
|
"ConfigurationSnippet": snippet.NewParser(cfg),
|
||||||
"Connection": connection.NewParser(cfg),
|
"Connection": connection.NewParser(cfg),
|
||||||
"CorsConfig": cors.NewParser(cfg),
|
"CorsConfig": cors.NewParser(cfg),
|
||||||
"CustomHTTPErrors": customhttperrors.NewParser(cfg),
|
"CustomHTTPErrors": customhttperrors.NewParser(cfg),
|
||||||
"DefaultBackend": defaultbackend.NewParser(cfg),
|
"CustomResponseHeaders": customresponseheaders.NewParser(cfg),
|
||||||
"FastCGI": fastcgi.NewParser(cfg),
|
"DefaultBackend": defaultbackend.NewParser(cfg),
|
||||||
"ExternalAuth": authreq.NewParser(cfg),
|
"FastCGI": fastcgi.NewParser(cfg),
|
||||||
"EnableGlobalAuth": authreqglobal.NewParser(cfg),
|
"ExternalAuth": authreq.NewParser(cfg),
|
||||||
"HTTP2PushPreload": http2pushpreload.NewParser(cfg),
|
"EnableGlobalAuth": authreqglobal.NewParser(cfg),
|
||||||
"Opentracing": opentracing.NewParser(cfg),
|
"HTTP2PushPreload": http2pushpreload.NewParser(cfg),
|
||||||
"Opentelemetry": opentelemetry.NewParser(cfg),
|
"Opentracing": opentracing.NewParser(cfg),
|
||||||
"Proxy": proxy.NewParser(cfg),
|
"Opentelemetry": opentelemetry.NewParser(cfg),
|
||||||
"ProxySSL": proxyssl.NewParser(cfg),
|
"Proxy": proxy.NewParser(cfg),
|
||||||
"RateLimit": ratelimit.NewParser(cfg),
|
"ProxySSL": proxyssl.NewParser(cfg),
|
||||||
"GlobalRateLimit": globalratelimit.NewParser(cfg),
|
"RateLimit": ratelimit.NewParser(cfg),
|
||||||
"Redirect": redirect.NewParser(cfg),
|
"GlobalRateLimit": globalratelimit.NewParser(cfg),
|
||||||
"Rewrite": rewrite.NewParser(cfg),
|
"Redirect": redirect.NewParser(cfg),
|
||||||
"Satisfy": satisfy.NewParser(cfg),
|
"Rewrite": rewrite.NewParser(cfg),
|
||||||
"ServerSnippet": serversnippet.NewParser(cfg),
|
"Satisfy": satisfy.NewParser(cfg),
|
||||||
"ServiceUpstream": serviceupstream.NewParser(cfg),
|
"ServerSnippet": serversnippet.NewParser(cfg),
|
||||||
"SessionAffinity": sessionaffinity.NewParser(cfg),
|
"ServiceUpstream": serviceupstream.NewParser(cfg),
|
||||||
"SSLPassthrough": sslpassthrough.NewParser(cfg),
|
"SessionAffinity": sessionaffinity.NewParser(cfg),
|
||||||
"UsePortInRedirects": portinredirect.NewParser(cfg),
|
"SSLPassthrough": sslpassthrough.NewParser(cfg),
|
||||||
"UpstreamHashBy": upstreamhashby.NewParser(cfg),
|
"UsePortInRedirects": portinredirect.NewParser(cfg),
|
||||||
"LoadBalancing": loadbalancing.NewParser(cfg),
|
"UpstreamHashBy": upstreamhashby.NewParser(cfg),
|
||||||
"UpstreamVhost": upstreamvhost.NewParser(cfg),
|
"LoadBalancing": loadbalancing.NewParser(cfg),
|
||||||
"Allowlist": ipallowlist.NewParser(cfg),
|
"UpstreamVhost": upstreamvhost.NewParser(cfg),
|
||||||
"Denylist": ipdenylist.NewParser(cfg),
|
"Allowlist": ipallowlist.NewParser(cfg),
|
||||||
"XForwardedPrefix": xforwardedprefix.NewParser(cfg),
|
"Denylist": ipdenylist.NewParser(cfg),
|
||||||
"SSLCipher": sslcipher.NewParser(cfg),
|
"XForwardedPrefix": xforwardedprefix.NewParser(cfg),
|
||||||
"Logs": log.NewParser(cfg),
|
"SSLCipher": sslcipher.NewParser(cfg),
|
||||||
"BackendProtocol": backendprotocol.NewParser(cfg),
|
"Logs": log.NewParser(cfg),
|
||||||
"ModSecurity": modsecurity.NewParser(cfg),
|
"BackendProtocol": backendprotocol.NewParser(cfg),
|
||||||
"Mirror": mirror.NewParser(cfg),
|
"ModSecurity": modsecurity.NewParser(cfg),
|
||||||
"StreamSnippet": streamsnippet.NewParser(cfg),
|
"Mirror": mirror.NewParser(cfg),
|
||||||
|
"StreamSnippet": streamsnippet.NewParser(cfg),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,7 @@ var (
|
||||||
annotationAffinityCookieName = parser.GetAnnotationWithPrefix("session-cookie-name")
|
annotationAffinityCookieName = parser.GetAnnotationWithPrefix("session-cookie-name")
|
||||||
annotationUpstreamHashBy = parser.GetAnnotationWithPrefix("upstream-hash-by")
|
annotationUpstreamHashBy = parser.GetAnnotationWithPrefix("upstream-hash-by")
|
||||||
annotationCustomHTTPErrors = parser.GetAnnotationWithPrefix("custom-http-errors")
|
annotationCustomHTTPErrors = parser.GetAnnotationWithPrefix("custom-http-errors")
|
||||||
|
annotationCustomResponseHeaders = parser.GetAnnotationWithPrefix("custom-response-headers")
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockCfg struct {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
143
internal/ingress/annotations/customresponseheaders/main.go
Normal file
143
internal/ingress/annotations/customresponseheaders/main.go
Normal file
|
@ -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)
|
||||||
|
}
|
136
internal/ingress/annotations/customresponseheaders/main_test.go
Normal file
136
internal/ingress/annotations/customresponseheaders/main_test.go
Normal file
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -207,6 +207,9 @@ type Configuration struct {
|
||||||
// DisableIpv6 disable listening on ipv6 address
|
// DisableIpv6 disable listening on ipv6 address
|
||||||
DisableIpv6 bool `json:"disable-ipv6,omitempty"`
|
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
|
// EnableUnderscoresInHeaders enables underscores in header names
|
||||||
// http://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers
|
// http://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers
|
||||||
// By default this is disabled
|
// By default this is disabled
|
||||||
|
@ -969,6 +972,7 @@ func NewDefault() Configuration {
|
||||||
SSLRedirect: true,
|
SSLRedirect: true,
|
||||||
CustomHTTPErrors: []int{},
|
CustomHTTPErrors: []int{},
|
||||||
DenylistSourceRange: []string{},
|
DenylistSourceRange: []string{},
|
||||||
|
CustomResponseHeaders: map[string]string{},
|
||||||
WhitelistSourceRange: []string{},
|
WhitelistSourceRange: []string{},
|
||||||
SkipAccessLogURLs: []string{},
|
SkipAccessLogURLs: []string{},
|
||||||
LimitRate: 0,
|
LimitRate: 0,
|
||||||
|
|
|
@ -1527,6 +1527,7 @@ func locationApplyAnnotations(loc *ingress.Location, anns *annotations.Ingress)
|
||||||
loc.BackendProtocol = anns.BackendProtocol
|
loc.BackendProtocol = anns.BackendProtocol
|
||||||
loc.FastCGI = anns.FastCGI
|
loc.FastCGI = anns.FastCGI
|
||||||
loc.CustomHTTPErrors = anns.CustomHTTPErrors
|
loc.CustomHTTPErrors = anns.CustomHTTPErrors
|
||||||
|
loc.CustomResponseHeaders = anns.CustomResponseHeaders
|
||||||
loc.ModSecurity = anns.ModSecurity
|
loc.ModSecurity = anns.ModSecurity
|
||||||
loc.Satisfy = anns.Satisfy
|
loc.Satisfy = anns.Satisfy
|
||||||
loc.Mirror = anns.Mirror
|
loc.Mirror = anns.Mirror
|
||||||
|
|
|
@ -31,6 +31,11 @@ type Backend struct {
|
||||||
// By default this is disabled
|
// By default this is disabled
|
||||||
CustomHTTPErrors []int `json:"custom-http-errors"`
|
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
|
// toggles whether or not to remove trailing slashes during TLS redirects
|
||||||
PreserveTrailingSlash bool `json:"preserve-trailing-slash"`
|
PreserveTrailingSlash bool `json:"preserve-trailing-slash"`
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,7 @@ import (
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/authtls"
|
"k8s.io/ingress-nginx/internal/ingress/annotations/authtls"
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/connection"
|
"k8s.io/ingress-nginx/internal/ingress/annotations/connection"
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/cors"
|
"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/fastcgi"
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/globalratelimit"
|
"k8s.io/ingress-nginx/internal/ingress/annotations/globalratelimit"
|
||||||
"k8s.io/ingress-nginx/internal/ingress/annotations/ipallowlist"
|
"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
|
// Opentelemetry allows the global opentelemetry setting to be overridden for a location
|
||||||
// +optional
|
// +optional
|
||||||
Opentelemetry opentelemetry.Config `json:"opentelemetry"`
|
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
|
// SSLPassthroughBackend describes a SSL upstream server configured
|
||||||
|
|
|
@ -470,6 +470,10 @@ func (l1 *Location) Equal(l2 *Location) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !l1.CustomResponseHeaders.Equal(&l2.CustomResponseHeaders) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1513,6 +1513,15 @@ stream {
|
||||||
fastcgi_param {{ $k }} {{ $v | quote }};
|
fastcgi_param {{ $k }} {{ $v | quote }};
|
||||||
{{ end }}
|
{{ 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) }}
|
{{ if not (empty $location.Redirect.URL) }}
|
||||||
return {{ $location.Redirect.Code }} {{ $location.Redirect.URL }};
|
return {{ $location.Redirect.Code }} {{ $location.Redirect.URL }};
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
98
test/e2e/annotations/customresponseheaders.go
Normal file
98
test/e2e/annotations/customresponseheaders.go
Normal file
|
@ -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")
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue