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/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: "<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
|
||||
|
||||
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 (
|
||||
"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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
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 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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -470,6 +470,10 @@ func (l1 *Location) Equal(l2 *Location) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
if !l1.CustomResponseHeaders.Equal(&l2.CustomResponseHeaders) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
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