diff --git a/provider/kv/kv_config.go b/provider/kv/kv_config.go index d1e73e438..a45885c59 100644 --- a/provider/kv/kv_config.go +++ b/provider/kv/kv_config.go @@ -2,6 +2,7 @@ package kv import ( "fmt" + "net/http" "sort" "strconv" "strings" @@ -38,6 +39,7 @@ func (p *Provider) buildConfiguration() *types.Configuration { "getRedirect": p.getRedirect, "getErrorPages": p.getErrorPages, "getRateLimit": p.getRateLimit, + "getHeaders": p.getHeaders, // Backend functions "getSticky": p.getSticky, @@ -160,6 +162,37 @@ func (p *Provider) getRateLimit(rootPath string) *types.RateLimit { } } +func (p *Provider) getHeaders(rootPath string) *types.Headers { + headers := &types.Headers{ + CustomRequestHeaders: p.getMap(rootPath, pathFrontendCustomRequestHeaders), + CustomResponseHeaders: p.getMap(rootPath, pathFrontendCustomResponseHeaders), + SSLProxyHeaders: p.getMap(rootPath, pathFrontendSSLProxyHeaders), + AllowedHosts: p.splitGet("", rootPath, pathFrontendAllowedHosts), + HostsProxyHeaders: p.splitGet(rootPath, pathFrontendHostsProxyHeaders), + SSLRedirect: p.getBool(false, rootPath, pathFrontendSSLRedirect), + SSLTemporaryRedirect: p.getBool(false, rootPath, pathFrontendSSLTemporaryRedirect), + SSLHost: p.get("", rootPath, pathFrontendSSLHost), + STSSeconds: p.getInt64(0, rootPath, pathFrontendSTSSeconds), + STSIncludeSubdomains: p.getBool(false, rootPath, pathFrontendSTSIncludeSubdomains), + STSPreload: p.getBool(false, rootPath, pathFrontendSTSPreload), + ForceSTSHeader: p.getBool(false, rootPath, pathFrontendForceSTSHeader), + FrameDeny: p.getBool(false, rootPath, pathFrontendFrameDeny), + CustomFrameOptionsValue: p.get("", rootPath, pathFrontendCustomFrameOptionsValue), + ContentTypeNosniff: p.getBool(false, rootPath, pathFrontendContentTypeNosniff), + BrowserXSSFilter: p.getBool(false, rootPath, pathFrontendBrowserXSSFilter), + ContentSecurityPolicy: p.get("", rootPath, pathFrontendContentSecurityPolicy), + PublicKey: p.get("", rootPath, pathFrontendPublicKey), + ReferrerPolicy: p.get("", rootPath, pathFrontendReferrerPolicy), + IsDevelopment: p.getBool(false, rootPath, pathFrontendIsDevelopment), + } + + if !headers.HasSecureHeadersDefined() && !headers.HasCustomHeadersDefined() { + return nil + } + + return headers +} + func (p *Provider) listServers(backend string) []string { serverNames := p.list(backend, pathBackendServers) return fun.Filter(p.serverFilter, serverNames).([]string) @@ -298,3 +331,18 @@ func (p *Provider) last(key string) string { index := strings.LastIndex(key, pathSeparator) return key[index+1:] } + +func (p *Provider) getMap(keyParts ...string) map[string]string { + var mapData map[string]string + + list := p.list(keyParts...) + for _, name := range list { + if mapData == nil { + mapData = make(map[string]string) + } + + mapData[http.CanonicalHeaderKey(p.last(name))] = p.get("", name) + } + + return mapData +} diff --git a/provider/kv/kv_config_test.go b/provider/kv/kv_config_test.go index 607c4afb5..a542367f0 100644 --- a/provider/kv/kv_config_test.go +++ b/provider/kv/kv_config_test.go @@ -655,6 +655,51 @@ func TestProviderGetInt64(t *testing.T) { } } +func TestProviderGetMap(t *testing.T) { + testCases := []struct { + desc string + keyParts []string + kvPairs []*store.KVPair + expected map[string]string + }{ + { + desc: "when several keys", + keyParts: []string{"traefik/frontends/foo", pathFrontendCustomRequestHeaders}, + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendCustomRequestHeaders+"Access-Control-Allow-Methods", "POST,GET,OPTIONS"), + withPair(pathFrontendCustomRequestHeaders+"Content-Type", "application/json; charset=utf-8"), + withPair(pathFrontendCustomRequestHeaders+"X-Custom-Header", "test"), + ), + ), + expected: map[string]string{ + "Access-Control-Allow-Methods": "POST,GET,OPTIONS", + "Content-Type": "application/json; charset=utf-8", + "X-Custom-Header": "test", + }, + }, + { + desc: "when no keys", + keyParts: []string{"traefik/frontends/foo", pathFrontendCustomRequestHeaders}, + kvPairs: filler("traefik", frontend("foo")), + expected: nil, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + p := newProviderMock(test.kvPairs) + + result := p.getMap(test.keyParts...) + + assert.EqualValues(t, test.expected, result) + }) + } +} + func TestProviderHasStickinessLabel(t *testing.T) { testCases := []struct { desc string @@ -882,3 +927,258 @@ func TestProviderGetRateLimit(t *testing.T) { }) } } + +func TestProviderGetHeaders(t *testing.T) { + testCases := []struct { + desc string + rootPath string + kvPairs []*store.KVPair + expected *types.Headers + }{ + { + desc: "Custom Request Headers", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendCustomRequestHeaders+"Access-Control-Allow-Methods", "POST,GET,OPTIONS"), + withPair(pathFrontendCustomRequestHeaders+"Content-Type", "application/json; charset=utf-8"), + withPair(pathFrontendCustomRequestHeaders+"X-Custom-Header", "test"))), + expected: &types.Headers{ + CustomRequestHeaders: map[string]string{ + "Access-Control-Allow-Methods": "POST,GET,OPTIONS", + "Content-Type": "application/json; charset=utf-8", + "X-Custom-Header": "test", + }, + }, + }, + { + desc: "Custom esponse Headers", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendCustomResponseHeaders+"Access-Control-Allow-Methods", "POST,GET,OPTIONS"), + withPair(pathFrontendCustomResponseHeaders+"Content-Type", "application/json; charset=utf-8"), + withPair(pathFrontendCustomResponseHeaders+"X-Custom-Header", "test"))), + expected: &types.Headers{ + CustomResponseHeaders: map[string]string{ + "Access-Control-Allow-Methods": "POST,GET,OPTIONS", + "Content-Type": "application/json; charset=utf-8", + "X-Custom-Header": "test", + }, + }, + }, + { + desc: "SSL Proxy Headers", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendSSLProxyHeaders+"Access-Control-Allow-Methods", "POST,GET,OPTIONS"), + withPair(pathFrontendSSLProxyHeaders+"Content-Type", "application/json; charset=utf-8"), + withPair(pathFrontendSSLProxyHeaders+"X-Custom-Header", "test"))), + expected: &types.Headers{ + SSLProxyHeaders: map[string]string{ + "Access-Control-Allow-Methods": "POST,GET,OPTIONS", + "Content-Type": "application/json; charset=utf-8", + "X-Custom-Header": "test", + }, + }, + }, + { + desc: "Allowed Hosts", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendAllowedHosts, "foo, bar, goo, hor"))), + expected: &types.Headers{ + AllowedHosts: []string{"foo", "bar", "goo", "hor"}, + }, + }, + { + desc: "Hosts Proxy Headers", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendHostsProxyHeaders, "foo, bar, goo, hor"))), + expected: &types.Headers{ + HostsProxyHeaders: []string{"foo", "bar", "goo", "hor"}, + }, + }, + { + desc: "SSL Redirect", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendSSLRedirect, "true"))), + expected: &types.Headers{ + SSLRedirect: true, + }, + }, + { + desc: "SSL Temporary Redirect", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendSSLTemporaryRedirect, "true"))), + expected: &types.Headers{ + SSLTemporaryRedirect: true, + }, + }, + { + desc: "SSL Host", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendSSLHost, "foo"))), + expected: &types.Headers{ + SSLHost: "foo", + }, + }, + { + desc: "STS Seconds", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendSTSSeconds, "666"))), + expected: &types.Headers{ + STSSeconds: 666, + }, + }, + { + desc: "STS Include Subdomains", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendSTSIncludeSubdomains, "true"))), + expected: &types.Headers{ + STSIncludeSubdomains: true, + }, + }, + { + desc: "STS Preload", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendSTSPreload, "true"))), + expected: &types.Headers{ + STSPreload: true, + }, + }, + { + desc: "Force STS Header", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendForceSTSHeader, "true"))), + expected: &types.Headers{ + ForceSTSHeader: true, + }, + }, + { + desc: "Frame Deny", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendFrameDeny, "true"))), + expected: &types.Headers{ + FrameDeny: true, + }, + }, + { + desc: "Custom Frame Options Value", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendCustomFrameOptionsValue, "foo"))), + expected: &types.Headers{ + CustomFrameOptionsValue: "foo", + }, + }, + { + desc: "Content Type Nosniff", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendContentTypeNosniff, "true"))), + expected: &types.Headers{ + ContentTypeNosniff: true, + }, + }, + { + desc: "Browser XSS Filter", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendBrowserXSSFilter, "true"))), + expected: &types.Headers{ + BrowserXSSFilter: true, + }, + }, + { + desc: "Content Security Policy", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendContentSecurityPolicy, "foo"))), + expected: &types.Headers{ + ContentSecurityPolicy: "foo", + }, + }, + { + desc: "Public Key", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendPublicKey, "foo"))), + expected: &types.Headers{ + PublicKey: "foo", + }, + }, + { + desc: "Referrer Policy", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendReferrerPolicy, "foo"))), + expected: &types.Headers{ + ReferrerPolicy: "foo", + }, + }, + { + desc: "Is Development", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendIsDevelopment, "true"))), + expected: &types.Headers{ + IsDevelopment: true, + }, + }, + { + desc: "should return nil when not significant configuration", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", + frontend("foo", + withPair(pathFrontendIsDevelopment, "false"))), + expected: nil, + }, + { + desc: "should return nil when no headers configuration", + rootPath: "traefik/frontends/foo", + kvPairs: filler("traefik", frontend("foo")), + expected: nil, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + p := newProviderMock(test.kvPairs) + + headers := p.getHeaders(test.rootPath) + + assert.Equal(t, test.expected, headers) + }) + } +} diff --git a/templates/kv.tmpl b/templates/kv.tmpl index eecb0b693..dbc38f6d4 100644 --- a/templates/kv.tmpl +++ b/templates/kv.tmpl @@ -104,6 +104,59 @@ {{end}} {{end}} + {{ $headers := getHeaders $frontend }} + {{ if $headers }} + [frontends."{{ $frontendName }}".headers] + SSLRedirect = {{ $headers.SSLRedirect }} + SSLTemporaryRedirect = {{ $headers.SSLTemporaryRedirect }} + SSLHost = "{{ $headers.SSLHost }}" + STSSeconds = {{ $headers.STSSeconds }} + STSIncludeSubdomains = {{ $headers.STSIncludeSubdomains }} + STSPreload = {{ $headers.STSPreload }} + ForceSTSHeader = {{ $headers.ForceSTSHeader }} + FrameDeny = {{ $headers.FrameDeny }} + CustomFrameOptionsValue = "{{ $headers.CustomFrameOptionsValue }}" + ContentTypeNosniff = {{ $headers.ContentTypeNosniff }} + BrowserXSSFilter = {{ $headers.BrowserXSSFilter }} + ContentSecurityPolicy = "{{ $headers.ContentSecurityPolicy }}" + PublicKey = "{{ $headers.PublicKey }}" + ReferrerPolicy = "{{ $headers.ReferrerPolicy }}" + IsDevelopment = {{ $headers.IsDevelopment }} + + {{ if $headers.AllowedHosts }} + AllowedHosts = [{{ range $headers.AllowedHosts }} + "{{.}}", + {{end}}] + {{end}} + + {{ if $headers.HostsProxyHeaders }} + HostsProxyHeaders = [{{ range $headers.HostsProxyHeaders }} + "{{.}}", + {{end}}] + {{end}} + + {{ if $headers.CustomRequestHeaders }} + [frontends."{{ $frontendName }}".headers.customRequestHeaders] + {{ range $k, $v := $headers.CustomRequestHeaders }} + {{$k}} = "{{$v}}" + {{end}} + {{end}} + + {{ if $headers.CustomResponseHeaders }} + [frontends."{{ $frontendName }}".headers.customResponseHeaders] + {{ range $k, $v := $headers.CustomResponseHeaders }} + {{$k}} = "{{$v}}" + {{end}} + {{end}} + + {{ if $headers.SSLProxyHeaders }} + [frontends."{{ $frontendName }}".headers.SSLProxyHeaders] + {{range $k, $v := $headers.SSLProxyHeaders}} + {{$k}} = "{{$v}}" + {{end}} + {{end}} + {{end}} + {{range $route := List $frontend "/routes/"}} [frontends."{{$frontendName}}".routes."{{Last $route}}"] rule = "{{Get "" $route "/rule"}}"