diff --git a/docs/content/middlewares/headers.md b/docs/content/middlewares/headers.md index 3fdc4930a..c7657c86c 100644 --- a/docs/content/middlewares/headers.md +++ b/docs/content/middlewares/headers.md @@ -14,11 +14,22 @@ The Headers middleware can manage the requests/responses headers. Add the `X-Script-Name` header to the proxied request and the `X-Custom-Response-Header` to the response ```yaml tab="Docker" -a-container: -image: a-container-image labels: -- "traefik.http.middlewares.testHeader.Headers.CustomRequestHeaders.X-Script-Name=test", -- "traefik.http.middlewares.testHeader.Headers.CustomResponseHeaders.X-Custom-Response-Header=True", +- "traefik.http.middlewares.testHeader.Headers.CustomRequestHeaders.X-Script-Name=test" +- "traefik.http.middlewares.testHeader.Headers.CustomResponseHeaders.X-Custom-Response-Header=True" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: testHeader +spec: + headers: + CustomRequestHeaders: + X-Script-Name: "test" + CustomResponseHeaders: + X-Custom-Response-Header: "True" ``` ```toml tab="File" @@ -34,51 +45,101 @@ labels: `X-Script-Name` header added to the proxied request, the `X-Custom-Request-Header` header removed from the request, and the `X-Custom-Response-Header` header removed from the response. -??? example "File" +```yaml tab="Docker" +labels: + - "traefik.http.middlewares.testHeader.Headers.CustomRequestHeaders.X-Script-Name=test" + - "traefik.http.middlewares.testHeader.Headers.CustomResponseHeaders.X-Custom-Response-Header=True" +``` - ```toml - [http.middlewares] - [http.middlewares.testHeader.headers] - [http.middlewares.testHeader.headers.CustomRequestHeaders] - X-Script-Name = "test" - [http.middlewares.testHeader.headers.CustomResponseHeaders] - X-Custom-Response-Header = "True" - ``` +```yaml tab="Kubernetes" +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: testHeader +spec: + headers: + CustomRequestHeaders: + X-Script-Name: "test" + CustomResponseHeaders: + X-Custom-Response-Header: "True" +``` -??? example "Docker" - - ```yml - a-container: - image: a-container-image - labels: - - "traefik.http.middlewares.testHeader.Headers.CustomRequestHeaders.X-Script-Name=test", - - "traefik.http.middlewares.testHeader.Headers.CustomResponseHeaders.X-Custom-Response-Header=True", - ``` +```toml tab="File" +[http.middlewares] + [http.middlewares.testHeader.headers] + [http.middlewares.testHeader.headers.CustomRequestHeaders] + X-Script-Name = "test" + [http.middlewares.testHeader.headers.CustomResponseHeaders] + X-Custom-Response-Header = "True" +``` ### Using Security Headers Security related headers (HSTS headers, SSL redirection, Browser XSS filter, etc) can be added and configured per frontend in a similar manner to the custom headers above. This functionality allows for some easy security features to quickly be set. -??? example "File" +```yaml tab="Docker" +labels: + - "traefik.http.middlewares.testHeader.Headers.FrameDeny=true" + - "traefik.http.middlewares.testHeader.Headers.SSLRedirect=true" +``` - ```toml - [http.middlewares] - [http.middlewares.testHeader.headers] - FrameDeny = true - SSLRedirect = true - ``` +```yaml tab="Kubernetes" +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: testHeader +spec: + headers: + FrameDeny: "true" + SSLRedirect: "true" +``` -??? example "Docker" +```toml tab="File" +[http.middlewares] + [http.middlewares.testHeader.headers] + FrameDeny = true + SSLRedirect = true +``` + +### CORS Headers + +CORS (Cross-Origin Resource Sharing) headers can be added and configured per frontend in a similar manner to the custom headers above. +This functionality allows for more advanced security features to quickly be set. + +```yaml tab="Docker" +labels: + - "traefik.http.middlewares.testHeader.Headers.AccessControlAllowMethods=GET,OPTIONS,PUT" + - "traefik.http.middlewares.testHeader.Headers.AccessControlAllowOrigin=origin-list-or-null" + - "traefik.http.middlewares.testHeader.Headers.AccessControlMaxAge=100" + - "traefik.http.middlewares.testHeader.Headers.AddVaryHeader=true" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: testHeader +spec: + headers: + AccessControlAllowMethods: + - "GET" + - "OPTIONS" + - "PUT" + AccessControlAllowOrigin: "origin-list-or-null" + AccessControlMaxAge: 100 + AddVaryHeader: "true" +``` + +```toml tab="File" +[http.middlewares] + [http.middlewares.testHeader.headers] + AccessControlAllowMethods= ["GET", "OPTIONS", "PUT"] + AccessControlAllowOrigin = "origin-list-or-null" + AccessControlMaxAge = 100 + AddVaryHeader = true +``` - ```yml - a-container: - image: a-container-image - labels: - - "traefik.http.middlewares.testHeader.Headers.FrameDeny=true", - - "traefik.http.middlewares.testHeader.Headers.SSLRedirect=true", - ``` - ## Configuration Options ### General @@ -93,6 +154,42 @@ This functionality allows for some easy security features to quickly be set. The `customRequestHeaders` option lists the Header names and values to apply to the request. +### customResponseHeaders + +The `customResponseHeaders` option lists the Header names and values to apply to the response. + +### accessControlAllowCredentials + +The `accessControlAllowCredentials` indicates whether the request can include user credentials. + +### accessControlAllowHeaders + +The `accessControlAllowHeaders` indicates which header field names can be used as part of the request. + +### accessControlAllowMethods + +The `accessControlAllowMethods` indicates which methods can be used during requests. + +### accessControlAllowOrigin + +The `accessControlAllowOrigin` indicates whether a resource can be shared by returning different values. The three options for this value are: + +- `origin-list-or-null` +- `*` +- `null` + +### accessControlExposeHeaders + +The `accessControlExposeHeaders` indicates which headers are safe to expose to the api of a CORS API specification. + +### accessControlMaxAge + +The `accessControlMaxAge` indicates how long a preflight request can be cached. + +### addVaryHeader + +The `addVaryHeader` is used in conjunction with `accessControlAllowOrigin` to determine whether the vary header should be added or modified to demonstrate that server responses can differ beased on the value of the origin header. + ### allowedHosts The `allowedHosts` option lists fully qualified domain names that are allowed. diff --git a/docs/content/reference/providers/file.md b/docs/content/reference/providers/file.md index 7c35e2754..78242ca07 100644 --- a/docs/content/reference/providers/file.md +++ b/docs/content/reference/providers/file.md @@ -70,6 +70,13 @@ excludedIPs = ["127.0.0.1/16", "192.168.1.7"] [http.middlewares.my-headers.Headers] + accessControlAllowCredentials = true + accessControlAllowHeaders = ["X-foobar", "X-fiibar"] + accessControlAllowMethods = ["GET", "PUT"] + accessControlAllowOrigin = "*" + accessControlExposeHeaders = ["X-foobar", "X-fiibar"] + accessControlMaxAge = 200 + addVaryHeader = true allowedHosts = ["foobar", "foobar"] hostsProxyHeaders = ["foobar", "foobar"] sslRedirect = true diff --git a/integration/fixtures/headers/basic.toml b/integration/fixtures/headers/basic.toml new file mode 100644 index 000000000..315d8b24b --- /dev/null +++ b/integration/fixtures/headers/basic.toml @@ -0,0 +1,21 @@ + +[entrypoints] + [entrypoints.web] + address = ":8000" + +[log] +logLevel = "DEBUG" + +[providers] + [providers.file] + +[http.routers] + [http.routers.router1] + rule = "Host(`test.localhost`)" + service = "service1" + +[http.services] + [http.services.service1.loadbalancer] + [[http.services.service1.loadbalancer.servers]] + url = "http://172.17.0.2:80" + weight = 1 diff --git a/integration/fixtures/headers/cors.toml b/integration/fixtures/headers/cors.toml new file mode 100644 index 000000000..091b207a5 --- /dev/null +++ b/integration/fixtures/headers/cors.toml @@ -0,0 +1,28 @@ + +[entrypoints] + [entrypoints.web] + address = ":8000" + +[log] +logLevel = "DEBUG" + +[providers] + [providers.file] + +[http.routers] + [http.routers.router1] + rule = "Host(`test.localhost`)" + service = "service1" + +[http.middlewares] + [http.middlewares.cors.Headers] + AccessControlAllowMethods= ["GET", "OPTIONS", "PUT"] + AccessControlAllowOrigin = "origin-list-or-null" + AccessControlMaxAge = 100 + AddVaryHeader = true + +[http.services] + [http.services.service1.loadbalancer] + [[http.services.service1.loadbalancer.servers]] + url = "http://172.17.0.2:80" + weight = 1 diff --git a/integration/headers_test.go b/integration/headers_test.go new file mode 100644 index 000000000..8ddc4f7a4 --- /dev/null +++ b/integration/headers_test.go @@ -0,0 +1,106 @@ +package integration + +import ( + "net/http" + "time" + + "github.com/containous/traefik/integration/try" + "github.com/go-check/check" + checker "github.com/vdemeester/shakers" +) + +// Headers test suites +type HeadersSuite struct{ BaseSuite } + +func (s *HeadersSuite) SetUpSuite(c *check.C) { + s.createComposeProject(c, "headers") + + s.composeProject.Start(c) +} + +func (s *HeadersSuite) TestSimpleConfiguration(c *check.C) { + cmd, display := s.traefikCmd(withConfigFile("fixtures/headers/basic.toml")) + defer display(c) + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + // Expected a 404 as we did not configure anything + err = try.GetRequest("http://127.0.0.1:8000/", 1000*time.Millisecond, try.StatusCodeIs(http.StatusNotFound)) + c.Assert(err, checker.IsNil) +} + +func (s *HeadersSuite) TestCorsResponses(c *check.C) { + cmd, display := s.traefikCmd(withConfigFile("fixtures/headers/cors.toml")) + defer display(c) + + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + testCase := []struct { + desc string + requestHeaders http.Header + expected http.Header + }{ + { + desc: "simple access control allow origin", + requestHeaders: http.Header{ + "Origin": {"https://foo.bar.org"}, + }, + expected: http.Header{ + "Access-Control-Allow-Origin": {"https://foo.bar.org"}, + "Vary": {"Origin"}, + }, + }, + } + + for _, test := range testCase { + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.localhost" + req.Header = test.requestHeaders + + err = try.Request(req, 500*time.Millisecond, try.HasBody(), try.HasHeaderStruct(test.expected)) + c.Assert(err, checker.IsNil) + } +} + +func (s *HeadersSuite) TestCorsPreflightResponses(c *check.C) { + cmd, display := s.traefikCmd(withConfigFile("fixtures/headers/cors.toml")) + defer display(c) + + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + testCase := []struct { + desc string + requestHeaders http.Header + expected http.Header + }{ + { + desc: "simple preflight request", + requestHeaders: http.Header{ + "Access-Control-Request-Headers": {"origin"}, + "Access-Control-Request-Method": {"GET", "OPTIONS"}, + "Origin": {"https://foo.bar.org"}, + }, + expected: http.Header{ + "Access-Control-Allow-Origin": {"https://foo.bar.org"}, + "Access-Control-Max-Age": {"100"}, + "Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"}, + }, + }, + } + + for _, test := range testCase { + req, err := http.NewRequest(http.MethodOptions, "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.localhost" + req.Header = test.requestHeaders + + err = try.Request(req, 500*time.Millisecond, try.HasBody(), try.HasHeaderStruct(test.expected)) + c.Assert(err, checker.IsNil) + } +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 4ec4c80c2..9429a092f 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -45,6 +45,7 @@ func init() { check.Suite(&FileSuite{}) check.Suite(&GRPCSuite{}) check.Suite(&HealthCheckSuite{}) + check.Suite(&HeadersSuite{}) check.Suite(&HostResolverSuite{}) check.Suite(&HTTPSSuite{}) check.Suite(&LogRotationSuite{}) diff --git a/integration/resources/compose/headers.yml b/integration/resources/compose/headers.yml new file mode 100644 index 000000000..fba4da55d --- /dev/null +++ b/integration/resources/compose/headers.yml @@ -0,0 +1,4 @@ +whoami1: + image: containous/whoami + ports: + - "8881:80" diff --git a/integration/try/condition.go b/integration/try/condition.go index 537859d53..ce3db4ca2 100644 --- a/integration/try/condition.go +++ b/integration/try/condition.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "net/http" + "reflect" "strings" "github.com/abronan/valkeyrie/store" @@ -125,6 +126,62 @@ func StatusCodeIs(status int) ResponseCondition { } } +// HasHeader returns a retry condition function. +// The condition returns an error if the response does not have a header set. +func HasHeader(header string) ResponseCondition { + return func(res *http.Response) error { + if _, ok := res.Header[header]; !ok { + return errors.New("response doesn't contain header: " + header) + } + return nil + } +} + +// HasHeaderValue returns a retry condition function. +// The condition returns an error if the response does not have a header set, and a value for that header. +// Has an option to test for an exact header match only, not just contains. +func HasHeaderValue(header, value string, exactMatch bool) ResponseCondition { + return func(res *http.Response) error { + if _, ok := res.Header[header]; !ok { + return errors.New("response doesn't contain header: " + header) + } + + matchFound := false + for _, hdr := range res.Header[header] { + if value != hdr && exactMatch { + return fmt.Errorf("got header %s with value %s, wanted %s", header, hdr, value) + } + if value == hdr { + matchFound = true + } + } + + if !matchFound { + return fmt.Errorf("response doesn't contain header %s with value %s", header, value) + } + return nil + } +} + +// HasHeaderStruct returns a retry condition function. +// The condition returns an error if the response does contain the headers set, and matching contents. +func HasHeaderStruct(header http.Header) ResponseCondition { + return func(res *http.Response) error { + for key := range header { + if _, ok := res.Header[key]; ok { + // Header exists in the response, test it. + eq := reflect.DeepEqual(header[key], res.Header[key]) + if !eq { + return fmt.Errorf("for header %s got values %v, wanted %v", key, res.Header[key], header[key]) + } + + } + } + return nil + } + +} + // DoCondition is a retry condition function. // It returns an error type DoCondition func() error diff --git a/pkg/config/middlewares.go b/pkg/config/middlewares.go index 8fe51e764..a76b5b744 100644 --- a/pkg/config/middlewares.go +++ b/pkg/config/middlewares.go @@ -126,6 +126,21 @@ type Headers struct { CustomRequestHeaders map[string]string `json:"customRequestHeaders,omitempty"` CustomResponseHeaders map[string]string `json:"customResponseHeaders,omitempty"` + // AccessControlAllowCredentials is only valid if true. false is ignored. + AccessControlAllowCredentials bool `json:"AccessControlAllowCredentials,omitempty"` + // AccessControlAllowHeaders must be used in response to a preflight request with Access-Control-Request-Headers set. + AccessControlAllowHeaders []string `json:"AccessControlAllowHeaders,omitempty"` + // AccessControlAllowMethods must be used in response to a preflight request with Access-Control-Request-Method set. + AccessControlAllowMethods []string `json:"AccessControlAllowMethods,omitempty"` + // AccessControlAllowOrigin Can be "origin-list-or-null" or "*". From (https://www.w3.org/TR/cors/#access-control-allow-origin-response-header) + AccessControlAllowOrigin string `json:"AccessControlAllowOrigin,omitempty"` + // AccessControlExposeHeaders sets valid headers for the response. + AccessControlExposeHeaders []string `json:"AccessControlExposeHeaders,omitempty"` + // AccessControlMaxAge sets the time that a preflight request may be cached. + AccessControlMaxAge int64 `json:"AccessControlMaxAge,omitempty"` + // AddVaryHeader controls if the Vary header is automatically added/updated when the AccessControlAllowOrigin is set. + AddVaryHeader bool `json:"AddVaryHeader,omitempty"` + AllowedHosts []string `json:"allowedHosts,omitempty"` HostsProxyHeaders []string `json:"hostsProxyHeaders,omitempty"` SSLRedirect bool `json:"sslRedirect,omitempty"` @@ -154,6 +169,17 @@ func (h *Headers) HasCustomHeadersDefined() bool { len(h.CustomRequestHeaders) != 0) } +// HasCorsHeadersDefined checks to see if any of the cors header elements have been set +func (h *Headers) HasCorsHeadersDefined() bool { + return h != nil && (h.AccessControlAllowCredentials || + len(h.AccessControlAllowHeaders) != 0 || + len(h.AccessControlAllowMethods) != 0 || + h.AccessControlAllowOrigin != "" || + len(h.AccessControlExposeHeaders) != 0 || + h.AccessControlMaxAge != 0 || + h.AddVaryHeader) +} + // HasSecureHeadersDefined checks to see if any of the secure header elements have been set func (h *Headers) HasSecureHeadersDefined() bool { return h != nil && (len(h.AllowedHosts) != 0 || diff --git a/pkg/middlewares/headers/headers.go b/pkg/middlewares/headers/headers.go index fd5fd1c8f..44a64d0c0 100644 --- a/pkg/middlewares/headers/headers.go +++ b/pkg/middlewares/headers/headers.go @@ -5,6 +5,8 @@ import ( "context" "errors" "net/http" + "strconv" + "strings" "github.com/containous/traefik/pkg/config" "github.com/containous/traefik/pkg/middlewares" @@ -14,7 +16,8 @@ import ( ) const ( - typeName = "Headers" + typeName = "Headers" + originHeaderKey = "X-Request-Origin" ) type headers struct { @@ -28,22 +31,26 @@ func New(ctx context.Context, next http.Handler, config config.Headers, name str logger := middlewares.GetLogger(ctx, name, typeName) logger.Debug("Creating middleware") - if !config.HasSecureHeadersDefined() && !config.HasCustomHeadersDefined() { + hasSecureHeaders := config.HasSecureHeadersDefined() + hasCustomHeaders := config.HasCustomHeadersDefined() + hasCorsHeaders := config.HasCorsHeadersDefined() + + if !hasSecureHeaders && !hasCustomHeaders && !hasCorsHeaders { return nil, errors.New("headers configuration not valid") } var handler http.Handler nextHandler := next - if config.HasSecureHeadersDefined() { + if hasSecureHeaders { logger.Debug("Setting up secureHeaders from %v", config) handler = newSecure(next, config) nextHandler = handler } - if config.HasCustomHeadersDefined() { - logger.Debug("Setting up customHeaders from %v", config) - handler = newHeader(nextHandler, config) + if hasCustomHeaders || hasCorsHeaders { + logger.Debug("Setting up customHeaders/Cors from %v", config) + handler = NewHeader(nextHandler, config) } return &headers{ @@ -102,29 +109,67 @@ func (s secureHeader) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // Header is a middleware that helps setup a few basic security features. A single headerOptions struct can be // provided to configure which features should be enabled, and the ability to override a few of the default values. -type header struct { - next http.Handler - // If Custom request headers are set, these will be added to the request - customRequestHeaders map[string]string +type Header struct { + next http.Handler + headers *config.Headers } // NewHeader constructs a new header instance from supplied frontend header struct. -func newHeader(next http.Handler, headers config.Headers) *header { - return &header{ - next: next, - customRequestHeaders: headers.CustomRequestHeaders, +func NewHeader(next http.Handler, headers config.Headers) *Header { + return &Header{ + next: next, + headers: &headers, } } -func (s *header) ServeHTTP(rw http.ResponseWriter, req *http.Request) { +func (s *Header) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + reqAcMethod := req.Header.Get("Access-Control-Request-Method") + reqAcHeaders := req.Header.Get("Access-Control-Request-Headers") + originHeader := req.Header.Get("Origin") + + if reqAcMethod != "" && reqAcHeaders != "" && originHeader != "" && req.Method == http.MethodOptions { + // If the request is an OPTIONS request with an Access-Control-Request-Method header, and Access-Control-Request-Headers headers, + // and Origin headers, then it is a CORS preflight request, and we need to build a custom response: https://www.w3.org/TR/cors/#preflight-request + if s.headers.AccessControlAllowCredentials { + rw.Header().Set("Access-Control-Allow-Credentials", "true") + } + + allowHeaders := strings.Join(s.headers.AccessControlAllowHeaders, ",") + if allowHeaders != "" { + rw.Header().Set("Access-Control-Allow-Headers", allowHeaders) + } + + allowMethods := strings.Join(s.headers.AccessControlAllowMethods, ",") + if allowMethods != "" { + rw.Header().Set("Access-Control-Allow-Methods", allowMethods) + } + + allowOrigin := s.getAllowOrigin(originHeader) + + if allowOrigin != "" { + rw.Header().Set("Access-Control-Allow-Origin", allowOrigin) + } + + rw.Header().Set("Access-Control-Max-Age", strconv.Itoa(int(s.headers.AccessControlMaxAge))) + + return + } + + if len(originHeader) > 0 { + rw.Header().Set(originHeaderKey, originHeader) + } + s.modifyRequestHeaders(req) - s.next.ServeHTTP(rw, req) + // If there is a next, call it. + if s.next != nil { + s.next.ServeHTTP(rw, req) + } } // modifyRequestHeaders set or delete request headers. -func (s *header) modifyRequestHeaders(req *http.Request) { +func (s *Header) modifyRequestHeaders(req *http.Request) { // Loop through Custom request headers - for header, value := range s.customRequestHeaders { + for header, value := range s.headers.CustomRequestHeaders { if value == "" { req.Header.Del(header) } else { @@ -132,3 +177,57 @@ func (s *header) modifyRequestHeaders(req *http.Request) { } } } + +// ModifyResponseHeaders set or delete response headers +func (s *Header) ModifyResponseHeaders(res *http.Response) error { + // Loop through Custom response headers + for header, value := range s.headers.CustomResponseHeaders { + if value == "" { + res.Header.Del(header) + } else { + res.Header.Set(header, value) + } + } + originHeader := res.Header.Get(originHeaderKey) + allowOrigin := s.getAllowOrigin(originHeader) + // Delete the origin header key, since it is only used to pass data from the request for response handling + res.Header.Del(originHeaderKey) + if allowOrigin != "" { + res.Header.Set("Access-Control-Allow-Origin", allowOrigin) + + if s.headers.AddVaryHeader { + varyHeader := res.Header.Get("Vary") + if varyHeader != "" { + varyHeader += "," + } + varyHeader += "Origin" + + res.Header.Set("Vary", varyHeader) + } + } + + if s.headers.AccessControlAllowCredentials { + res.Header.Set("Access-Control-Allow-Credentials", "true") + } + + exposeHeaders := strings.Join(s.headers.AccessControlExposeHeaders, ",") + if exposeHeaders != "" { + res.Header.Set("Access-Control-Expose-Headers", exposeHeaders) + } + + return nil +} + +func (s *Header) getAllowOrigin(header string) string { + switch s.headers.AccessControlAllowOrigin { + case "origin-list-or-null": + if len(header) == 0 { + return "null" + } + return header + case "*": + return "*" + default: + return "" + } +} diff --git a/pkg/middlewares/headers/headers_test.go b/pkg/middlewares/headers/headers_test.go index 835e56dd6..5c50452ce 100644 --- a/pkg/middlewares/headers/headers_test.go +++ b/pkg/middlewares/headers/headers_test.go @@ -10,6 +10,7 @@ import ( "github.com/containous/traefik/pkg/config" "github.com/containous/traefik/pkg/testhelpers" + "github.com/containous/traefik/pkg/tracing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +18,7 @@ import ( func TestCustomRequestHeader(t *testing.T) { emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - header := newHeader(emptyHandler, config.Headers{ + header := NewHeader(emptyHandler, config.Headers{ CustomRequestHeaders: map[string]string{ "X-Custom-Request-Header": "test_request", }, @@ -35,7 +36,7 @@ func TestCustomRequestHeader(t *testing.T) { func TestCustomRequestHeaderEmptyValue(t *testing.T) { emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - header := newHeader(emptyHandler, config.Headers{ + header := NewHeader(emptyHandler, config.Headers{ CustomRequestHeaders: map[string]string{ "X-Custom-Request-Header": "test_request", }, @@ -49,7 +50,7 @@ func TestCustomRequestHeaderEmptyValue(t *testing.T) { assert.Equal(t, http.StatusOK, res.Code) assert.Equal(t, "test_request", req.Header.Get("X-Custom-Request-Header")) - header = newHeader(emptyHandler, config.Headers{ + header = NewHeader(emptyHandler, config.Headers{ CustomRequestHeaders: map[string]string{ "X-Custom-Request-Header": "", }, @@ -188,3 +189,312 @@ func TestSSLForceHost(t *testing.T) { }) } } + +func TestCORSPreflights(t *testing.T) { + emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + testCases := []struct { + desc string + header *Header + requestHeaders http.Header + expected http.Header + }{ + { + desc: "Test Simple Preflight", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"}, + AccessControlAllowOrigin: "origin-list-or-null", + AccessControlMaxAge: 600, + }), + requestHeaders: map[string][]string{ + "Access-Control-Request-Headers": {"origin"}, + "Access-Control-Request-Method": {"GET", "OPTIONS"}, + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"https://foo.bar.org"}, + "Access-Control-Max-Age": {"600"}, + "Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"}, + }, + }, + { + desc: "Wildcard origin Preflight", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"}, + AccessControlAllowOrigin: "*", + AccessControlMaxAge: 600, + }), + requestHeaders: map[string][]string{ + "Access-Control-Request-Headers": {"origin"}, + "Access-Control-Request-Method": {"GET", "OPTIONS"}, + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"*"}, + "Access-Control-Max-Age": {"600"}, + "Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"}, + }, + }, + { + desc: "Allow Credentials Preflight", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"}, + AccessControlAllowOrigin: "*", + AccessControlAllowCredentials: true, + AccessControlMaxAge: 600, + }), + requestHeaders: map[string][]string{ + "Access-Control-Request-Headers": {"origin"}, + "Access-Control-Request-Method": {"GET", "OPTIONS"}, + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"*"}, + "Access-Control-Max-Age": {"600"}, + "Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"}, + "Access-Control-Allow-Credentials": {"true"}, + }, + }, + { + desc: "Allow Headers Preflight", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"}, + AccessControlAllowOrigin: "*", + AccessControlAllowHeaders: []string{"origin", "X-Forwarded-For"}, + AccessControlMaxAge: 600, + }), + requestHeaders: map[string][]string{ + "Access-Control-Request-Headers": {"origin"}, + "Access-Control-Request-Method": {"GET", "OPTIONS"}, + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"*"}, + "Access-Control-Max-Age": {"600"}, + "Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"}, + "Access-Control-Allow-Headers": {"origin,X-Forwarded-For"}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + req := testhelpers.MustNewRequest(http.MethodOptions, "/foo", nil) + req.Header = test.requestHeaders + + rw := httptest.NewRecorder() + test.header.ServeHTTP(rw, req) + + assert.Equal(t, test.expected, rw.Result().Header) + }) + } +} + +func TestEmptyHeaderObject(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + _, err := New(context.Background(), next, config.Headers{}, "testing") + require.Errorf(t, err, "headers configuration not valid") +} + +func TestCustomHeaderHandler(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + header, _ := New(context.Background(), next, config.Headers{ + CustomRequestHeaders: map[string]string{ + "X-Custom-Request-Header": "test_request", + }, + }, "testing") + + res := httptest.NewRecorder() + req := testhelpers.MustNewRequest(http.MethodGet, "/foo", nil) + + header.ServeHTTP(res, req) + + assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, "test_request", req.Header.Get("X-Custom-Request-Header")) +} + +func TestGetTracingInformation(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + header := &headers{ + handler: next, + name: "testing", + } + + name, trace := header.GetTracingInformation() + + assert.Equal(t, "testing", name) + assert.Equal(t, tracing.SpanKindNoneEnum, trace) + +} + +func TestCORSResponses(t *testing.T) { + emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + nonEmptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Vary", "Testing") }) + + testCases := []struct { + desc string + header *Header + requestHeaders http.Header + expected http.Header + }{ + { + desc: "Test Simple Request", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowOrigin: "origin-list-or-null", + }), + requestHeaders: map[string][]string{ + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"https://foo.bar.org"}, + }, + }, + { + desc: "Wildcard origin Request", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowOrigin: "*", + }), + requestHeaders: map[string][]string{ + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"*"}, + }, + }, + { + desc: "Empty origin Request", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowOrigin: "origin-list-or-null", + }), + requestHeaders: map[string][]string{}, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"null"}, + }, + }, + { + desc: "Not Defined origin Request", + header: NewHeader(emptyHandler, config.Headers{}), + requestHeaders: map[string][]string{}, + expected: map[string][]string{}, + }, + { + desc: "Allow Credentials Request", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowOrigin: "*", + AccessControlAllowCredentials: true, + }), + requestHeaders: map[string][]string{ + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"*"}, + "Access-Control-Allow-Credentials": {"true"}, + }, + }, + { + desc: "Expose Headers Request", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowOrigin: "*", + AccessControlExposeHeaders: []string{"origin", "X-Forwarded-For"}, + }), + requestHeaders: map[string][]string{ + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"*"}, + "Access-Control-Expose-Headers": {"origin,X-Forwarded-For"}, + }, + }, + { + desc: "Test Simple Request with Vary Headers", + header: NewHeader(emptyHandler, config.Headers{ + AccessControlAllowOrigin: "origin-list-or-null", + AddVaryHeader: true, + }), + requestHeaders: map[string][]string{ + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"https://foo.bar.org"}, + "Vary": {"Origin"}, + }, + }, + { + desc: "Test Simple Request with Vary Headers and non-empty response", + header: NewHeader(nonEmptyHandler, config.Headers{ + AccessControlAllowOrigin: "origin-list-or-null", + AddVaryHeader: true, + }), + requestHeaders: map[string][]string{ + "Origin": {"https://foo.bar.org"}, + }, + expected: map[string][]string{ + "Access-Control-Allow-Origin": {"https://foo.bar.org"}, + "Vary": {"Testing,Origin"}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + req := testhelpers.MustNewRequest(http.MethodGet, "/foo", nil) + req.Header = test.requestHeaders + + rw := httptest.NewRecorder() + test.header.ServeHTTP(rw, req) + err := test.header.ModifyResponseHeaders(rw.Result()) + require.NoError(t, err) + assert.Equal(t, test.expected, rw.Result().Header) + }) + } +} + +func TestCustomResponseHeaders(t *testing.T) { + emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + testCases := []struct { + desc string + header *Header + expected http.Header + }{ + { + desc: "Test Simple Response", + header: NewHeader(emptyHandler, config.Headers{ + CustomResponseHeaders: map[string]string{ + "Testing": "foo", + "Testing2": "bar", + }, + }), + expected: map[string][]string{ + "Testing": {"foo"}, + "Testing2": {"bar"}, + }, + }, + { + desc: "Deleting Custom Header", + header: NewHeader(emptyHandler, config.Headers{ + CustomResponseHeaders: map[string]string{ + "Testing": "foo", + "Testing2": "", + }, + }), + expected: map[string][]string{ + "Testing": {"foo"}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + req := testhelpers.MustNewRequest(http.MethodGet, "/foo", nil) + rw := httptest.NewRecorder() + test.header.ServeHTTP(rw, req) + err := test.header.ModifyResponseHeaders(rw.Result()) + require.NoError(t, err) + assert.Equal(t, test.expected, rw.Result().Header) + }) + } +} diff --git a/pkg/provider/label/parser_test.go b/pkg/provider/label/parser_test.go index ef2d7ee71..f781a6daf 100644 --- a/pkg/provider/label/parser_test.go +++ b/pkg/provider/label/parser_test.go @@ -42,7 +42,14 @@ func TestDecodeConfiguration(t *testing.T) { "traefik.http.middlewares.Middleware7.forwardauth.tls.insecureskipverify": "true", "traefik.http.middlewares.Middleware7.forwardauth.tls.key": "foobar", "traefik.http.middlewares.Middleware7.forwardauth.trustforwardheader": "true", + "traefik.http.middlewares.Middleware8.headers.accesscontrolallowcredentials": "true", "traefik.http.middlewares.Middleware8.headers.allowedhosts": "foobar, fiibar", + "traefik.http.middlewares.Middleware8.headers.accesscontrolallowheaders": "X-foobar, X-fiibar", + "traefik.http.middlewares.Middleware8.headers.accesscontrolallowmethods": "GET, PUT", + "traefik.http.middlewares.Middleware8.headers.accesscontrolalloworigin": "foobar", + "traefik.http.middlewares.Middleware8.headers.accesscontrolexposeheaders": "X-foobar, X-fiibar", + "traefik.http.middlewares.Middleware8.headers.accesscontrolmaxage": "200", + "traefik.http.middlewares.Middleware8.headers.addvaryheader": "true", "traefik.http.middlewares.Middleware8.headers.browserxssfilter": "true", "traefik.http.middlewares.Middleware8.headers.contentsecuritypolicy": "foobar", "traefik.http.middlewares.Middleware8.headers.contenttypenosniff": "true", @@ -377,6 +384,22 @@ func TestDecodeConfiguration(t *testing.T) { "name0": "foobar", "name1": "foobar", }, + AccessControlAllowCredentials: true, + AccessControlAllowHeaders: []string{ + "X-foobar", + "X-fiibar", + }, + AccessControlAllowMethods: []string{ + "GET", + "PUT", + }, + AccessControlAllowOrigin: "foobar", + AccessControlExposeHeaders: []string{ + "X-foobar", + "X-fiibar", + }, + AccessControlMaxAge: 200, + AddVaryHeader: true, AllowedHosts: []string{ "foobar", "fiibar", @@ -710,6 +733,22 @@ func TestEncodeConfiguration(t *testing.T) { "name0": "foobar", "name1": "foobar", }, + AccessControlAllowCredentials: true, + AccessControlAllowHeaders: []string{ + "X-foobar", + "X-fiibar", + }, + AccessControlAllowMethods: []string{ + "GET", + "PUT", + }, + AccessControlAllowOrigin: "foobar", + AccessControlExposeHeaders: []string{ + "X-foobar", + "X-fiibar", + }, + AccessControlMaxAge: 200, + AddVaryHeader: true, AllowedHosts: []string{ "foobar", "fiibar", @@ -854,6 +893,13 @@ func TestEncodeConfiguration(t *testing.T) { "traefik.HTTP.Middlewares.Middleware7.ForwardAuth.TLS.InsecureSkipVerify": "true", "traefik.HTTP.Middlewares.Middleware7.ForwardAuth.TLS.Key": "foobar", "traefik.HTTP.Middlewares.Middleware7.ForwardAuth.TrustForwardHeader": "true", + "traefik.HTTP.Middlewares.Middleware8.Headers.AccessControlAllowCredentials": "true", + "traefik.HTTP.Middlewares.Middleware8.Headers.AccessControlAllowHeaders": "X-foobar, X-fiibar", + "traefik.HTTP.Middlewares.Middleware8.Headers.AccessControlAllowMethods": "GET, PUT", + "traefik.HTTP.Middlewares.Middleware8.Headers.AccessControlAllowOrigin": "foobar", + "traefik.HTTP.Middlewares.Middleware8.Headers.AccessControlExposeHeaders": "X-foobar, X-fiibar", + "traefik.HTTP.Middlewares.Middleware8.Headers.AccessControlMaxAge": "200", + "traefik.HTTP.Middlewares.Middleware8.Headers.AddVaryHeader": "true", "traefik.HTTP.Middlewares.Middleware8.Headers.AllowedHosts": "foobar, fiibar", "traefik.HTTP.Middlewares.Middleware8.Headers.BrowserXSSFilter": "true", "traefik.HTTP.Middlewares.Middleware8.Headers.ContentSecurityPolicy": "foobar", diff --git a/pkg/responsemodifiers/headers.go b/pkg/responsemodifiers/headers.go index 5494e812d..4796b9be4 100644 --- a/pkg/responsemodifiers/headers.go +++ b/pkg/responsemodifiers/headers.go @@ -4,46 +4,43 @@ import ( "net/http" "github.com/containous/traefik/pkg/config" + "github.com/containous/traefik/pkg/middlewares/headers" "github.com/unrolled/secure" ) -func buildHeaders(headers *config.Headers) func(*http.Response) error { +func buildHeaders(hdrs *config.Headers) func(*http.Response) error { opt := secure.Options{ - BrowserXssFilter: headers.BrowserXSSFilter, - ContentTypeNosniff: headers.ContentTypeNosniff, - ForceSTSHeader: headers.ForceSTSHeader, - FrameDeny: headers.FrameDeny, - IsDevelopment: headers.IsDevelopment, - SSLRedirect: headers.SSLRedirect, - SSLForceHost: headers.SSLForceHost, - SSLTemporaryRedirect: headers.SSLTemporaryRedirect, - STSIncludeSubdomains: headers.STSIncludeSubdomains, - STSPreload: headers.STSPreload, - ContentSecurityPolicy: headers.ContentSecurityPolicy, - CustomBrowserXssValue: headers.CustomBrowserXSSValue, - CustomFrameOptionsValue: headers.CustomFrameOptionsValue, - PublicKey: headers.PublicKey, - ReferrerPolicy: headers.ReferrerPolicy, - SSLHost: headers.SSLHost, - AllowedHosts: headers.AllowedHosts, - HostsProxyHeaders: headers.HostsProxyHeaders, - SSLProxyHeaders: headers.SSLProxyHeaders, - STSSeconds: headers.STSSeconds, + BrowserXssFilter: hdrs.BrowserXSSFilter, + ContentTypeNosniff: hdrs.ContentTypeNosniff, + ForceSTSHeader: hdrs.ForceSTSHeader, + FrameDeny: hdrs.FrameDeny, + IsDevelopment: hdrs.IsDevelopment, + SSLRedirect: hdrs.SSLRedirect, + SSLForceHost: hdrs.SSLForceHost, + SSLTemporaryRedirect: hdrs.SSLTemporaryRedirect, + STSIncludeSubdomains: hdrs.STSIncludeSubdomains, + STSPreload: hdrs.STSPreload, + ContentSecurityPolicy: hdrs.ContentSecurityPolicy, + CustomBrowserXssValue: hdrs.CustomBrowserXSSValue, + CustomFrameOptionsValue: hdrs.CustomFrameOptionsValue, + PublicKey: hdrs.PublicKey, + ReferrerPolicy: hdrs.ReferrerPolicy, + SSLHost: hdrs.SSLHost, + AllowedHosts: hdrs.AllowedHosts, + HostsProxyHeaders: hdrs.HostsProxyHeaders, + SSLProxyHeaders: hdrs.SSLProxyHeaders, + STSSeconds: hdrs.STSSeconds, } return func(resp *http.Response) error { - if headers.HasCustomHeadersDefined() { - // Loop through Custom response headers - for header, value := range headers.CustomResponseHeaders { - if value == "" { - resp.Header.Del(header) - } else { - resp.Header.Set(header, value) - } + if hdrs.HasCustomHeadersDefined() || hdrs.HasCorsHeadersDefined() { + err := headers.NewHeader(nil, *hdrs).ModifyResponseHeaders(resp) + if err != nil { + return err } } - if headers.HasSecureHeadersDefined() { + if hdrs.HasSecureHeadersDefined() { err := secure.New(opt).ModifyResponseHeaders(resp) if err != nil { return err