diff --git a/docs/content/reference/install-configuration/configuration-options.md b/docs/content/reference/install-configuration/configuration-options.md index 59e1794b6..e9426ff0e 100644 --- a/docs/content/reference/install-configuration/configuration-options.md +++ b/docs/content/reference/install-configuration/configuration-options.md @@ -85,7 +85,6 @@ THIS FILE MUST NOT BE EDITED BY HAND | entrypoints._name_.forwardedheaders.insecure | Trust all forwarded headers. | false | | entrypoints._name_.forwardedheaders.trustedips | Trust only forwarded headers from selected IPs. | | | entrypoints._name_.http | HTTP configuration. | | -| entrypoints._name_.http.encodedcharacters | Defines which encoded characters are allowed in the request path. | | | entrypoints._name_.http.encodedcharacters.allowencodedbackslash | Defines whether requests with encoded back slash characters in the path are allowed. | false | | entrypoints._name_.http.encodedcharacters.allowencodedhash | Defines whether requests with encoded hash characters in the path are allowed. | false | | entrypoints._name_.http.encodedcharacters.allowencodednullcharacter | Defines whether requests with encoded null characters in the path are allowed. | false | diff --git a/integration/fixtures/simple_encoded_chars.toml b/integration/fixtures/simple_encoded_chars.toml new file mode 100644 index 000000000..56f645ca3 --- /dev/null +++ b/integration/fixtures/simple_encoded_chars.toml @@ -0,0 +1,34 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + +[entryPoints] + [entryPoints.strict] + address = ":8000" + # Default: no encoded characters allowed + + [entryPoints.permissive] + address = ":8001" + [entryPoints.permissive.http.encodedCharacters] + allowEncodedSlash = true + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.sameRouter] + service = "service1" + rule = "Host(`test.localhost`)" + +[http.services] + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "{{ .Server1 }}" diff --git a/integration/simple_test.go b/integration/simple_test.go index f344719cf..158153a6c 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -2092,3 +2092,72 @@ func (s *SimpleSuite) TestSanitizePathSyntaxV2() { } } } + +// TestEncodedCharactersDifferentEntryPoints verifies that router handler caching does not interfere with +// per-entry-point encoded characters configuration. +// The same router should behave differently on different entry points. +func (s *SimpleSuite) TestEncodedCharactersDifferentEntryPoints() { + s.createComposeProject("base") + + s.composeUp() + defer s.composeDown() + + whoami1URL := "http://" + net.JoinHostPort(s.getComposeServiceIP("whoami1"), "80") + + file := s.adaptFile("fixtures/simple_encoded_chars.toml", struct { + Server1 string + }{whoami1URL}) + + s.traefikCmd(withConfigFile(file)) + + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`test.localhost`)")) + require.NoError(s.T(), err) + + testCases := []struct { + desc string + request string + target string + expected int + }{ + { + desc: "Encoded slash should be REJECTED on strict entry point", + request: "GET /path%2Fwith%2Fslash HTTP/1.1\r\nHost: test.localhost\r\n\r\n", + target: "127.0.0.1:8000", // strict entry point + expected: http.StatusBadRequest, + }, + { + desc: "Encoded slash should be ALLOWED on permissive entry point", + request: "GET /path%2Fwith%2Fslash HTTP/1.1\r\nHost: test.localhost\r\n\r\n", + target: "127.0.0.1:8001", // permissive entry point + expected: http.StatusOK, + }, + { + desc: "Regular path should work on strict entry point", + request: "GET /regular/path HTTP/1.1\r\nHost: test.localhost\r\n\r\n", + target: "127.0.0.1:8000", + expected: http.StatusOK, + }, + { + desc: "Regular path should work on permissive entry point", + request: "GET /regular/path HTTP/1.1\r\nHost: test.localhost\r\n\r\n", + target: "127.0.0.1:8001", + expected: http.StatusOK, + }, + } + + for _, test := range testCases { + conn, err := net.Dial("tcp", test.target) + require.NoError(s.T(), err) + + _, err = conn.Write([]byte(test.request)) + require.NoError(s.T(), err) + + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(s.T(), err) + + assert.Equalf(s.T(), test.expected, resp.StatusCode, "%s failed with %d instead of %d", test.desc, resp.StatusCode, test.expected) + + err = conn.Close() + require.NoError(s.T(), err) + } +} diff --git a/pkg/api/handler_support_dump_test.go b/pkg/api/handler_support_dump_test.go index 7dede6df3..8de6e766a 100644 --- a/pkg/api/handler_support_dump_test.go +++ b/pkg/api/handler_support_dump_test.go @@ -76,7 +76,7 @@ func TestHandler_SupportDump(t *testing.T) { assert.Contains(t, string(files["version.json"]), `"version":"dev"`) // Verify static config contains entry points - assert.Contains(t, string(files["static-config.json"]), `"entryPoints":{"web":{"address":"xxxx","http":{"encodedCharacters":{}}}`) + assert.Contains(t, string(files["static-config.json"]), `"entryPoints":{"web":{"address":"xxxx","http":{}`) // Verify runtime config contains services assert.Contains(t, string(files["runtime-config.json"]), `"services":`) diff --git a/pkg/api/testdata/entrypoint-bar.json b/pkg/api/testdata/entrypoint-bar.json index 27b6762c4..e68aa5d99 100644 --- a/pkg/api/testdata/entrypoint-bar.json +++ b/pkg/api/testdata/entrypoint-bar.json @@ -1,7 +1,5 @@ { "address": ":81", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "bar" } diff --git a/pkg/api/testdata/entrypoint-foo-slash-bar.json b/pkg/api/testdata/entrypoint-foo-slash-bar.json index 8384c00d6..5f0bcbafc 100644 --- a/pkg/api/testdata/entrypoint-foo-slash-bar.json +++ b/pkg/api/testdata/entrypoint-foo-slash-bar.json @@ -1,7 +1,5 @@ { "address": ":81", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "foo / bar" } diff --git a/pkg/api/testdata/entrypoints-many-lastpage.json b/pkg/api/testdata/entrypoints-many-lastpage.json index a50e13584..4d2917405 100644 --- a/pkg/api/testdata/entrypoints-many-lastpage.json +++ b/pkg/api/testdata/entrypoints-many-lastpage.json @@ -1,37 +1,27 @@ [ { "address": ":14", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "ep14" }, { "address": ":15", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "ep15" }, { "address": ":16", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "ep16" }, { "address": ":17", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "ep17" }, { "address": ":18", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "ep18" } ] diff --git a/pkg/api/testdata/entrypoints-page2.json b/pkg/api/testdata/entrypoints-page2.json index 89e8d649b..b98ced751 100644 --- a/pkg/api/testdata/entrypoints-page2.json +++ b/pkg/api/testdata/entrypoints-page2.json @@ -1,9 +1,7 @@ [ { "address": ":82", - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "web2" } ] diff --git a/pkg/api/testdata/entrypoints.json b/pkg/api/testdata/entrypoints.json index 5c96cbed8..d93d07bfc 100644 --- a/pkg/api/testdata/entrypoints.json +++ b/pkg/api/testdata/entrypoints.json @@ -8,9 +8,7 @@ "192.168.1.4" ] }, - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "web", "proxyProtocol": { "insecure": true, @@ -40,9 +38,7 @@ "192.168.1.40" ] }, - "http": { - "encodedCharacters": {} - }, + "http": {}, "name": "websecure", "proxyProtocol": { "insecure": true, diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go index f9022c19d..afb503e44 100644 --- a/pkg/config/dynamic/http_config.go +++ b/pkg/config/dynamic/http_config.go @@ -44,10 +44,11 @@ type HTTPConfiguration struct { // Model holds model configuration. type Model struct { - Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` - TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` - Observability RouterObservabilityConfig `json:"observability,omitempty" toml:"observability,omitempty" yaml:"observability,omitempty" export:"true"` - DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` + Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` + TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` + Observability RouterObservabilityConfig `json:"observability,omitempty" toml:"observability,omitempty" yaml:"observability,omitempty" export:"true"` + DeniedEncodedPathCharacters *RouterDeniedEncodedPathCharacters `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` + DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` } // +k8s:deepcopy-gen=true @@ -71,11 +72,58 @@ type Router struct { Rule string `json:"rule,omitempty" toml:"rule,omitempty" yaml:"rule,omitempty"` ParentRefs []string `json:"parentRefs,omitempty" toml:"parentRefs,omitempty" yaml:"parentRefs,omitempty" label:"-" export:"true"` // Deprecated: Please do not use this field and rewrite the router rules to use the v3 syntax. - RuleSyntax string `json:"ruleSyntax,omitempty" toml:"ruleSyntax,omitempty" yaml:"ruleSyntax,omitempty" export:"true"` - Priority int `json:"priority,omitempty" toml:"priority,omitempty,omitzero" yaml:"priority,omitempty" export:"true"` - TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` - Observability *RouterObservabilityConfig `json:"observability,omitempty" toml:"observability,omitempty" yaml:"observability,omitempty" export:"true"` - DefaultRule bool `json:"-" toml:"-" yaml:"-" label:"-" file:"-"` + RuleSyntax string `json:"ruleSyntax,omitempty" toml:"ruleSyntax,omitempty" yaml:"ruleSyntax,omitempty" export:"true"` + Priority int `json:"priority,omitempty" toml:"priority,omitempty,omitzero" yaml:"priority,omitempty" export:"true"` + TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` + Observability *RouterObservabilityConfig `json:"observability,omitempty" toml:"observability,omitempty" yaml:"observability,omitempty" export:"true"` + DefaultRule bool `json:"-" toml:"-" yaml:"-" label:"-" file:"-"` + DeniedEncodedPathCharacters RouterDeniedEncodedPathCharacters `json:"-" toml:"-" yaml:"-" label:"-" file:"-"` +} + +// +k8s:deepcopy-gen=true + +// RouterDeniedEncodedPathCharacters configures which encoded characters are allowed in the request path. +type RouterDeniedEncodedPathCharacters struct { + AllowEncodedSlash bool `description:"Defines whether requests with encoded slash characters in the path are allowed." json:"allowEncodedSlash,omitempty" toml:"allowEncodedSlash,omitempty" yaml:"allowEncodedSlash,omitempty" export:"true"` + AllowEncodedBackSlash bool `description:"Defines whether requests with encoded back slash characters in the path are allowed." json:"allowEncodedBackSlash,omitempty" toml:"allowEncodedBackSlash,omitempty" yaml:"allowEncodedBackSlash,omitempty" export:"true"` + AllowEncodedNullCharacter bool `description:"Defines whether requests with encoded null characters in the path are allowed." json:"allowEncodedNullCharacter,omitempty" toml:"allowEncodedNullCharacter,omitempty" yaml:"allowEncodedNullCharacter,omitempty" export:"true"` + AllowEncodedSemicolon bool `description:"Defines whether requests with encoded semicolon characters in the path are allowed." json:"allowEncodedSemicolon,omitempty" toml:"allowEncodedSemicolon,omitempty" yaml:"allowEncodedSemicolon,omitempty" export:"true"` + AllowEncodedPercent bool `description:"Defines whether requests with encoded percent characters in the path are allowed." json:"allowEncodedPercent,omitempty" toml:"allowEncodedPercent,omitempty" yaml:"allowEncodedPercent,omitempty" export:"true"` + AllowEncodedQuestionMark bool `description:"Defines whether requests with encoded question mark characters in the path are allowed." json:"allowEncodedQuestionMark,omitempty" toml:"allowEncodedQuestionMark,omitempty" yaml:"allowEncodedQuestionMark,omitempty" export:"true"` + AllowEncodedHash bool `description:"Defines whether requests with encoded hash characters in the path are allowed." json:"allowEncodedHash,omitempty" toml:"allowEncodedHash,omitempty" yaml:"allowEncodedHash,omitempty" export:"true"` +} + +// Map returns a map of unallowed encoded characters. +func (r *RouterDeniedEncodedPathCharacters) Map() map[string]struct{} { + characters := make(map[string]struct{}) + + if !r.AllowEncodedSlash { + characters["%2F"] = struct{}{} + characters["%2f"] = struct{}{} + } + if !r.AllowEncodedBackSlash { + characters["%5C"] = struct{}{} + characters["%5c"] = struct{}{} + } + if !r.AllowEncodedNullCharacter { + characters["%00"] = struct{}{} + } + if !r.AllowEncodedSemicolon { + characters["%3B"] = struct{}{} + characters["%3b"] = struct{}{} + } + if !r.AllowEncodedPercent { + characters["%25"] = struct{}{} + } + if !r.AllowEncodedQuestionMark { + characters["%3F"] = struct{}{} + characters["%3f"] = struct{}{} + } + if !r.AllowEncodedHash { + characters["%23"] = struct{}{} + } + + return characters } // +k8s:deepcopy-gen=true diff --git a/pkg/config/dynamic/http_config_test.go b/pkg/config/dynamic/http_config_test.go new file mode 100644 index 000000000..c8cda437d --- /dev/null +++ b/pkg/config/dynamic/http_config_test.go @@ -0,0 +1,165 @@ +package dynamic + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEncodedCharactersMap(t *testing.T) { + tests := []struct { + name string + config RouterDeniedEncodedPathCharacters + expected map[string]struct{} + }{ + { + name: "Handles empty configuration", + expected: map[string]struct{}{ + "%2F": {}, + "%2f": {}, + "%5C": {}, + "%5c": {}, + "%00": {}, + "%3B": {}, + "%3b": {}, + "%25": {}, + "%3F": {}, + "%3f": {}, + "%23": {}, + }, + }, + { + name: "Exclude encoded slash when allowed", + config: RouterDeniedEncodedPathCharacters{ + AllowEncodedSlash: true, + }, + expected: map[string]struct{}{ + "%5C": {}, + "%5c": {}, + "%00": {}, + "%3B": {}, + "%3b": {}, + "%25": {}, + "%3F": {}, + "%3f": {}, + "%23": {}, + }, + }, + + { + name: "Exclude encoded backslash when allowed", + config: RouterDeniedEncodedPathCharacters{ + AllowEncodedBackSlash: true, + }, + expected: map[string]struct{}{ + "%2F": {}, + "%2f": {}, + "%00": {}, + "%3B": {}, + "%3b": {}, + "%25": {}, + "%3F": {}, + "%3f": {}, + "%23": {}, + }, + }, + + { + name: "Exclude encoded null character when allowed", + config: RouterDeniedEncodedPathCharacters{ + AllowEncodedNullCharacter: true, + }, + expected: map[string]struct{}{ + "%2F": {}, + "%2f": {}, + "%5C": {}, + "%5c": {}, + "%3B": {}, + "%3b": {}, + "%25": {}, + "%3F": {}, + "%3f": {}, + "%23": {}, + }, + }, + { + name: "Exclude encoded semicolon when allowed", + config: RouterDeniedEncodedPathCharacters{ + AllowEncodedSemicolon: true, + }, + expected: map[string]struct{}{ + "%2F": {}, + "%2f": {}, + "%5C": {}, + "%5c": {}, + "%00": {}, + "%25": {}, + "%3F": {}, + "%3f": {}, + "%23": {}, + }, + }, + { + name: "Exclude encoded percent when allowed", + config: RouterDeniedEncodedPathCharacters{ + AllowEncodedPercent: true, + }, + expected: map[string]struct{}{ + "%2F": {}, + "%2f": {}, + "%5C": {}, + "%5c": {}, + "%00": {}, + "%3B": {}, + "%3b": {}, + "%3F": {}, + "%3f": {}, + "%23": {}, + }, + }, + { + name: "Exclude encoded question mark when allowed", + config: RouterDeniedEncodedPathCharacters{ + AllowEncodedQuestionMark: true, + }, + expected: map[string]struct{}{ + "%2F": {}, + "%2f": {}, + "%5C": {}, + "%5c": {}, + "%00": {}, + "%3B": {}, + "%3b": {}, + "%25": {}, + "%23": {}, + }, + }, + { + name: "Exclude encoded hash when allowed", + config: RouterDeniedEncodedPathCharacters{ + AllowEncodedHash: true, + }, + expected: map[string]struct{}{ + "%2F": {}, + "%2f": {}, + "%5C": {}, + "%5c": {}, + "%00": {}, + "%3B": {}, + "%3b": {}, + "%25": {}, + "%3F": {}, + "%3f": {}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + result := test.config.Map() + require.Equal(t, test.expected, result) + }) + } +} diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 1e7b9e36d..af3495832 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -1086,6 +1086,11 @@ func (in *Model) DeepCopyInto(out *Model) { (*in).DeepCopyInto(*out) } in.Observability.DeepCopyInto(&out.Observability) + if in.DeniedEncodedPathCharacters != nil { + in, out := &in.DeniedEncodedPathCharacters, &out.DeniedEncodedPathCharacters + *out = new(RouterDeniedEncodedPathCharacters) + **out = **in + } return } @@ -1384,6 +1389,7 @@ func (in *Router) DeepCopyInto(out *Router) { *out = new(RouterObservabilityConfig) (*in).DeepCopyInto(*out) } + out.DeniedEncodedPathCharacters = in.DeniedEncodedPathCharacters return } @@ -1397,6 +1403,22 @@ func (in *Router) DeepCopy() *Router { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouterDeniedEncodedPathCharacters) DeepCopyInto(out *RouterDeniedEncodedPathCharacters) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouterDeniedEncodedPathCharacters. +func (in *RouterDeniedEncodedPathCharacters) DeepCopy() *RouterDeniedEncodedPathCharacters { + if in == nil { + return nil + } + out := new(RouterDeniedEncodedPathCharacters) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RouterObservabilityConfig) DeepCopyInto(out *RouterObservabilityConfig) { *out = *in diff --git a/pkg/config/static/entrypoints.go b/pkg/config/static/entrypoints.go index 5f5935649..a1e152773 100644 --- a/pkg/config/static/entrypoints.go +++ b/pkg/config/static/entrypoints.go @@ -65,13 +65,13 @@ func (ep *EntryPoint) SetDefaults() { // HTTPConfig is the HTTP configuration of an entry point. type HTTPConfig struct { - Redirections *Redirections `description:"Set of redirection" json:"redirections,omitempty" toml:"redirections,omitempty" yaml:"redirections,omitempty" export:"true"` - Middlewares []string `description:"Default middlewares for the routers linked to the entry point." json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` - TLS *TLSConfig `description:"Default TLS configuration for the routers linked to the entry point." json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` - EncodedCharacters EncodedCharacters `description:"Defines which encoded characters are allowed in the request path." json:"encodedCharacters,omitempty" toml:"encodedCharacters,omitempty" yaml:"encodedCharacters,omitempty" export:"true"` - EncodeQuerySemicolons bool `description:"Defines whether request query semicolons should be URLEncoded." json:"encodeQuerySemicolons,omitempty" toml:"encodeQuerySemicolons,omitempty" yaml:"encodeQuerySemicolons,omitempty" export:"true"` - SanitizePath *bool `description:"Defines whether to enable request path sanitization (removal of /./, /../ and multiple slash sequences)." json:"sanitizePath,omitempty" toml:"sanitizePath,omitempty" yaml:"sanitizePath,omitempty" export:"true"` - MaxHeaderBytes int `description:"Maximum size of request headers in bytes." json:"maxHeaderBytes,omitempty" toml:"maxHeaderBytes,omitempty" yaml:"maxHeaderBytes,omitempty" export:"true"` + Redirections *Redirections `description:"Set of redirection" json:"redirections,omitempty" toml:"redirections,omitempty" yaml:"redirections,omitempty" export:"true"` + Middlewares []string `description:"Default middlewares for the routers linked to the entry point." json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` + TLS *TLSConfig `description:"Default TLS configuration for the routers linked to the entry point." json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` + EncodedCharacters *EncodedCharacters `description:"Defines which encoded characters are allowed in the request path." json:"encodedCharacters,omitempty" toml:"encodedCharacters,omitempty" yaml:"encodedCharacters,omitempty" export:"true"` + EncodeQuerySemicolons bool `description:"Defines whether request query semicolons should be URLEncoded." json:"encodeQuerySemicolons,omitempty" toml:"encodeQuerySemicolons,omitempty" yaml:"encodeQuerySemicolons,omitempty" export:"true"` + SanitizePath *bool `description:"Defines whether to enable request path sanitization (removal of /./, /../ and multiple slash sequences)." json:"sanitizePath,omitempty" toml:"sanitizePath,omitempty" yaml:"sanitizePath,omitempty" export:"true"` + MaxHeaderBytes int `description:"Maximum size of request headers in bytes." json:"maxHeaderBytes,omitempty" toml:"maxHeaderBytes,omitempty" yaml:"maxHeaderBytes,omitempty" export:"true"` } // SetDefaults sets the default values. @@ -92,39 +92,6 @@ type EncodedCharacters struct { AllowEncodedHash bool `description:"Defines whether requests with encoded hash characters in the path are allowed." json:"allowEncodedHash,omitempty" toml:"allowEncodedHash,omitempty" yaml:"allowEncodedHash,omitempty" export:"true"` } -// Map returns a map of unallowed encoded characters. -func (h *EncodedCharacters) Map() map[string]struct{} { - characters := make(map[string]struct{}) - - if !h.AllowEncodedSlash { - characters["%2F"] = struct{}{} - characters["%2f"] = struct{}{} - } - if !h.AllowEncodedBackSlash { - characters["%5C"] = struct{}{} - characters["%5c"] = struct{}{} - } - if !h.AllowEncodedNullCharacter { - characters["%00"] = struct{}{} - } - if !h.AllowEncodedSemicolon { - characters["%3B"] = struct{}{} - characters["%3b"] = struct{}{} - } - if !h.AllowEncodedPercent { - characters["%25"] = struct{}{} - } - if !h.AllowEncodedQuestionMark { - characters["%3F"] = struct{}{} - characters["%3f"] = struct{}{} - } - if !h.AllowEncodedHash { - characters["%23"] = struct{}{} - } - - return characters -} - // HTTP2Config is the HTTP2 configuration of an entry point. type HTTP2Config struct { MaxConcurrentStreams int32 `description:"Specifies the number of concurrent streams per connection that each client is allowed to initiate." json:"maxConcurrentStreams,omitempty" toml:"maxConcurrentStreams,omitempty" yaml:"maxConcurrentStreams,omitempty" export:"true"` diff --git a/pkg/config/static/entrypoints_test.go b/pkg/config/static/entrypoints_test.go index a412098fe..866076508 100644 --- a/pkg/config/static/entrypoints_test.go +++ b/pkg/config/static/entrypoints_test.go @@ -65,161 +65,3 @@ func TestEntryPointProtocol(t *testing.T) { }) } } - -func TestEncodedCharactersMap(t *testing.T) { - tests := []struct { - name string - config EncodedCharacters - expected map[string]struct{} - }{ - { - name: "Handles empty configuration", - expected: map[string]struct{}{ - "%2F": {}, - "%2f": {}, - "%5C": {}, - "%5c": {}, - "%00": {}, - "%3B": {}, - "%3b": {}, - "%25": {}, - "%3F": {}, - "%3f": {}, - "%23": {}, - }, - }, - { - name: "Exclude encoded slash when allowed", - config: EncodedCharacters{ - AllowEncodedSlash: true, - }, - expected: map[string]struct{}{ - "%5C": {}, - "%5c": {}, - "%00": {}, - "%3B": {}, - "%3b": {}, - "%25": {}, - "%3F": {}, - "%3f": {}, - "%23": {}, - }, - }, - - { - name: "Exclude encoded backslash when allowed", - config: EncodedCharacters{ - AllowEncodedBackSlash: true, - }, - expected: map[string]struct{}{ - "%2F": {}, - "%2f": {}, - "%00": {}, - "%3B": {}, - "%3b": {}, - "%25": {}, - "%3F": {}, - "%3f": {}, - "%23": {}, - }, - }, - - { - name: "Exclude encoded null character when allowed", - config: EncodedCharacters{ - AllowEncodedNullCharacter: true, - }, - expected: map[string]struct{}{ - "%2F": {}, - "%2f": {}, - "%5C": {}, - "%5c": {}, - "%3B": {}, - "%3b": {}, - "%25": {}, - "%3F": {}, - "%3f": {}, - "%23": {}, - }, - }, - { - name: "Exclude encoded semicolon when allowed", - config: EncodedCharacters{ - AllowEncodedSemicolon: true, - }, - expected: map[string]struct{}{ - "%2F": {}, - "%2f": {}, - "%5C": {}, - "%5c": {}, - "%00": {}, - "%25": {}, - "%3F": {}, - "%3f": {}, - "%23": {}, - }, - }, - { - name: "Exclude encoded percent when allowed", - config: EncodedCharacters{ - AllowEncodedPercent: true, - }, - expected: map[string]struct{}{ - "%2F": {}, - "%2f": {}, - "%5C": {}, - "%5c": {}, - "%00": {}, - "%3B": {}, - "%3b": {}, - "%3F": {}, - "%3f": {}, - "%23": {}, - }, - }, - { - name: "Exclude encoded question mark when allowed", - config: EncodedCharacters{ - AllowEncodedQuestionMark: true, - }, - expected: map[string]struct{}{ - "%2F": {}, - "%2f": {}, - "%5C": {}, - "%5c": {}, - "%00": {}, - "%3B": {}, - "%3b": {}, - "%25": {}, - "%23": {}, - }, - }, - { - name: "Exclude encoded hash when allowed", - config: EncodedCharacters{ - AllowEncodedHash: true, - }, - expected: map[string]struct{}{ - "%2F": {}, - "%2f": {}, - "%5C": {}, - "%5c": {}, - "%00": {}, - "%3B": {}, - "%3b": {}, - "%25": {}, - "%3F": {}, - "%3f": {}, - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - result := test.config.Map() - require.Equal(t, test.expected, result) - }) - } -} diff --git a/pkg/provider/traefik/internal.go b/pkg/provider/traefik/internal.go index 73d17d63f..7c99f977e 100644 --- a/pkg/provider/traefik/internal.go +++ b/pkg/provider/traefik/internal.go @@ -231,7 +231,7 @@ func (i *Provider) entryPointModels(cfg *dynamic.Configuration) { } } - if len(ep.HTTP.Middlewares) == 0 && ep.HTTP.TLS == nil && defaultRuleSyntax == "" && ep.Observability == nil { + if len(ep.HTTP.Middlewares) == 0 && ep.HTTP.TLS == nil && defaultRuleSyntax == "" && ep.Observability == nil && ep.HTTP.EncodedCharacters == nil { continue } @@ -240,6 +240,18 @@ func (i *Provider) entryPointModels(cfg *dynamic.Configuration) { Middlewares: ep.HTTP.Middlewares, } + if ep.HTTP.EncodedCharacters != nil { + httpModel.DeniedEncodedPathCharacters = &dynamic.RouterDeniedEncodedPathCharacters{ + AllowEncodedSlash: ep.HTTP.EncodedCharacters.AllowEncodedSlash, + AllowEncodedBackSlash: ep.HTTP.EncodedCharacters.AllowEncodedBackSlash, + AllowEncodedPercent: ep.HTTP.EncodedCharacters.AllowEncodedPercent, + AllowEncodedQuestionMark: ep.HTTP.EncodedCharacters.AllowEncodedQuestionMark, + AllowEncodedSemicolon: ep.HTTP.EncodedCharacters.AllowEncodedSemicolon, + AllowEncodedHash: ep.HTTP.EncodedCharacters.AllowEncodedHash, + AllowEncodedNullCharacter: ep.HTTP.EncodedCharacters.AllowEncodedNullCharacter, + } + } + if ep.Observability != nil { httpModel.Observability = dynamic.RouterObservabilityConfig{ AccessLogs: ep.Observability.AccessLogs, diff --git a/pkg/redactor/testdata/anonymized-static-config.json b/pkg/redactor/testdata/anonymized-static-config.json index d380f1692..afd1d19b8 100644 --- a/pkg/redactor/testdata/anonymized-static-config.json +++ b/pkg/redactor/testdata/anonymized-static-config.json @@ -82,8 +82,7 @@ ] } ] - }, - "encodedCharacters": {} + } } } }, diff --git a/pkg/server/aggregator.go b/pkg/server/aggregator.go index dc342ad3a..086501b0c 100644 --- a/pkg/server/aggregator.go +++ b/pkg/server/aggregator.go @@ -212,6 +212,12 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration { cp.Middlewares = append(m.Middlewares, cp.Middlewares...) + if m.DeniedEncodedPathCharacters != nil { + // As the denied encoded path characters option is not configurable at the router level, + // we can simply copy the whole structure to override the router's default config. + cp.DeniedEncodedPathCharacters = *m.DeniedEncodedPathCharacters + } + if cp.Observability == nil { cp.Observability = &dynamic.RouterObservabilityConfig{} } diff --git a/pkg/server/router/router.go b/pkg/server/router/router.go index 02d404815..6bbf0c7fc 100644 --- a/pkg/server/router/router.go +++ b/pkg/server/router/router.go @@ -39,14 +39,13 @@ type serviceManager interface { // Manager A route/router manager. type Manager struct { - routerHandlers map[string]http.Handler - serviceManager serviceManager - observabilityMgr *middleware.ObservabilityMgr - middlewaresBuilder middlewareBuilder - conf *runtime.Configuration - tlsManager *tls.Manager - parser httpmuxer.SyntaxParser - deniedEncodedPathCharacters map[string]map[string]struct{} + routerHandlers map[string]http.Handler + serviceManager serviceManager + observabilityMgr *middleware.ObservabilityMgr + middlewaresBuilder middlewareBuilder + conf *runtime.Configuration + tlsManager *tls.Manager + parser httpmuxer.SyntaxParser } // NewManager creates a new Manager. @@ -56,17 +55,15 @@ func NewManager(conf *runtime.Configuration, observabilityMgr *middleware.ObservabilityMgr, tlsManager *tls.Manager, parser httpmuxer.SyntaxParser, - deniedEncodedPathCharacters map[string]map[string]struct{}, ) *Manager { return &Manager{ - routerHandlers: make(map[string]http.Handler), - serviceManager: serviceManager, - observabilityMgr: observabilityMgr, - middlewaresBuilder: middlewaresBuilder, - conf: conf, - tlsManager: tlsManager, - parser: parser, - deniedEncodedPathCharacters: deniedEncodedPathCharacters, + routerHandlers: make(map[string]http.Handler), + serviceManager: serviceManager, + observabilityMgr: observabilityMgr, + middlewaresBuilder: middlewaresBuilder, + conf: conf, + tlsManager: tlsManager, + parser: parser, } } @@ -282,7 +279,7 @@ func (m *Manager) buildHTTPHandler(ctx context.Context, router *runtime.RouterIn return denyFragment(next), nil }) chain = chain.Append(func(next http.Handler) (http.Handler, error) { - return denyEncodedPathCharacters(m.deniedEncodedPathCharacters[entryPointName], next), nil + return denyEncodedPathCharacters(router.DeniedEncodedPathCharacters.Map(), next), nil }) } diff --git a/pkg/server/router/router_test.go b/pkg/server/router/router_test.go index d36938d16..65976107d 100644 --- a/pkg/server/router/router_test.go +++ b/pkg/server/router/router_test.go @@ -18,7 +18,6 @@ import ( ptypes "github.com/traefik/paerser/types" "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/config/runtime" - "github.com/traefik/traefik/v3/pkg/config/static" "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" httpmuxer "github.com/traefik/traefik/v3/pkg/muxer/http" "github.com/traefik/traefik/v3/pkg/server/middleware" @@ -333,7 +332,7 @@ func TestRouterManager_Get(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, nil) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) handlers := routerManager.BuildHandlers(t.Context(), test.entryPoints, false) @@ -721,7 +720,7 @@ func TestRuntimeConfiguration(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, nil) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) _ = routerManager.BuildHandlers(t.Context(), entryPoints, false) _ = routerManager.BuildHandlers(t.Context(), entryPoints, true) @@ -802,7 +801,7 @@ func TestProviderOnMiddlewares(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, nil) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) _ = routerManager.BuildHandlers(t.Context(), entryPoints, false) @@ -857,7 +856,7 @@ func BenchmarkRouterServe(b *testing.B) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(b, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, nil) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) handlers := routerManager.BuildHandlers(b.Context(), entryPoints, false) @@ -1450,7 +1449,7 @@ func TestManager_buildChildRoutersMuxer(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, nil) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) // Compute multi-layer routing to populate ChildRefs manager.ParseRouterTree() @@ -1641,7 +1640,7 @@ func TestManager_buildHTTPHandler_WithChildRouters(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, nil) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) // Run ParseRouterTree to validate configuration and populate ChildRefs/errors manager.ParseRouterTree() @@ -1788,7 +1787,7 @@ func TestManager_BuildHandlers_WithChildRouters(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, nil) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) // Compute multi-layer routing to set up parent-child relationships manager.ParseRouterTree() @@ -1819,11 +1818,10 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { routers map[string]*dynamic.Router services map[string]*dynamic.Service requestPath string - encodedCharacters static.EncodedCharacters expectedStatusCode int }{ { - desc: "parent router without child routers request with encoded slash", + desc: "parent router without child routers, request with encoded slash", requestPath: "/foo%2F", routers: map[string]*dynamic.Router{ "parent": { @@ -1842,7 +1840,7 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { expectedStatusCode: http.StatusBadRequest, }, { - desc: "parent router with child routers request with encoded slash", + desc: "parent router with child routers, request with encoded slash", requestPath: "/foo%2F", routers: map[string]*dynamic.Router{ "parent": { @@ -1865,13 +1863,16 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { expectedStatusCode: http.StatusBadRequest, }, { - desc: "parent router without child router allowing encoded slash", + desc: "parent router allowing encoded slash without child router", requestPath: "/foo%2F", routers: map[string]*dynamic.Router{ "parent": { EntryPoints: []string{"web"}, Rule: "PathPrefix(`/`)", Service: "service", + DeniedEncodedPathCharacters: dynamic.RouterDeniedEncodedPathCharacters{ + AllowEncodedSlash: true, + }, }, }, services: map[string]*dynamic.Service{ @@ -1881,18 +1882,18 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { }, }, }, - encodedCharacters: static.EncodedCharacters{ - AllowEncodedSlash: true, - }, expectedStatusCode: http.StatusOK, }, { - desc: "parent router with child routers allowing encoded slash", + desc: "parent router allowing encoded slash with child routers", requestPath: "/foo%2F", routers: map[string]*dynamic.Router{ "parent": { EntryPoints: []string{"web"}, Rule: "PathPrefix(`/`)", + DeniedEncodedPathCharacters: dynamic.RouterDeniedEncodedPathCharacters{ + AllowEncodedSlash: true, + }, }, "child1": { Rule: "PathPrefix(`/`)", @@ -1907,13 +1908,10 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { }, }, }, - encodedCharacters: static.EncodedCharacters{ - AllowEncodedSlash: true, - }, expectedStatusCode: http.StatusOK, }, { - desc: "parent router without child routers request with fragment", + desc: "parent router without child routers, request with fragment", requestPath: "/foo#", routers: map[string]*dynamic.Router{ "parent": { @@ -1932,7 +1930,7 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { expectedStatusCode: http.StatusBadRequest, }, { - desc: "parent router with child routers request with fragment", + desc: "parent router with child routers, request with fragment", requestPath: "/foo#", routers: map[string]*dynamic.Router{ "parent": { @@ -1986,8 +1984,7 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - deniedEncodedPathCharacters := map[string]map[string]struct{}{"web": test.encodedCharacters.Map()} - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, deniedEncodedPathCharacters) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) // Compute multi-layer routing to set up parent-child relationships manager.ParseRouterTree() diff --git a/pkg/server/routerfactory.go b/pkg/server/routerfactory.go index ffb6b8199..698de82b4 100644 --- a/pkg/server/routerfactory.go +++ b/pkg/server/routerfactory.go @@ -26,8 +26,7 @@ type RouterFactory struct { entryPointsTCP []string entryPointsUDP []string - allowACMEByPass map[string]bool - deniedEncodedPathCharacters map[string]map[string]struct{} + allowACMEByPass map[string]bool managerFactory *service.ManagerFactory @@ -73,27 +72,21 @@ func NewRouterFactory(staticConfiguration static.Configuration, managerFactory * } } - deniedEncodedPathCharacters := map[string]map[string]struct{}{} - for name, ep := range staticConfiguration.EntryPoints { - deniedEncodedPathCharacters[name] = ep.HTTP.EncodedCharacters.Map() - } - parser, err := httpmuxer.NewSyntaxParser() if err != nil { return nil, fmt.Errorf("creating parser: %w", err) } return &RouterFactory{ - entryPointsTCP: entryPointsTCP, - entryPointsUDP: entryPointsUDP, - managerFactory: managerFactory, - observabilityMgr: observabilityMgr, - tlsManager: tlsManager, - pluginBuilder: pluginBuilder, - dialerManager: dialerManager, - allowACMEByPass: allowACMEByPass, - deniedEncodedPathCharacters: deniedEncodedPathCharacters, - parser: parser, + entryPointsTCP: entryPointsTCP, + entryPointsUDP: entryPointsUDP, + managerFactory: managerFactory, + observabilityMgr: observabilityMgr, + tlsManager: tlsManager, + pluginBuilder: pluginBuilder, + dialerManager: dialerManager, + allowACMEByPass: allowACMEByPass, + parser: parser, }, nil } @@ -111,7 +104,7 @@ func (f *RouterFactory) CreateRouters(rtConf *runtime.Configuration) (map[string middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, f.pluginBuilder) - routerManager := router.NewManager(rtConf, serviceManager, middlewaresBuilder, f.observabilityMgr, f.tlsManager, f.parser, f.deniedEncodedPathCharacters) + routerManager := router.NewManager(rtConf, serviceManager, middlewaresBuilder, f.observabilityMgr, f.tlsManager, f.parser) routerManager.ParseRouterTree() diff --git a/pkg/server/server_entrypoint_tcp_test.go b/pkg/server/server_entrypoint_tcp_test.go index 324a2e988..d7f985c08 100644 --- a/pkg/server/server_entrypoint_tcp_test.go +++ b/pkg/server/server_entrypoint_tcp_test.go @@ -526,8 +526,10 @@ func TestPathOperations(t *testing.T) { configuration.SetDefaults() // We need to allow some of the suspicious encoded characters to test the path operations in case they are authorized. - configuration.HTTP.EncodedCharacters.AllowEncodedSlash = true - configuration.HTTP.EncodedCharacters.AllowEncodedPercent = true + configuration.HTTP.EncodedCharacters = &static.EncodedCharacters{ + AllowEncodedSlash: true, + AllowEncodedPercent: true, + } // Create the HTTP server using newHTTPServer. server, err := newHTTPServer(t.Context(), ln, configuration, false, requestdecorator.New(nil))