diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index 0551ddabe..ec114361a 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -25,6 +25,7 @@ import ( "github.com/containous/traefik/v2/pkg/provider/acme" "github.com/containous/traefik/v2/pkg/provider/aggregator" "github.com/containous/traefik/v2/pkg/provider/traefik" + "github.com/containous/traefik/v2/pkg/rules" "github.com/containous/traefik/v2/pkg/safe" "github.com/containous/traefik/v2/pkg/server" "github.com/containous/traefik/v2/pkg/server/middleware" @@ -161,6 +162,8 @@ func runCmd(staticConfiguration *static.Configuration) error { } func setupServer(staticConfiguration *static.Configuration) (*server.Server, error) { + rules.EnableDomainFronting(staticConfiguration.Global.InsecureSNI) + providerAggregator := aggregator.NewProviderAggregator(*staticConfiguration.Providers) // adds internal provider diff --git a/docs/content/https/tls.md b/docs/content/https/tls.md index 6804636c2..1ba56c62b 100644 --- a/docs/content/https/tls.md +++ b/docs/content/https/tls.md @@ -130,6 +130,20 @@ tls: If no default certificate is provided, Traefik generates and uses a self-signed certificate. +## Domain fronting + +Basically, [domain fronting](https://en.wikipedia.org/wiki/Domain_fronting) is a technique that allows to open a +connection with a specific domain name, thanks to the +[Server Name Indication](https://en.wikipedia.org/wiki/Server_Name_Indication), then access a service with another +domain set in the HTTP `Host` header. + +Since the `v2.2.2`, Traefik avoids (by default) using domain fronting. +As it is valid for advanced use cases, the `HostHeader` and `HostSNI` [rules](../routing/routers/index.md#rule) allow +to fine tune the routing with the `Server Name Indication` and `Host header` value. + +If you encounter routing issues with a previously working configuration, please refer to the +[migration guide](../migration/v2.md) to update your configuration. + ## TLS Options The TLS options allow one to configure some parameters of the TLS connection. @@ -317,7 +331,7 @@ spec: ### Strict SNI Checking With strict SNI checking, Traefik won't allow connections from clients connections -that do not specify a server_name extension. +that do not specify a server_name extension or don't match any certificate configured on the tlsOption. ```toml tab="File (TOML)" # Dynamic configuration diff --git a/docs/content/migration/v2.md b/docs/content/migration/v2.md index bc500eef6..678d3d2cf 100644 --- a/docs/content/migration/v2.md +++ b/docs/content/migration/v2.md @@ -1,5 +1,117 @@ # Migration: Steps needed between the versions +## v2.x to v2.2.2 + +### Domain fronting + +In `v2.2.2` we introduced the ability to avoid [Domain fronting](https://en.wikipedia.org/wiki/Domain_fronting), +and enabled it by default for [https routers](../routing/routers/index.md#rule) configured with ```Host(`something`)```. + +!!! example "Allow Domain Fronting on a Specific Router" + + !!! info "Before v2.2.2" + + ```yaml tab="Docker" + labels: + - "traefik.http.routers.router0.rule=Host(`test.localhost`)" + ``` + + ```yaml tab="K8s Ingress" + apiVersion: traefik.containo.us/v1alpha1 + kind: IngressRoute + metadata: + name: ingressroutebar + + spec: + entryPoints: + - http + routes: + - match: Host(`test.localhost`) + kind: Rule + services: + - name: server0 + port: 80 + - name: server1 + port: 80 + ``` + + ```toml tab="File (TOML)" + [http.routers.router0] + rule = "Host(`test.localhost`)" + service = "my-service" + ``` + + ```toml tab="File (YAML)" + http: + routers: + router0: + rule: "Host(`test.localhost`)" + service: my-service + ``` + + !!! info "v2.2.2" + + ```yaml tab="Docker" + labels: + - "traefik.http.routers.router0.rule=HostHeader(`test.localhost`)" + ``` + + ```yaml tab="K8s Ingress" + apiVersion: traefik.containo.us/v1alpha1 + kind: IngressRoute + metadata: + name: ingressroutebar + + spec: + entryPoints: + - http + routes: + - match: HostHeader(`test.localhost`) + kind: Rule + services: + - name: server0 + port: 80 + - name: server1 + port: 80 + ``` + + ```toml tab="File (TOML)" + [http.routers.router0] + rule = "HostHeader(`test.localhost`)" + service = "my-service" + ``` + + ```toml tab="File (YAML)" + http: + routers: + router0: + rule: "HostHeader(`test.localhost`)" + service: my-service + ``` + +As a fallback, a new flag is available as a global option: + +!!! example "Enabling Domain Fronting for All Routers" + + ```toml tab="File (TOML)" + # Static configuration + [global] + # Enabling domain fronting + insecureSNI = true + ``` + + ```yaml tab="File (YAML)" + # Static configuration + global: + # Enabling domain fronting + insecureSNI: true + ``` + + ```bash tab="CLI" + # Enabling domain fronting + --global.insecureSNI + ``` + ## v2.0 to v2.1 ### Kubernetes CRD diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index aed610b9a..83b266711 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -162,6 +162,9 @@ WriteTimeout is the maximum duration before timing out writes of the response. I `--global.checknewversion`: Periodically check if a new version has been released. (Default: ```false```) +`--global.insecuresni`: +Allow domain fronting. If the option is not specified, it will be disabled by default. (Default: ```false```) + `--global.sendanonymoususage`: Periodically send anonymous usage statistics. If the option is not specified, it will be enabled by default. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 2228bec10..f4fb6a80a 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -162,6 +162,9 @@ WriteTimeout is the maximum duration before timing out writes of the response. I `TRAEFIK_GLOBAL_CHECKNEWVERSION`: Periodically check if a new version has been released. (Default: ```false```) +`TRAEFIK_GLOBAL_INSECURESNI`: +Allow domain fronting. If the option is not specified, it will be disabled by default. (Default: ```false```) + `TRAEFIK_GLOBAL_SENDANONYMOUSUSAGE`: Periodically send anonymous usage statistics. If the option is not specified, it will be enabled by default. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index fce1dc4d3..c90dc95c5 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -1,6 +1,7 @@ [global] checkNewVersion = true sendAnonymousUsage = true + insecureSNI = false [serversTransport] insecureSkipVerify = true diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index a8ab3acda..85d0888f5 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -1,6 +1,8 @@ global: checkNewVersion: true sendAnonymousUsage: true + insecureSNI: false + serversTransport: insecureSkipVerify: true rootCAs: diff --git a/docs/content/routing/routers/index.md b/docs/content/routing/routers/index.md index bba559dc9..eb97bf151 100644 --- a/docs/content/routing/routers/index.md +++ b/docs/content/routing/routers/index.md @@ -228,16 +228,18 @@ If the rule is verified, the router becomes active, calls middlewares, and then The table below lists all the available matchers: -| Rule | Description | -|------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| -| ```Headers(`key`, `value`)``` | Check if there is a key `key`defined in the headers, with the value `value` | -| ```HeadersRegexp(`key`, `regexp`)``` | Check if there is a key `key`defined in the headers, with a value that matches the regular expression `regexp` | -| ```Host(`example.com`, ...)``` | Check if the request domain targets one of the given `domains`. | -| ```HostRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Check if the request domain matches the given `regexp`. | -| ```Method(`GET`, ...)``` | Check if the request method is one of the given `methods` (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) | -| ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. It accepts a sequence of literal and regular expression paths. | -| ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. It accepts a sequence of literal and regular expression prefix paths. | -| ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. | +| Rule | Description | +|------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ```Headers(`key`, `value`)``` | Check if there is a key `key`defined in the headers, with the value `value` | +| ```HeadersRegexp(`key`, `regexp`)``` | Check if there is a key `key`defined in the headers, with a value that matches the regular expression `regexp` | +| ```Host(`example.com`, ...)``` | By default, is equivalent to `HostHeader` **AND** `HostSNI` rules. See [Domain Fronting](../../https/tls.md#domain-fronting) and the [migration guide](../../migration/v2.md#domain-fronting) for more details. | +| ```HostHeader(`example.com`, ...)``` | Check if the request domain (host header value) targets one of the given `domains`. | +| ```HostSNI(`example.com`, ...)``` | Check if the [Server Name Indication](https://en.wikipedia.org/wiki/Server_Name_Indication) corresponds to the given `domains`. | +| ```HostRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Check if the request domain matches the given `regexp`. | +| ```Method(`GET`, ...)``` | Check if the request method is one of the given `methods` (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) | +| ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. It accepts a sequence of literal and regular expression paths. | +| ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. It accepts a sequence of literal and regular expression prefix paths. | +| ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. | !!! important "Regexp Syntax" diff --git a/integration/fixtures/grpc/config.toml b/integration/fixtures/grpc/config.toml index 9d448ae5b..c75bbebd4 100644 --- a/integration/fixtures/grpc/config.toml +++ b/integration/fixtures/grpc/config.toml @@ -22,7 +22,7 @@ [http.routers] [http.routers.router1] - rule = "Host(`127.0.0.1`)" + rule = "Host(`localhost`)" service = "service1" [http.routers.router1.tls] diff --git a/integration/fixtures/grpc/config_h2c_termination.toml b/integration/fixtures/grpc/config_h2c_termination.toml index a51bcc21b..f9ecc99d6 100644 --- a/integration/fixtures/grpc/config_h2c_termination.toml +++ b/integration/fixtures/grpc/config_h2c_termination.toml @@ -19,7 +19,7 @@ [http.routers] [http.routers.router1] - rule = "Host(`127.0.0.1`)" + rule = "Host(`localhost`)" service = "service1" [http.routers.router1.tls] diff --git a/integration/fixtures/grpc/config_insecure.toml b/integration/fixtures/grpc/config_insecure.toml index 264360ad5..ecd179e5a 100644 --- a/integration/fixtures/grpc/config_insecure.toml +++ b/integration/fixtures/grpc/config_insecure.toml @@ -22,7 +22,7 @@ [http.routers] [http.routers.router1] - rule = "Host(`127.0.0.1`)" + rule = "Host(`localhost`)" service = "service1" [http.routers.router1.tls] diff --git a/integration/fixtures/grpc/config_retry.toml b/integration/fixtures/grpc/config_retry.toml index 6f7a3a96a..b984f6ddf 100644 --- a/integration/fixtures/grpc/config_retry.toml +++ b/integration/fixtures/grpc/config_retry.toml @@ -22,7 +22,7 @@ [http.routers] [http.routers.router1] - rule = "Host(`127.0.0.1`)" + rule = "Host(`localhost`)" service = "service1" middlewares = ["retryer"] [http.routers.router1.tls] diff --git a/integration/grpc_test.go b/integration/grpc_test.go index 3db0a2e86..f881fb66e 100644 --- a/integration/grpc_test.go +++ b/integration/grpc_test.go @@ -86,7 +86,7 @@ func starth2cGRPCServer(lis net.Listener, server *myserver) error { func getHelloClientGRPC() (helloworld.GreeterClient, func() error, error) { roots := x509.NewCertPool() roots.AppendCertsFromPEM(LocalhostCert) - credsClient := credentials.NewClientTLSFromCert(roots, "") + credsClient := credentials.NewClientTLSFromCert(roots, "localhost") conn, err := grpc.Dial("127.0.0.1:4443", grpc.WithTransportCredentials(credsClient)) if err != nil { return nil, func() error { return nil }, err @@ -167,7 +167,7 @@ func (s *GRPCSuite) TestGRPC(c *check.C) { defer cmd.Process.Kill() // wait for Traefik - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`127.0.0.1`)")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`localhost`)")) c.Assert(err, check.IsNil) var response string @@ -247,7 +247,7 @@ func (s *GRPCSuite) TestGRPCh2cTermination(c *check.C) { defer cmd.Process.Kill() // wait for Traefik - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`127.0.0.1`)")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`localhost`)")) c.Assert(err, check.IsNil) var response string @@ -289,7 +289,7 @@ func (s *GRPCSuite) TestGRPCInsecure(c *check.C) { defer cmd.Process.Kill() // wait for Traefik - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`127.0.0.1`)")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`localhost`)")) c.Assert(err, check.IsNil) var response string @@ -336,7 +336,7 @@ func (s *GRPCSuite) TestGRPCBuffer(c *check.C) { defer cmd.Process.Kill() // wait for Traefik - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`127.0.0.1`)")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`localhost`)")) c.Assert(err, check.IsNil) var client helloworld.Greeter_StreamExampleClient client, closer, err := callStreamExampleClientGRPC() @@ -395,7 +395,7 @@ func (s *GRPCSuite) TestGRPCBufferWithFlushInterval(c *check.C) { defer cmd.Process.Kill() // wait for Traefik - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`127.0.0.1`)")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`localhost`)")) c.Assert(err, check.IsNil) var client helloworld.Greeter_StreamExampleClient @@ -453,7 +453,7 @@ func (s *GRPCSuite) TestGRPCWithRetry(c *check.C) { defer cmd.Process.Kill() // wait for Traefik - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`127.0.0.1`)")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`localhost`)")) c.Assert(err, check.IsNil) var response string diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index 1fac9e019..b35ddce03 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -79,6 +79,7 @@ type CertificateResolver struct { type Global struct { CheckNewVersion bool `description:"Periodically check if a new version has been released." json:"checkNewVersion,omitempty" toml:"checkNewVersion,omitempty" yaml:"checkNewVersion,omitempty" label:"allowEmpty" export:"true"` SendAnonymousUsage bool `description:"Periodically send anonymous usage statistics. If the option is not specified, it will be enabled by default." json:"sendAnonymousUsage,omitempty" toml:"sendAnonymousUsage,omitempty" yaml:"sendAnonymousUsage,omitempty" label:"allowEmpty" export:"true"` + InsecureSNI bool `description:"Allow domain fronting. If the option is not specified, it will be disabled by default." json:"insecureSNI,omitempty" toml:"insecureSNI,omitempty" yaml:"insecureSNI,omitempty" label:"allowEmpty" export:"true"` } // ServersTransport options to configure communication between Traefik and the servers. diff --git a/pkg/rules/rules.go b/pkg/rules/rules.go index 797a669eb..04e029a15 100644 --- a/pkg/rules/rules.go +++ b/pkg/rules/rules.go @@ -12,7 +12,9 @@ import ( ) var funcs = map[string]func(*mux.Route, ...string) error{ - "Host": host, + "Host": hostSecure, + "HostHeader": host, + "HostSNI": hostSNI, "HostRegexp": hostRegexp, "Path": path, "PathPrefix": pathPrefix, @@ -22,6 +24,18 @@ var funcs = map[string]func(*mux.Route, ...string) error{ "Query": query, } +// EnableDomainFronting initialize the matcher functions to used on routers. +// InsecureSNI defines if the domain fronting is allowed. +func EnableDomainFronting(ok bool) { + if ok { + log.WithoutContext().Warn("With insecureSNI enabled, router rules do not prevent domain fronting techniques. Please use `HostHeader` and `HostSNI` rules if domain fronting is not desired.") + funcs["Host"] = host + return + } + + funcs["Host"] = hostSecure +} + // Router handle routing with rules. type Router struct { *mux.Router @@ -98,46 +112,125 @@ func host(route *mux.Route, hosts ...string) error { } route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { - reqHost := requestdecorator.GetCanonizedHost(req.Context()) - if len(reqHost) == 0 { - log.FromContext(req.Context()).Warnf("Could not retrieve CanonizedHost, rejecting %s", req.Host) - return false - } + return matchHost(req, true, hosts...) + }) + return nil +} - flatH := requestdecorator.GetCNAMEFlatten(req.Context()) - if len(flatH) > 0 { - for _, host := range hosts { - if strings.EqualFold(reqHost, host) || strings.EqualFold(flatH, host) { - return true - } - log.FromContext(req.Context()).Debugf("CNAMEFlattening: request %s which resolved to %s, is not matched to route %s", reqHost, flatH, host) - } - return false - } +func matchHost(req *http.Request, insecureSNI bool, hosts ...string) bool { + logger := log.FromContext(req.Context()) + reqHost := requestdecorator.GetCanonizedHost(req.Context()) + if len(reqHost) == 0 { + logger.Warnf("Could not retrieve CanonizedHost, rejecting %s", req.Host) + return false + } + + flatH := requestdecorator.GetCNAMEFlatten(req.Context()) + if len(flatH) > 0 { for _, host := range hosts { - if reqHost == host { + if strings.EqualFold(reqHost, host) || strings.EqualFold(flatH, host) { return true } - - // Check for match on trailing period on host - if last := len(host) - 1; last >= 0 && host[last] == '.' { - h := host[:last] - if reqHost == h { - return true - } - } - - // Check for match on trailing period on request - if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' { - h := reqHost[:last] - if h == host { - return true - } - } + logger.Debugf("CNAMEFlattening: request %s which resolved to %s, is not matched to route %s", reqHost, flatH, host) } return false + } + + for _, host := range hosts { + if reqHost == host { + logHostSNI(insecureSNI, req, reqHost) + return true + } + + // Check for match on trailing period on host + if last := len(host) - 1; last >= 0 && host[last] == '.' { + h := host[:last] + if reqHost == h { + logHostSNI(insecureSNI, req, reqHost) + return true + } + } + + // Check for match on trailing period on request + if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' { + h := reqHost[:last] + if h == host { + logHostSNI(insecureSNI, req, reqHost) + return true + } + } + } + return false +} + +func logHostSNI(insecureSNI bool, req *http.Request, reqHost string) { + if insecureSNI && req.TLS != nil && !strings.EqualFold(reqHost, req.TLS.ServerName) { + log.FromContext(req.Context()).Debugf("Router reached with Host(%q) different from SNI(%q)", reqHost, req.TLS.ServerName) + } +} + +func hostSNI(route *mux.Route, hosts ...string) error { + for i, host := range hosts { + hosts[i] = strings.ToLower(host) + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + return matchSNI(req, hosts...) }) + + return nil +} + +func matchSNI(req *http.Request, hosts ...string) bool { + if req.TLS == nil { + return true + } + + if req.TLS.ServerName == "" { + return false + } + + for _, host := range hosts { + if strings.EqualFold(req.TLS.ServerName, host) { + return true + } + + // Check for match on trailing period on host + if last := len(host) - 1; last >= 0 && host[last] == '.' { + h := host[:last] + if strings.EqualFold(req.TLS.ServerName, h) { + return true + } + } + + // Check for match on trailing period on request + if last := len(req.TLS.ServerName) - 1; last >= 0 && req.TLS.ServerName[last] == '.' { + h := req.TLS.ServerName[:last] + if strings.EqualFold(h, host) { + return true + } + } + } + + return false +} + +func hostSecure(route *mux.Route, hosts ...string) error { + for i, host := range hosts { + hosts[i] = strings.ToLower(host) + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + for _, host := range hosts { + if matchSNI(req, host) && matchHost(req, false, host) { + return true + } + } + + return false + }) + return nil } diff --git a/pkg/rules/rules_test.go b/pkg/rules/rules_test.go index 3b44a4cc9..099779e40 100644 --- a/pkg/rules/rules_test.go +++ b/pkg/rules/rules_test.go @@ -681,6 +681,18 @@ func TestParseDomains(t *testing.T) { domain: []string{"foo.bar", "test.bar"}, errorExpected: false, }, + { + description: "Many host rules upper", + expression: "HOST(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + errorExpected: false, + }, + { + description: "Many host rules lower", + expression: "host(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + errorExpected: false, + }, { description: "No host rule", expression: "Path(`/test`)", diff --git a/pkg/server/router/router_test.go b/pkg/server/router/router_test.go index f7b17164c..294d55fdc 100644 --- a/pkg/server/router/router_test.go +++ b/pkg/server/router/router_test.go @@ -2,6 +2,7 @@ package router import ( "context" + "crypto/tls" "io/ioutil" "net/http" "net/http/httptest" @@ -14,6 +15,7 @@ import ( "github.com/containous/traefik/v2/pkg/middlewares/accesslog" "github.com/containous/traefik/v2/pkg/middlewares/requestdecorator" "github.com/containous/traefik/v2/pkg/responsemodifiers" + "github.com/containous/traefik/v2/pkg/rules" "github.com/containous/traefik/v2/pkg/server/middleware" "github.com/containous/traefik/v2/pkg/server/service" "github.com/containous/traefik/v2/pkg/testhelpers" @@ -25,6 +27,8 @@ import ( func TestRouterManager_Get(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + t.Cleanup(func() { server.Close() }) + type expectedResult struct { StatusCode int RequestHeaders map[string]string @@ -310,9 +314,230 @@ func TestRouterManager_Get(t *testing.T) { } } +func TestRouterManager_SNI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + t.Cleanup(func() { server.Close() }) + + type expectedResult struct { + StatusCode int + RequestHeaders map[string]string + } + + testCases := []struct { + desc string + routersConfig map[string]*dynamic.Router + serviceConfig map[string]*dynamic.Service + middlewaresConfig map[string]*dynamic.Middleware + entryPoint string + sni string + insecureSNI bool + expected expectedResult + }{ + { + desc: "Insecure SNI without TLS", + routersConfig: map[string]*dynamic.Router{ + "foo@provider-1": { + EntryPoints: []string{"web"}, + Service: "foo-service", + Rule: "Host(`foo.bar`)", + Priority: 0, + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "foo-service@provider-1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: server.URL, + }, + }, + }, + }, + }, + insecureSNI: true, + entryPoint: "web", + expected: expectedResult{StatusCode: http.StatusOK}, + }, + { + desc: "Secure SNI without TLS", + routersConfig: map[string]*dynamic.Router{ + "foo@provider-1": { + EntryPoints: []string{"web"}, + Service: "foo-service", + Rule: "Host(`foo.bar`)", + Priority: 0, + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "foo-service@provider-1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: server.URL, + }, + }, + }, + }, + }, + entryPoint: "web", + expected: expectedResult{StatusCode: http.StatusOK}, + }, + { + desc: "Secure SNI with TLS without sni", + routersConfig: map[string]*dynamic.Router{ + "foo@provider-1": { + EntryPoints: []string{"websecure"}, + Service: "foo-service", + Rule: "Host(`foo.bar`)", + Priority: 0, + TLS: &dynamic.RouterTLSConfig{}, + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "foo-service@provider-1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: server.URL, + }, + }, + }, + }, + }, + entryPoint: "websecure", + expected: expectedResult{StatusCode: http.StatusNotFound}, + }, + { + desc: "Secure SNI with TLS with sni request", + routersConfig: map[string]*dynamic.Router{ + "foo@provider-1": { + EntryPoints: []string{"websecure"}, + Service: "foo-service", + Rule: "Host(`foo.bar`)", + Priority: 0, + TLS: &dynamic.RouterTLSConfig{}, + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "foo-service@provider-1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: server.URL, + }, + }, + }, + }, + }, + entryPoint: "websecure", + sni: "foo.bar", + expected: expectedResult{StatusCode: http.StatusOK}, + }, + { + desc: "Insecure SNI with TLS without sni", + routersConfig: map[string]*dynamic.Router{ + "foo@provider-1": { + EntryPoints: []string{"websecure"}, + Service: "foo-service", + Rule: "Host(`foo.bar`)", + Priority: 0, + TLS: &dynamic.RouterTLSConfig{}, + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "foo-service@provider-1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: server.URL, + }, + }, + }, + }, + }, + entryPoint: "websecure", + insecureSNI: true, + expected: expectedResult{StatusCode: http.StatusOK}, + }, + { + desc: "Secure SNI with TLS with sni uppercase", + routersConfig: map[string]*dynamic.Router{ + "foo@provider-1": { + EntryPoints: []string{"websecure"}, + Service: "foo-service", + Rule: "Host(`Foo.bar`)", + Priority: 0, + TLS: &dynamic.RouterTLSConfig{}, + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "foo-service@provider-1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: server.URL, + }, + }, + }, + }, + }, + entryPoint: "websecure", + sni: "Foo.bar", + expected: expectedResult{StatusCode: http.StatusOK}, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + rtConf := runtime.NewConfig(dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Services: test.serviceConfig, + Routers: test.routersConfig, + Middlewares: test.middlewaresConfig, + }, + }) + + rules.EnableDomainFronting(test.insecureSNI) + serviceManager := service.NewManager(rtConf.Services, http.DefaultTransport, nil, nil) + middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager) + responseModifierFactory := responsemodifiers.NewBuilder(rtConf.Middlewares) + chainBuilder := middleware.NewChainBuilder(static.Configuration{}, nil, nil) + + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, responseModifierFactory, chainBuilder) + + handlers := routerManager.BuildHandlers(context.Background(), []string{test.entryPoint}, test.entryPoint == "websecure") + + w := httptest.NewRecorder() + req := testhelpers.MustNewRequest(http.MethodGet, "https://foo.bar/", nil) + + if test.entryPoint == "websecure" { + req.TLS = &tls.ConnectionState{} + + if test.sni != "" { + req.TLS.ServerName = test.sni + } + } + + reqHost := requestdecorator.New(nil) + reqHost.ServeHTTP(w, req, handlers[test.entryPoint].ServeHTTP) + + assert.Equal(t, test.expected.StatusCode, w.Code) + + for key, value := range test.expected.RequestHeaders { + assert.Equal(t, value, req.Header.Get(key)) + } + }) + } +} + func TestAccessLog(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + t.Cleanup(func() { server.Close() }) + testCases := []struct { desc string routersConfig map[string]*dynamic.Router @@ -683,7 +908,6 @@ func TestRuntimeConfiguration(t *testing.T) { chainBuilder := middleware.NewChainBuilder(static.Configuration{}, nil, nil) routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, responseModifierFactory, chainBuilder) - _ = routerManager.BuildHandlers(context.Background(), entryPoints, false) // even though rtConf was passed by argument to the manager builders above, @@ -778,13 +1002,15 @@ type staticTransport struct { res *http.Response } -func (t *staticTransport) RoundTrip(r *http.Request) (*http.Response, error) { +func (t *staticTransport) RoundTrip(_ *http.Request) (*http.Response, error) { return t.res, nil } func BenchmarkRouterServe(b *testing.B) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + b.Cleanup(func() { server.Close() }) + res := &http.Response{ StatusCode: 200, Body: ioutil.NopCloser(strings.NewReader("")), diff --git a/pkg/tcp/router.go b/pkg/tcp/router.go index c66e987ce..90675eed6 100644 --- a/pkg/tcp/router.go +++ b/pkg/tcp/router.go @@ -102,7 +102,7 @@ func (r *Router) AddRouteTLS(sniHost string, target Handler, config *tls.Config) }) } -// AddRouteHTTPTLS defines a handler for a given sniHost and sets the matching tlsConfig. +// AddRouteHTTPTLS defines the matching tlsConfig for a given sniHost. func (r *Router) AddRouteHTTPTLS(sniHost string, config *tls.Config) { if r.hostHTTPTLSConfig == nil { r.hostHTTPTLSConfig = map[string]*tls.Config{} diff --git a/traefik.sample.toml b/traefik.sample.toml index cb2cb4c40..0658a766f 100644 --- a/traefik.sample.toml +++ b/traefik.sample.toml @@ -12,6 +12,8 @@ [global] checkNewVersion = true sendAnonymousUsage = true + # Enabling domain fronting + # insecureSNI = true ################################################################ # Entrypoints configuration