From 4cb6241e93492de4befa962dab73087fdc145402 Mon Sep 17 00:00:00 2001 From: Daniel Tomcej Date: Tue, 28 Nov 2017 06:36:03 -0600 Subject: [PATCH] Kubernetes security header annotations --- autogen/gentemplates/gen.go | 46 ++++++++ docs/configuration/backends/kubernetes.md | 30 ++++- provider/kubernetes/annotations_parser.go | 62 +++++++++++ provider/kubernetes/kubernetes.go | 127 ++++++++++++---------- templates/kubernetes.tmpl | 46 ++++++++ 5 files changed, 251 insertions(+), 60 deletions(-) create mode 100644 provider/kubernetes/annotations_parser.go diff --git a/autogen/gentemplates/gen.go b/autogen/gentemplates/gen.go index d56fe3e39..cdcd6448a 100644 --- a/autogen/gentemplates/gen.go +++ b/autogen/gentemplates/gen.go @@ -421,6 +421,52 @@ var _templatesKubernetesTmpl = []byte(`[backends]{{range $backendName, $backend whitelistSourceRange = [{{range $frontend.WhitelistSourceRange}} "{{.}}", {{end}}] + [frontends."{{$frontendName}}".headers] + SSLRedirect = {{$frontend.Headers.SSLRedirect}} + SSLTemporaryRedirect = {{$frontend.Headers.SSLTemporaryRedirect}} + SSLHost = "{{$frontend.Headers.SSLHost}}" + STSSeconds = {{$frontend.Headers.STSSeconds}} + STSIncludeSubdomains = {{$frontend.Headers.STSIncludeSubdomains}} + STSPreload = {{$frontend.Headers.STSPreload}} + ForceSTSHeader = {{$frontend.Headers.ForceSTSHeader}} + FrameDeny = {{$frontend.Headers.FrameDeny}} + CustomFrameOptionsValue = "{{$frontend.Headers.CustomFrameOptionsValue}}" + ContentTypeNosniff = {{$frontend.Headers.ContentTypeNosniff}} + BrowserXSSFilter = {{$frontend.Headers.BrowserXSSFilter}} + ContentSecurityPolicy = "{{$frontend.Headers.ContentSecurityPolicy}}" + PublicKey = "{{$frontend.Headers.PublicKey}}" + ReferrerPolicy = "{{$frontend.Headers.ReferrerPolicy}}" + IsDevelopment = {{$frontend.Headers.IsDevelopment}} +{{if $frontend.Headers.CustomRequestHeaders}} + [frontends."{{$frontendName}}".headers.customrequestheaders] + {{range $k, $v := $frontend.Headers.CustomRequestHeaders}} + {{$k}} = "{{$v}}" + {{end}} +{{end}} +{{if $frontend.Headers.CustomResponseHeaders}} + [frontends."{{$frontendName}}".headers.customresponseheaders] + {{range $k, $v := $frontend.Headers.CustomResponseHeaders}} + {{$k}} = "{{$v}}" + {{end}} +{{end}} +{{if $frontend.Headers.AllowedHosts}} + [frontends."{{$frontendName}}".headers.AllowedHosts] + {{range $frontend.Headers.AllowedHosts}} + "{{.}}" + {{end}} +{{end}} +{{if $frontend.Headers.HostsProxyHeaders}} + [frontends."{{$frontendName}}".headers.HostsProxyHeaders] + {{range $frontend.Headers.HostsProxyHeaders}} + "{{.}}" + {{end}} +{{end}} +{{if $frontend.Headers.SSLProxyHeaders}} + [frontends."{{$frontendName}}".headers.SSLProxyHeaders] + {{range $k, $v := $frontend.Headers.SSLProxyHeaders}} + {{$k}} = "{{$v}}" + {{end}} +{{end}} {{range $routeName, $route := $frontend.Routes}} [frontends."{{$frontendName}}".routes."{{$routeName}}"] rule = "{{$route.Rule}}" diff --git a/docs/configuration/backends/kubernetes.md b/docs/configuration/backends/kubernetes.md index 61a850ed3..b2d6d27dd 100644 --- a/docs/configuration/backends/kubernetes.md +++ b/docs/configuration/backends/kubernetes.md @@ -2,7 +2,7 @@ Træfik can be configured to use Kubernetes Ingress as a backend configuration. -See also [Kubernetes user guide](/user-guide/kubernetes). +See also [Kubernetes user guide](/user-guide/kubernetes). ## Configuration @@ -102,7 +102,7 @@ Annotations can be used on containers to override default behaviour for the whol Override the default frontend rule type. Default: `PathPrefix`. - `traefik.frontend.priority: "3"` Override the default frontend rule priority. -- `traefik.frontend.redirect: https`: +- `traefik.frontend.redirect: https`: Enables Redirect to another entryPoint for that frontend (e.g. HTTPS). - `traefik.frontend.entryPoints: http,https` Override the default frontend endpoints. @@ -132,6 +132,32 @@ As known from nginx when used as Kubernetes Ingress Controller, a list of IP-Ran An unset or empty list allows all Source-IPs to access. If one of the Net-Specifications are invalid, the whole list is invalid and allows all Source-IPs to access. +#### Security annotations + +The following security annotations can be applied to the ingress object to add security features: + +| Annotation | Description | +|----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ingress.kubernetes.io/allowed-hosts:EXPR` | Provides a list of allowed hosts that requests will be processed. Format: `Host1,Host2` | +| `ingress.kubernetes.io/custom-request-headers:EXPR ` | Provides the container with custom request headers that will be appended to each request forwarded to the container. Format: `HEADER:value,HEADER2:value2` | +| `ingress.kubernetes.io/custom-response-headers:EXPR` | Appends the headers to each response returned by the container, before forwarding the response to the client. Format: `HEADER:value,HEADER2:value2` | +| `ingress.kubernetes.io/proxy-headers:EXPR ` | Provides a list of headers that the proxied hostname may be stored. Format: `HEADER1,HEADER2` | +| `ingress.kubernetes.io/ssl-redirect:true` | Forces the frontend to redirect to SSL if a non-SSL request is sent. | +| `ingress.kubernetes.io/ssl-temporary-redirect:true` | Forces the frontend to redirect to SSL if a non-SSL request is sent, but by sending a 302 instead of a 301. | +| `ingress.kubernetes.io/ssl-host:HOST` | This setting configures the hostname that redirects will be based on. Default is "", which is the same host as the request. | +| `ingress.kubernetes.io/ssl-proxy-headers:EXPR` | Header combinations that would signify a proper SSL Request (Such as `X-Forwarded-For:https`). Format: `HEADER:value,HEADER2:value2` | +| `ingress.kubernetes.io/hsts-max-age:315360000` | Sets the max-age of the HSTS header. | +| `ngress.kubernetes.io/hsts-include-subdomains:true` | Adds the IncludeSubdomains section of the STS header. | +| `ingress.kubernetes.io/hsts-preload:true` | Adds the preload flag to the HSTS header. | +| `ingress.kubernetes.io/force-hsts:false` | Adds the STS header to non-SSL requests. | +| `ingress.kubernetes.io/frame-deny:false` | Adds the `X-Frame-Options` header with the value of `DENY`. | +| `ingress.kubernetes.io/custom-frame-options-value:VALUE` | Overrides the `X-Frame-Options` header with the custom value. | +| `ingress.kubernetes.io/content-type-nosniff:true` | Adds the `X-Content-Type-Options` header with the value `nosniff`. | +| `ingress.kubernetes.io/browser-xss-filter:true` | Adds the X-XSS-Protection header with the value `1; mode=block`. | +| `ingress.kubernetes.io/content-security-policy:VALUE` | Adds CSP Header with the custom value. | +| `ingress.kubernetes.io/public-key:VALUE` | Adds pinned HTST public key header. | +| `ingress.kubernetes.io/referrer-policy:VALUE` | Adds referrer policy header. | +| `ingress.kubernetes.io/is-development:false` | This will cause the `AllowedHosts`, `SSLRedirect`, and `STSSeconds`/`STSIncludeSubdomains` options to be ignored during development.
When deploying to production, be sure to set this to false. | ### Authentication diff --git a/provider/kubernetes/annotations_parser.go b/provider/kubernetes/annotations_parser.go new file mode 100644 index 000000000..361a87f04 --- /dev/null +++ b/provider/kubernetes/annotations_parser.go @@ -0,0 +1,62 @@ +package kubernetes + +import ( + "strings" + + "github.com/containous/traefik/log" + "github.com/containous/traefik/provider" + "github.com/containous/traefik/types" + "k8s.io/client-go/pkg/apis/extensions/v1beta1" +) + +func getBoolAnnotation(meta *v1beta1.Ingress, name string, defaultValue bool) bool { + annotationValue := defaultValue + annotationStringValue, ok := meta.Annotations[name] + switch { + case !ok: + // No op. + case annotationStringValue == "false": + annotationValue = false + case annotationStringValue == "true": + annotationValue = true + default: + log.Warnf("Unknown value '%s' for %s, falling back to %s", name, types.LabelFrontendPassTLSCert, defaultValue) + } + return annotationValue +} + +func getStringAnnotation(meta *v1beta1.Ingress, name string) string { + value := meta.Annotations[name] + return value +} + +func getSliceAnnotation(meta *v1beta1.Ingress, name string) []string { + var value []string + if annotation, ok := meta.Annotations[name]; ok && annotation != "" { + value = provider.SplitAndTrimString(annotation) + } + if len(value) == 0 { + log.Debugf("Could not load %v annotation, skipping...", name) + return nil + } + return value +} + +func getMapAnnotation(meta *v1beta1.Ingress, name string) map[string]string { + value := make(map[string]string) + if annotation := meta.Annotations[name]; annotation != "" { + for _, v := range strings.Split(annotation, ",") { + pair := strings.Split(v, ":") + if len(pair) != 2 { + log.Debugf("Could not load annotation (%v) with value: %v, skipping...", name, pair) + } else { + value[pair[0]] = pair[1] + } + } + } + if len(value) == 0 { + log.Debugf("Could not load %v annotation, skipping...", name) + return nil + } + return value +} diff --git a/provider/kubernetes/kubernetes.go b/provider/kubernetes/kubernetes.go index f62293976..5284a2220 100644 --- a/provider/kubernetes/kubernetes.go +++ b/provider/kubernetes/kubernetes.go @@ -30,12 +30,32 @@ const ( ruleTypePathPrefix = "PathPrefix" ruleTypeReplacePath = "ReplacePath" - annotationKubernetesIngressClass = "kubernetes.io/ingress.class" - annotationKubernetesAuthRealm = "ingress.kubernetes.io/auth-realm" - annotationKubernetesAuthType = "ingress.kubernetes.io/auth-type" - annotationKubernetesAuthSecret = "ingress.kubernetes.io/auth-secret" - annotationKubernetesRewriteTarget = "ingress.kubernetes.io/rewrite-target" - annotationKubernetesWhitelistSourceRange = "ingress.kubernetes.io/whitelist-source-range" + annotationKubernetesIngressClass = "kubernetes.io/ingress.class" + annotationKubernetesAuthRealm = "ingress.kubernetes.io/auth-realm" + annotationKubernetesAuthType = "ingress.kubernetes.io/auth-type" + annotationKubernetesAuthSecret = "ingress.kubernetes.io/auth-secret" + annotationKubernetesRewriteTarget = "ingress.kubernetes.io/rewrite-target" + annotationKubernetesWhitelistSourceRange = "ingress.kubernetes.io/whitelist-source-range" + annotationKubernetesSSLRedirect = "ingress.kubernetes.io/ssl-redirect" + annotationKubernetesHSTSMaxAge = "ingress.kubernetes.io/hsts-max-age" + annotationKubernetesHSTSIncludeSubdomains = "ingress.kubernetes.io/hsts-include-subdomains" + annotationKubernetesCustomRequestHeaders = "ingress.kubernetes.io/custom-request-headers" + annotationKubernetesCustomResponseHeaders = "ingress.kubernetes.io/custom-response-headers" + annotationKubernetesAllowedHosts = "ingress.kubernetes.io/allowed-hosts" + annotationKubernetesProxyHeaders = "ingress.kubernetes.io/proxy-headers" + annotationKubernetesSSLTemporaryRedirect = "ingress.kubernetes.io/ssl-temporary-redirect" + annotationKubernetesSSLHost = "ingress.kubernetes.io/ssl-host" + annotationKubernetesSSLProxyHeaders = "ingress.kubernetes.io/ssl-proxy-headers" + annotationKubernetesHSTSPreload = "ingress.kubernetes.io/hsts-preload" + annotationKubernetesForceHSTSHeader = "ingress.kubernetes.io/force-hsts" + annotationKubernetesFrameDeny = "ingress.kubernetes.io/frame-deny" + annotationKubernetesCustomFrameOptionsValue = "ingress.kubernetes.io/custom-frame-options-value" + annotationKubernetesContentTypeNosniff = "ingress.kubernetes.io/content-type-nosniff" + annotationKubernetesBrowserXSSFilter = "ingress.kubernetes.io/browser-xss-filter" + annotationKubernetesContentSecurityPolicy = "ingress.kubernetes.io/content-security-policy" + annotationKubernetesPublicKey = "ingress.kubernetes.io/public-key" + annotationKubernetesReferrerPolicy = "ingress.kubernetes.io/referrer-policy" + annotationKubernetesIsDevelopment = "ingress.kubernetes.io/is-development" ) const traefikDefaultRealm = "traefik" @@ -53,7 +73,7 @@ type Provider struct { lastConfiguration safe.Safe } -func (p *Provider) newK8sClient() (Client, error) { +func (p Provider) newK8sClient() (Client, error) { withEndpoint := "" if p.Endpoint != "" { withEndpoint = fmt.Sprintf(" with endpoint %v", p.Endpoint) @@ -166,18 +186,18 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) } } - passHostHeader := getAnnotationPassHostHeader(i, p) - passTLSCert := getAnnotationPassTLSCert(i, p) + passHostHeader := getBoolAnnotation(i, types.LabelFrontendPassHostHeader, !p.DisablePassHostHeaders) + passTLSCert := getBoolAnnotation(i, types.LabelFrontendPassTLSCert, p.EnablePassTLSCert) + if realm := i.Annotations[annotationKubernetesAuthRealm]; realm != "" && realm != traefikDefaultRealm { log.Errorf("Value for annotation %q on ingress %s/%s invalid: no realm customization supported", annotationKubernetesAuthRealm, i.ObjectMeta.Namespace, i.ObjectMeta.Name) delete(templateObjects.Backends, r.Host+pa.Path) continue } - entryPoints := getEntrypoints(i) + entryPoints := getSliceAnnotation(i, types.LabelFrontendEntryPoints) - whitelistSourceRangeAnnotation := i.Annotations[annotationKubernetesWhitelistSourceRange] - whitelistSourceRange := provider.SplitAndTrimString(whitelistSourceRangeAnnotation) + whitelistSourceRange := getSliceAnnotation(i, annotationKubernetesWhitelistSourceRange) entryPointRedirect, _ := i.Annotations[types.LabelFrontendRedirect] @@ -188,7 +208,30 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) continue } - priority := p.getPriority(pa, i) + priority := getPriority(i) + + headers := types.Headers{ + CustomRequestHeaders: getMapAnnotation(i, annotationKubernetesCustomRequestHeaders), + CustomResponseHeaders: getMapAnnotation(i, annotationKubernetesCustomResponseHeaders), + AllowedHosts: getSliceAnnotation(i, annotationKubernetesAllowedHosts), + HostsProxyHeaders: getSliceAnnotation(i, annotationKubernetesProxyHeaders), + SSLRedirect: getBoolAnnotation(i, annotationKubernetesSSLRedirect, false), + SSLTemporaryRedirect: getBoolAnnotation(i, annotationKubernetesSSLTemporaryRedirect, false), + SSLHost: getStringAnnotation(i, annotationKubernetesSSLHost), + SSLProxyHeaders: getMapAnnotation(i, annotationKubernetesSSLProxyHeaders), + STSSeconds: getSTSSeconds(i), + STSIncludeSubdomains: getBoolAnnotation(i, annotationKubernetesHSTSIncludeSubdomains, false), + STSPreload: getBoolAnnotation(i, annotationKubernetesHSTSPreload, false), + ForceSTSHeader: getBoolAnnotation(i, annotationKubernetesForceHSTSHeader, false), + FrameDeny: getBoolAnnotation(i, annotationKubernetesFrameDeny, false), + CustomFrameOptionsValue: getStringAnnotation(i, annotationKubernetesCustomFrameOptionsValue), + ContentTypeNosniff: getBoolAnnotation(i, annotationKubernetesContentTypeNosniff, false), + BrowserXSSFilter: getBoolAnnotation(i, annotationKubernetesBrowserXSSFilter, false), + ContentSecurityPolicy: getStringAnnotation(i, annotationKubernetesContentSecurityPolicy), + PublicKey: getStringAnnotation(i, annotationKubernetesPublicKey), + ReferrerPolicy: getStringAnnotation(i, annotationKubernetesReferrerPolicy), + IsDevelopment: getBoolAnnotation(i, annotationKubernetesIsDevelopment, false), + } templateObjects.Frontends[r.Host+pa.Path] = &types.Frontend{ Backend: r.Host + pa.Path, @@ -200,6 +243,7 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) WhitelistSourceRange: whitelistSourceRange, Redirect: entryPointRedirect, EntryPoints: entryPoints, + Headers: headers, } } if len(r.Host) > 0 { @@ -312,37 +356,21 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) return &templateObjects, nil } -func getEntrypoints(i *v1beta1.Ingress) []string { - entrypointsAnnotation, ok := i.Annotations[types.LabelFrontendEntryPoints] - if ok { - return strings.Split(entrypointsAnnotation, ",") +func (p Provider) loadConfig(templateObjects types.Configuration) *types.Configuration { + var FuncMap = template.FuncMap{} + configuration, err := p.GetConfiguration("templates/kubernetes.tmpl", FuncMap, templateObjects) + if err != nil { + log.Error(err) } - return nil - + return configuration } -func getBoolAnnotation(meta v1.ObjectMeta, name string, defaultValue bool) bool { - annotationValue := defaultValue - annotationStringValue, ok := meta.Annotations[name] - switch { - case !ok: - // No op. - case annotationStringValue == "false": - annotationValue = false - case annotationStringValue == "true": - annotationValue = true - default: - log.Warnf("Unknown value '%s' for %s, falling back to %s", name, types.LabelFrontendPassTLSCert, defaultValue) +func getSTSSeconds(i *v1beta1.Ingress) int64 { + value, err := strconv.ParseInt(i.ObjectMeta.Annotations[annotationKubernetesHSTSMaxAge], 10, 64) + if err == nil && value > 0 { + return value } - return annotationValue -} - -func getAnnotationPassHostHeader(i *v1beta1.Ingress, p *Provider) bool { - return getBoolAnnotation(i.ObjectMeta, types.LabelFrontendPassHostHeader, p.getPassHostHeader()) -} - -func getAnnotationPassTLSCert(i *v1beta1.Ingress, p *Provider) bool { - return getBoolAnnotation(i.ObjectMeta, types.LabelFrontendPassTLSCert, p.getPassTLSCert()) + return 0 } func getRuleForPath(pa v1beta1.HTTPIngressPath, i *v1beta1.Ingress) string { @@ -364,7 +392,7 @@ func getRuleForPath(pa v1beta1.HTTPIngressPath, i *v1beta1.Ingress) string { return strings.Join(rules, ";") } -func (p *Provider) getPriority(path v1beta1.HTTPIngressPath, i *v1beta1.Ingress) int { +func getPriority(i *v1beta1.Ingress) int { priority := 0 priorityRaw, ok := i.Annotations[types.LabelFrontendPriority] @@ -464,20 +492,3 @@ func shouldProcessIngress(ingressClass string) bool { return false } } - -func (p *Provider) getPassHostHeader() bool { - return !p.DisablePassHostHeaders -} - -func (p *Provider) getPassTLSCert() bool { - return p.EnablePassTLSCert -} - -func (p *Provider) loadConfig(templateObjects types.Configuration) *types.Configuration { - var FuncMap = template.FuncMap{} - configuration, err := p.GetConfiguration("templates/kubernetes.tmpl", FuncMap, templateObjects) - if err != nil { - log.Error(err) - } - return configuration -} diff --git a/templates/kubernetes.tmpl b/templates/kubernetes.tmpl index fb5a1a334..7a671d323 100644 --- a/templates/kubernetes.tmpl +++ b/templates/kubernetes.tmpl @@ -32,6 +32,52 @@ whitelistSourceRange = [{{range $frontend.WhitelistSourceRange}} "{{.}}", {{end}}] + [frontends."{{$frontendName}}".headers] + SSLRedirect = {{$frontend.Headers.SSLRedirect}} + SSLTemporaryRedirect = {{$frontend.Headers.SSLTemporaryRedirect}} + SSLHost = "{{$frontend.Headers.SSLHost}}" + STSSeconds = {{$frontend.Headers.STSSeconds}} + STSIncludeSubdomains = {{$frontend.Headers.STSIncludeSubdomains}} + STSPreload = {{$frontend.Headers.STSPreload}} + ForceSTSHeader = {{$frontend.Headers.ForceSTSHeader}} + FrameDeny = {{$frontend.Headers.FrameDeny}} + CustomFrameOptionsValue = "{{$frontend.Headers.CustomFrameOptionsValue}}" + ContentTypeNosniff = {{$frontend.Headers.ContentTypeNosniff}} + BrowserXSSFilter = {{$frontend.Headers.BrowserXSSFilter}} + ContentSecurityPolicy = "{{$frontend.Headers.ContentSecurityPolicy}}" + PublicKey = "{{$frontend.Headers.PublicKey}}" + ReferrerPolicy = "{{$frontend.Headers.ReferrerPolicy}}" + IsDevelopment = {{$frontend.Headers.IsDevelopment}} +{{if $frontend.Headers.CustomRequestHeaders}} + [frontends."{{$frontendName}}".headers.customrequestheaders] + {{range $k, $v := $frontend.Headers.CustomRequestHeaders}} + {{$k}} = "{{$v}}" + {{end}} +{{end}} +{{if $frontend.Headers.CustomResponseHeaders}} + [frontends."{{$frontendName}}".headers.customresponseheaders] + {{range $k, $v := $frontend.Headers.CustomResponseHeaders}} + {{$k}} = "{{$v}}" + {{end}} +{{end}} +{{if $frontend.Headers.AllowedHosts}} + [frontends."{{$frontendName}}".headers.AllowedHosts] + {{range $frontend.Headers.AllowedHosts}} + "{{.}}" + {{end}} +{{end}} +{{if $frontend.Headers.HostsProxyHeaders}} + [frontends."{{$frontendName}}".headers.HostsProxyHeaders] + {{range $frontend.Headers.HostsProxyHeaders}} + "{{.}}" + {{end}} +{{end}} +{{if $frontend.Headers.SSLProxyHeaders}} + [frontends."{{$frontendName}}".headers.SSLProxyHeaders] + {{range $k, $v := $frontend.Headers.SSLProxyHeaders}} + {{$k}} = "{{$v}}" + {{end}} +{{end}} {{range $routeName, $route := $frontend.Routes}} [frontends."{{$frontendName}}".routes."{{$routeName}}"] rule = "{{$route.Rule}}"