diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
index 3e6e68c48..5997a58cd 100644
--- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
+++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
@@ -1105,6 +1105,39 @@ spec:
containing user credentials.
type: string
type: object
+ encodedCharacters:
+ description: EncodedCharacters configures which encoded characters
+ are allowed in the request path.
+ properties:
+ allowEncodedBackSlash:
+ description: AllowEncodedBackSlash defines whether requests with
+ encoded back slash characters in the path are allowed.
+ type: boolean
+ allowEncodedHash:
+ description: AllowEncodedHash defines whether requests with encoded
+ hash characters in the path are allowed.
+ type: boolean
+ allowEncodedNullCharacter:
+ description: AllowEncodedNullCharacter defines whether requests
+ with encoded null characters in the path are allowed.
+ type: boolean
+ allowEncodedPercent:
+ description: AllowEncodedPercent defines whether requests with
+ encoded percent characters in the path are allowed.
+ type: boolean
+ allowEncodedQuestionMark:
+ description: AllowEncodedQuestionMark defines whether requests
+ with encoded question mark characters in the path are allowed.
+ type: boolean
+ allowEncodedSemicolon:
+ description: AllowEncodedSemicolon defines whether requests with
+ encoded semicolon characters in the path are allowed.
+ type: boolean
+ allowEncodedSlash:
+ description: AllowEncodedSlash defines whether requests with encoded
+ slash characters in the path are allowed.
+ type: boolean
+ type: object
errors:
description: |-
ErrorPage holds the custom error middleware configuration.
diff --git a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml
index e5ececaec..107d814f9 100644
--- a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml
+++ b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml
@@ -260,6 +260,39 @@ spec:
containing user credentials.
type: string
type: object
+ encodedCharacters:
+ description: EncodedCharacters configures which encoded characters
+ are allowed in the request path.
+ properties:
+ allowEncodedBackSlash:
+ description: AllowEncodedBackSlash defines whether requests with
+ encoded back slash characters in the path are allowed.
+ type: boolean
+ allowEncodedHash:
+ description: AllowEncodedHash defines whether requests with encoded
+ hash characters in the path are allowed.
+ type: boolean
+ allowEncodedNullCharacter:
+ description: AllowEncodedNullCharacter defines whether requests
+ with encoded null characters in the path are allowed.
+ type: boolean
+ allowEncodedPercent:
+ description: AllowEncodedPercent defines whether requests with
+ encoded percent characters in the path are allowed.
+ type: boolean
+ allowEncodedQuestionMark:
+ description: AllowEncodedQuestionMark defines whether requests
+ with encoded question mark characters in the path are allowed.
+ type: boolean
+ allowEncodedSemicolon:
+ description: AllowEncodedSemicolon defines whether requests with
+ encoded semicolon characters in the path are allowed.
+ type: boolean
+ allowEncodedSlash:
+ description: AllowEncodedSlash defines whether requests with encoded
+ slash characters in the path are allowed.
+ type: boolean
+ type: object
errors:
description: |-
ErrorPage holds the custom error middleware configuration.
diff --git a/docs/content/reference/routing-configuration/http/middlewares/encodedcharacters.md b/docs/content/reference/routing-configuration/http/middlewares/encodedcharacters.md
new file mode 100644
index 000000000..efa70a79f
--- /dev/null
+++ b/docs/content/reference/routing-configuration/http/middlewares/encodedcharacters.md
@@ -0,0 +1,62 @@
+---
+title: "Traefik EncodedCharacters Documentation"
+description: "In Traefik Proxy, the EncodedCharacters middleware controls which ambiguous reserved encoded characters are allowed in the request path. Read the technical documentation."
+---
+
+The EncodedCharacters middleware controls which ambiguous reserved encoded characters are allowed in the request path.
+
+When you use this middleware, by default, potentially dangerous encoded characters are rejected for security enhancement.
+
+## Configuration Examples
+
+```yaml tab="Docker & Swarm"
+# Allow encoded slash in the request path.
+labels:
+ - "traefik.http.middlewares.test-encodedchars.encodedcharacters.allowencodedslash=true"
+```
+
+```yaml tab="Kubernetes"
+# Allow encoded slash in the request path.
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: test-encodedchars
+spec:
+ encodedCharacters:
+ allowEncodedSlash: true
+```
+
+```yaml tab="Consul Catalog"
+# Allow encoded slash in the request path.
+- "traefik.http.middlewares.test-encodedchars.encodedcharacters.allowencodedslash=true"
+```
+
+```yaml tab="File (YAML)"
+# Allow encoded slash in the request path.
+http:
+ middlewares:
+ test-encodedchars:
+ encodedCharacters:
+ allowEncodedSlash: true
+```
+
+```toml tab="File (TOML)"
+# Allow encoded slash in the request path.
+[http.middlewares]
+ [http.middlewares.test-encodedchars.encodedCharacters]
+ allowEncodedSlash = true
+```
+
+## Configuration Options
+
+When you are configuring these options, check if your backend is fully compliant with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986).
+This helps avoid split-view situation, where Traefik and your backend interpret the same URL differently.
+
+| Field | Description | Default | Required |
+|-------------------------|--------------------------------------------------------------------|---------| -------- |
+| `allowEncodedSlash` | Allow encoded slash (`%2F` and `%2f`) in the request path. | `false` | No |
+| `allowEncodedBackSlash` | Allow encoded backslash (`%5C` and `%5c`) in the request path. | `false` | No |
+| `allowEncodedSemicolon` | Allow encoded semicolon (`%3B` and `%3b`) in the request path. | `false` | No |
+| `allowEncodedPercent` | Allow encoded percent (`%25`) in the request path. | `false` | No |
+| `allowEncodedQuestionMark` | Allow encoded question mark (`%3F` and `%3f`) in the request path. | `false` | No |
+| `allowEncodedHash` | Allow encoded hash (`%23`) in the request path. | `false` | No |
diff --git a/docs/content/reference/routing-configuration/http/middlewares/overview.md b/docs/content/reference/routing-configuration/http/middlewares/overview.md
index 5a287c70b..1c8ac86b7 100644
--- a/docs/content/reference/routing-configuration/http/middlewares/overview.md
+++ b/docs/content/reference/routing-configuration/http/middlewares/overview.md
@@ -18,8 +18,8 @@ Middlewares that use the same protocol can be combined into chains to fit every
## Available HTTP Middlewares
-| Middleware | Purpose | Area |
-|-------------------------------------------|---------------------------------------------------|-----------------------------|
+| Middleware | Purpose | Area |
+|------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------|-----------------------------|
| [AddPrefix](addprefix.md) | Adds a Path Prefix | Path Modifier |
| [BasicAuth](basicauth.md) | Adds Basic Authentication | Security, Authentication |
| [Buffering](buffering.md) | Buffers the request/response | Request Lifecycle |
@@ -28,6 +28,7 @@ Middlewares that use the same protocol can be combined into chains to fit every
| [Compress](compress.md) | Compresses the response | Content Modifier |
| [ContentType](contenttype.md) | Handles Content-Type auto-detection | Misc |
| [DigestAuth](digestauth.md) | Adds Digest Authentication | Security, Authentication |
+| [EncodedCharacters](encodedcharacters.md) | Defines allowed reserved encoded characters in the request path | Security, Request Lifecycle |
| [Errors](errorpages.md) | Defines custom error pages | Request Lifecycle |
| [ForwardAuth](forwardauth.md) | Delegates Authentication | Security, Authentication |
| [GrpcWeb](grpcweb.md) | Converts gRPC Web requests to HTTP/2 gRPC requests. | Request |
diff --git a/docs/content/reference/routing-configuration/other-providers/file.toml b/docs/content/reference/routing-configuration/other-providers/file.toml
index ec728baaa..8e2d3db5e 100644
--- a/docs/content/reference/routing-configuration/other-providers/file.toml
+++ b/docs/content/reference/routing-configuration/other-providers/file.toml
@@ -191,15 +191,24 @@
realm = "foobar"
headerField = "foobar"
[http.middlewares.Middleware09]
- [http.middlewares.Middleware09.errors]
+ [http.middlewares.Middleware09.encodedCharacters]
+ allowEncodedSlash = true
+ allowEncodedBackSlash = true
+ allowEncodedNullCharacter = true
+ allowEncodedSemicolon = true
+ allowEncodedPercent = true
+ allowEncodedQuestionMark = true
+ allowEncodedHash = true
+ [http.middlewares.Middleware10]
+ [http.middlewares.Middleware10.errors]
status = ["foobar", "foobar"]
service = "foobar"
query = "foobar"
- [http.middlewares.Middleware09.errors.statusRewrites]
+ [http.middlewares.Middleware10.errors.statusRewrites]
name0 = 42
name1 = 42
- [http.middlewares.Middleware10]
- [http.middlewares.Middleware10.forwardAuth]
+ [http.middlewares.Middleware11]
+ [http.middlewares.Middleware11.forwardAuth]
address = "foobar"
trustForwardHeader = true
authResponseHeaders = ["foobar", "foobar"]
@@ -211,17 +220,17 @@
maxBodySize = 42
preserveLocationHeader = true
preserveRequestMethod = true
- [http.middlewares.Middleware10.forwardAuth.tls]
+ [http.middlewares.Middleware11.forwardAuth.tls]
ca = "foobar"
cert = "foobar"
key = "foobar"
insecureSkipVerify = true
caOptional = true
- [http.middlewares.Middleware11]
- [http.middlewares.Middleware11.grpcWeb]
- allowOrigins = ["foobar", "foobar"]
[http.middlewares.Middleware12]
- [http.middlewares.Middleware12.headers]
+ [http.middlewares.Middleware12.grpcWeb]
+ allowOrigins = ["foobar", "foobar"]
+ [http.middlewares.Middleware13]
+ [http.middlewares.Middleware13.headers]
accessControlAllowCredentials = true
accessControlAllowHeaders = ["foobar", "foobar"]
accessControlAllowMethods = ["foobar", "foobar"]
@@ -252,49 +261,49 @@
sslTemporaryRedirect = true
sslHost = "foobar"
sslForceHost = true
- [http.middlewares.Middleware12.headers.customRequestHeaders]
+ [http.middlewares.Middleware13.headers.customRequestHeaders]
name0 = "foobar"
name1 = "foobar"
- [http.middlewares.Middleware12.headers.customResponseHeaders]
+ [http.middlewares.Middleware13.headers.customResponseHeaders]
name0 = "foobar"
name1 = "foobar"
- [http.middlewares.Middleware12.headers.sslProxyHeaders]
+ [http.middlewares.Middleware13.headers.sslProxyHeaders]
name0 = "foobar"
name1 = "foobar"
- [http.middlewares.Middleware13]
- [http.middlewares.Middleware13.ipAllowList]
+ [http.middlewares.Middleware14]
+ [http.middlewares.Middleware14.ipAllowList]
sourceRange = ["foobar", "foobar"]
rejectStatusCode = 42
- [http.middlewares.Middleware13.ipAllowList.ipStrategy]
- depth = 42
- excludedIPs = ["foobar", "foobar"]
- ipv6Subnet = 42
- [http.middlewares.Middleware14]
- [http.middlewares.Middleware14.ipWhiteList]
- sourceRange = ["foobar", "foobar"]
- [http.middlewares.Middleware14.ipWhiteList.ipStrategy]
+ [http.middlewares.Middleware14.ipAllowList.ipStrategy]
depth = 42
excludedIPs = ["foobar", "foobar"]
ipv6Subnet = 42
[http.middlewares.Middleware15]
- [http.middlewares.Middleware15.inFlightReq]
+ [http.middlewares.Middleware15.ipWhiteList]
+ sourceRange = ["foobar", "foobar"]
+ [http.middlewares.Middleware15.ipWhiteList.ipStrategy]
+ depth = 42
+ excludedIPs = ["foobar", "foobar"]
+ ipv6Subnet = 42
+ [http.middlewares.Middleware16]
+ [http.middlewares.Middleware16.inFlightReq]
amount = 42
- [http.middlewares.Middleware15.inFlightReq.sourceCriterion]
+ [http.middlewares.Middleware16.inFlightReq.sourceCriterion]
requestHeaderName = "foobar"
requestHost = true
- [http.middlewares.Middleware15.inFlightReq.sourceCriterion.ipStrategy]
+ [http.middlewares.Middleware16.inFlightReq.sourceCriterion.ipStrategy]
depth = 42
excludedIPs = ["foobar", "foobar"]
ipv6Subnet = 42
- [http.middlewares.Middleware16]
- [http.middlewares.Middleware16.passTLSClientCert]
+ [http.middlewares.Middleware17]
+ [http.middlewares.Middleware17.passTLSClientCert]
pem = true
- [http.middlewares.Middleware16.passTLSClientCert.info]
+ [http.middlewares.Middleware17.passTLSClientCert.info]
notAfter = true
notBefore = true
sans = true
serialNumber = true
- [http.middlewares.Middleware16.passTLSClientCert.info.subject]
+ [http.middlewares.Middleware17.passTLSClientCert.info.subject]
country = true
province = true
locality = true
@@ -303,7 +312,7 @@
commonName = true
serialNumber = true
domainComponent = true
- [http.middlewares.Middleware16.passTLSClientCert.info.issuer]
+ [http.middlewares.Middleware17.passTLSClientCert.info.issuer]
country = true
province = true
locality = true
@@ -311,27 +320,27 @@
commonName = true
serialNumber = true
domainComponent = true
- [http.middlewares.Middleware17]
- [http.middlewares.Middleware17.plugin]
- [http.middlewares.Middleware17.plugin.PluginConf0]
- name0 = "foobar"
- name1 = "foobar"
- [http.middlewares.Middleware17.plugin.PluginConf1]
- name0 = "foobar"
- name1 = "foobar"
[http.middlewares.Middleware18]
- [http.middlewares.Middleware18.rateLimit]
+ [http.middlewares.Middleware18.plugin]
+ [http.middlewares.Middleware18.plugin.PluginConf0]
+ name0 = "foobar"
+ name1 = "foobar"
+ [http.middlewares.Middleware18.plugin.PluginConf1]
+ name0 = "foobar"
+ name1 = "foobar"
+ [http.middlewares.Middleware19]
+ [http.middlewares.Middleware19.rateLimit]
average = 42
period = "42s"
burst = 42
- [http.middlewares.Middleware18.rateLimit.sourceCriterion]
+ [http.middlewares.Middleware19.rateLimit.sourceCriterion]
requestHeaderName = "foobar"
requestHost = true
- [http.middlewares.Middleware18.rateLimit.sourceCriterion.ipStrategy]
+ [http.middlewares.Middleware19.rateLimit.sourceCriterion.ipStrategy]
depth = 42
excludedIPs = ["foobar", "foobar"]
ipv6Subnet = 42
- [http.middlewares.Middleware18.rateLimit.redis]
+ [http.middlewares.Middleware19.rateLimit.redis]
endpoints = ["foobar", "foobar"]
username = "foobar"
password = "foobar"
@@ -342,38 +351,38 @@
readTimeout = "42s"
writeTimeout = "42s"
dialTimeout = "42s"
- [http.middlewares.Middleware18.rateLimit.redis.tls]
+ [http.middlewares.Middleware19.rateLimit.redis.tls]
ca = "foobar"
cert = "foobar"
key = "foobar"
insecureSkipVerify = true
- [http.middlewares.Middleware19]
- [http.middlewares.Middleware19.redirectRegex]
+ [http.middlewares.Middleware20]
+ [http.middlewares.Middleware20.redirectRegex]
regex = "foobar"
replacement = "foobar"
permanent = true
- [http.middlewares.Middleware20]
- [http.middlewares.Middleware20.redirectScheme]
+ [http.middlewares.Middleware21]
+ [http.middlewares.Middleware21.redirectScheme]
scheme = "foobar"
port = "foobar"
permanent = true
- [http.middlewares.Middleware21]
- [http.middlewares.Middleware21.replacePath]
- path = "foobar"
[http.middlewares.Middleware22]
- [http.middlewares.Middleware22.replacePathRegex]
+ [http.middlewares.Middleware22.replacePath]
+ path = "foobar"
+ [http.middlewares.Middleware23]
+ [http.middlewares.Middleware23.replacePathRegex]
regex = "foobar"
replacement = "foobar"
- [http.middlewares.Middleware23]
- [http.middlewares.Middleware23.retry]
+ [http.middlewares.Middleware24]
+ [http.middlewares.Middleware24.retry]
attempts = 42
initialInterval = "42s"
- [http.middlewares.Middleware24]
- [http.middlewares.Middleware24.stripPrefix]
+ [http.middlewares.Middleware25]
+ [http.middlewares.Middleware25.stripPrefix]
prefixes = ["foobar", "foobar"]
forceSlash = true
- [http.middlewares.Middleware25]
- [http.middlewares.Middleware25.stripPrefixRegex]
+ [http.middlewares.Middleware26]
+ [http.middlewares.Middleware26.stripPrefixRegex]
regex = ["foobar", "foobar"]
[http.serversTransports]
[http.serversTransports.ServersTransport0]
diff --git a/docs/content/reference/routing-configuration/other-providers/file.yaml b/docs/content/reference/routing-configuration/other-providers/file.yaml
index 0b6033287..dc56188e2 100644
--- a/docs/content/reference/routing-configuration/other-providers/file.yaml
+++ b/docs/content/reference/routing-configuration/other-providers/file.yaml
@@ -205,6 +205,15 @@ http:
realm: foobar
headerField: foobar
Middleware09:
+ encodedCharacters:
+ allowEncodedSlash: true
+ allowEncodedBackSlash: true
+ allowEncodedNullCharacter: true
+ allowEncodedSemicolon: true
+ allowEncodedPercent: true
+ allowEncodedQuestionMark: true
+ allowEncodedHash: true
+ Middleware10:
errors:
status:
- foobar
@@ -214,7 +223,7 @@ http:
name1: 42
service: foobar
query: foobar
- Middleware10:
+ Middleware11:
forwardAuth:
address: foobar
tls:
@@ -239,12 +248,12 @@ http:
maxBodySize: 42
preserveLocationHeader: true
preserveRequestMethod: true
- Middleware11:
+ Middleware12:
grpcWeb:
allowOrigins:
- foobar
- foobar
- Middleware12:
+ Middleware13:
headers:
customRequestHeaders:
name0: foobar
@@ -299,7 +308,7 @@ http:
sslTemporaryRedirect: true
sslHost: foobar
sslForceHost: true
- Middleware13:
+ Middleware14:
ipAllowList:
sourceRange:
- foobar
@@ -311,7 +320,7 @@ http:
- foobar
ipv6Subnet: 42
rejectStatusCode: 42
- Middleware14:
+ Middleware15:
ipWhiteList:
sourceRange:
- foobar
@@ -322,7 +331,7 @@ http:
- foobar
- foobar
ipv6Subnet: 42
- Middleware15:
+ Middleware16:
inFlightReq:
amount: 42
sourceCriterion:
@@ -334,7 +343,7 @@ http:
ipv6Subnet: 42
requestHeaderName: foobar
requestHost: true
- Middleware16:
+ Middleware17:
passTLSClientCert:
pem: true
info:
@@ -359,7 +368,7 @@ http:
commonName: true
serialNumber: true
domainComponent: true
- Middleware17:
+ Middleware18:
plugin:
PluginConf0:
name0: foobar
@@ -367,7 +376,7 @@ http:
PluginConf1:
name0: foobar
name1: foobar
- Middleware18:
+ Middleware19:
rateLimit:
average: 42
period: 42s
@@ -399,34 +408,34 @@ http:
readTimeout: 42s
writeTimeout: 42s
dialTimeout: 42s
- Middleware19:
+ Middleware20:
redirectRegex:
regex: foobar
replacement: foobar
permanent: true
- Middleware20:
+ Middleware21:
redirectScheme:
scheme: foobar
port: foobar
permanent: true
- Middleware21:
+ Middleware22:
replacePath:
path: foobar
- Middleware22:
+ Middleware23:
replacePathRegex:
regex: foobar
replacement: foobar
- Middleware23:
+ Middleware24:
retry:
attempts: 42
initialInterval: 42s
- Middleware24:
+ Middleware25:
stripPrefix:
prefixes:
- foobar
- foobar
forceSlash: true
- Middleware25:
+ Middleware26:
stripPrefixRegex:
regex:
- foobar
diff --git a/docs/content/security/request-path.md b/docs/content/security/request-path.md
index fe88c3142..0cb63fb45 100644
--- a/docs/content/security/request-path.md
+++ b/docs/content/security/request-path.md
@@ -133,3 +133,8 @@ entryPoints:
--entryPoints.websecure.http.encodedCharacters.allowEncodedQuestionMark=false
--entryPoints.websecure.http.encodedCharacters.allowEncodedHash=false
```
+
+!!! info "Encoded Characters filtering on a per-route basis"
+
+ If you need to configure encoded character filtering on a per-route basis, you can use the `EncodedCharacters` middleware.
+ Refer to the documentation for the [`EncodedCharacter` middleware](../reference/routing-configuration/http/middlewares/encodedcharacters.md) for detailed implementation instructions and configuration options.
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 77e5ce866..73185498f 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -292,6 +292,7 @@ nav:
- 'ContentType': 'reference/routing-configuration/http/middlewares/contenttype.md'
- 'DigestAuth': 'reference/routing-configuration/http/middlewares/digestauth.md'
- 'Distributed RateLimit ' : 'reference/routing-configuration/http/middlewares/distributed-ratelimit.md'
+ - 'EncodedCharacters': 'reference/routing-configuration/http/middlewares/encodedcharacters.md'
- 'Errors': 'reference/routing-configuration/http/middlewares/errorpages.md'
- 'ForwardAuth': 'reference/routing-configuration/http/middlewares/forwardauth.md'
- 'GrpcWeb': 'reference/routing-configuration/http/middlewares/grpcweb.md'
diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml
index ff40d64d0..a525c8d65 100644
--- a/integration/fixtures/k8s/01-traefik-crd.yml
+++ b/integration/fixtures/k8s/01-traefik-crd.yml
@@ -1106,6 +1106,39 @@ spec:
containing user credentials.
type: string
type: object
+ encodedCharacters:
+ description: EncodedCharacters configures which encoded characters
+ are allowed in the request path.
+ properties:
+ allowEncodedBackSlash:
+ description: AllowEncodedBackSlash defines whether requests with
+ encoded back slash characters in the path are allowed.
+ type: boolean
+ allowEncodedHash:
+ description: AllowEncodedHash defines whether requests with encoded
+ hash characters in the path are allowed.
+ type: boolean
+ allowEncodedNullCharacter:
+ description: AllowEncodedNullCharacter defines whether requests
+ with encoded null characters in the path are allowed.
+ type: boolean
+ allowEncodedPercent:
+ description: AllowEncodedPercent defines whether requests with
+ encoded percent characters in the path are allowed.
+ type: boolean
+ allowEncodedQuestionMark:
+ description: AllowEncodedQuestionMark defines whether requests
+ with encoded question mark characters in the path are allowed.
+ type: boolean
+ allowEncodedSemicolon:
+ description: AllowEncodedSemicolon defines whether requests with
+ encoded semicolon characters in the path are allowed.
+ type: boolean
+ allowEncodedSlash:
+ description: AllowEncodedSlash defines whether requests with encoded
+ slash characters in the path are allowed.
+ type: boolean
+ type: object
errors:
description: |-
ErrorPage holds the custom error middleware configuration.
diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go
index f2681247e..1d03e47cc 100644
--- a/pkg/config/dynamic/middlewares.go
+++ b/pkg/config/dynamic/middlewares.go
@@ -27,6 +27,7 @@ type Middleware struct {
IPWhiteList *IPWhiteList `json:"ipWhiteList,omitempty" toml:"ipWhiteList,omitempty" yaml:"ipWhiteList,omitempty" export:"true"`
IPAllowList *IPAllowList `json:"ipAllowList,omitempty" toml:"ipAllowList,omitempty" yaml:"ipAllowList,omitempty" export:"true"`
Headers *Headers `json:"headers,omitempty" toml:"headers,omitempty" yaml:"headers,omitempty" export:"true"`
+ EncodedCharacters *EncodedCharacters `json:"encodedCharacters,omitempty" toml:"encodedCharacters,omitempty" yaml:"encodedCharacters,omitempty" export:"true"`
Errors *ErrorPage `json:"errors,omitempty" toml:"errors,omitempty" yaml:"errors,omitempty" export:"true"`
RateLimit *RateLimit `json:"rateLimit,omitempty" toml:"rateLimit,omitempty" yaml:"rateLimit,omitempty" export:"true"`
RedirectRegex *RedirectRegex `json:"redirectRegex,omitempty" toml:"redirectRegex,omitempty" yaml:"redirectRegex,omitempty" export:"true"`
@@ -217,6 +218,26 @@ type DigestAuth struct {
// +k8s:deepcopy-gen=true
+// EncodedCharacters configures which encoded characters are allowed in the request path.
+type EncodedCharacters struct {
+ // AllowEncodedSlash defines whether requests with encoded slash characters in the path are allowed.
+ AllowEncodedSlash bool `json:"allowEncodedSlash,omitempty" toml:"allowEncodedSlash,omitempty" yaml:"allowEncodedSlash,omitempty" export:"true"`
+ // AllowEncodedBackSlash defines whether requests with encoded back slash characters in the path are allowed.
+ AllowEncodedBackSlash bool `json:"allowEncodedBackSlash,omitempty" toml:"allowEncodedBackSlash,omitempty" yaml:"allowEncodedBackSlash,omitempty" export:"true"`
+ // AllowEncodedNullCharacter defines whether requests with encoded null characters in the path are allowed.
+ AllowEncodedNullCharacter bool `json:"allowEncodedNullCharacter,omitempty" toml:"allowEncodedNullCharacter,omitempty" yaml:"allowEncodedNullCharacter,omitempty" export:"true"`
+ // AllowEncodedSemicolon defines whether requests with encoded semicolon characters in the path are allowed.
+ AllowEncodedSemicolon bool `json:"allowEncodedSemicolon,omitempty" toml:"allowEncodedSemicolon,omitempty" yaml:"allowEncodedSemicolon,omitempty" export:"true"`
+ // AllowEncodedPercent defines whether requests with encoded percent characters in the path are allowed.
+ AllowEncodedPercent bool `json:"allowEncodedPercent,omitempty" toml:"allowEncodedPercent,omitempty" yaml:"allowEncodedPercent,omitempty" export:"true"`
+ // AllowEncodedQuestionMark defines whether requests with encoded question mark characters in the path are allowed.
+ AllowEncodedQuestionMark bool `json:"allowEncodedQuestionMark,omitempty" toml:"allowEncodedQuestionMark,omitempty" yaml:"allowEncodedQuestionMark,omitempty" export:"true"`
+ // AllowEncodedHash defines whether requests with encoded hash characters in the path are allowed.
+ AllowEncodedHash bool `json:"allowEncodedHash,omitempty" toml:"allowEncodedHash,omitempty" yaml:"allowEncodedHash,omitempty" export:"true"`
+}
+
+// +k8s:deepcopy-gen=true
+
// ErrorPage holds the custom error middleware configuration.
// This middleware returns a custom page in lieu of the default, according to configured ranges of HTTP Status codes.
type ErrorPage struct {
diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go
index 48065822e..f19b3448c 100644
--- a/pkg/config/dynamic/zz_generated.deepcopy.go
+++ b/pkg/config/dynamic/zz_generated.deepcopy.go
@@ -306,6 +306,22 @@ func (in *DigestAuth) DeepCopy() *DigestAuth {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *EncodedCharacters) DeepCopyInto(out *EncodedCharacters) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EncodedCharacters.
+func (in *EncodedCharacters) DeepCopy() *EncodedCharacters {
+ if in == nil {
+ return nil
+ }
+ out := new(EncodedCharacters)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ErrorPage) DeepCopyInto(out *ErrorPage) {
*out = *in
@@ -905,6 +921,11 @@ func (in *Middleware) DeepCopyInto(out *Middleware) {
*out = new(Headers)
(*in).DeepCopyInto(*out)
}
+ if in.EncodedCharacters != nil {
+ in, out := &in.EncodedCharacters, &out.EncodedCharacters
+ *out = new(EncodedCharacters)
+ **out = **in
+ }
if in.Errors != nil {
in, out := &in.Errors, &out.Errors
*out = new(ErrorPage)
diff --git a/pkg/middlewares/encodedcharacters/encoded_characters.go b/pkg/middlewares/encodedcharacters/encoded_characters.go
new file mode 100644
index 000000000..e4859a3ea
--- /dev/null
+++ b/pkg/middlewares/encodedcharacters/encoded_characters.go
@@ -0,0 +1,100 @@
+package encodedcharacters
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/traefik/traefik/v3/pkg/config/dynamic"
+ "github.com/traefik/traefik/v3/pkg/middlewares"
+)
+
+const typeName = "EncodedCharacters"
+
+type encodedCharacters struct {
+ next http.Handler
+ deniedCharacters map[string]struct{}
+ name string
+}
+
+// NewEncodedCharacters creates an Encoded Characters middleware.
+func NewEncodedCharacters(ctx context.Context, next http.Handler, config dynamic.EncodedCharacters, name string) http.Handler {
+ middlewares.GetLogger(ctx, name, typeName).Debug().Msg("Creating middleware")
+
+ return &encodedCharacters{
+ next: next,
+ deniedCharacters: mapDeniedCharacters(config),
+ name: name,
+ }
+}
+
+func (ec *encodedCharacters) GetTracingInformation() (string, string) {
+ return ec.name, typeName
+}
+
+func (ec *encodedCharacters) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+ logger := middlewares.GetLogger(req.Context(), ec.name, typeName)
+
+ if len(ec.deniedCharacters) == 0 {
+ ec.next.ServeHTTP(rw, req)
+ return
+ }
+
+ escapedPath := req.URL.EscapedPath()
+
+ for i := 0; i < len(escapedPath); i++ {
+ if escapedPath[i] != '%' {
+ continue
+ }
+
+ // This should never happen as the standard library will reject requests containing invalid percent-encodings.
+ // This discards URLs with a percent character at the end.
+ if i+2 >= len(escapedPath) {
+ rw.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ // This rejects a request with a path containing the given encoded characters.
+ if _, exists := ec.deniedCharacters[escapedPath[i:i+3]]; exists {
+ logger.Debug().Msgf("Rejecting request because it contains encoded character %s in the URL path: %s", escapedPath[i:i+3], escapedPath)
+ rw.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ i += 2
+ }
+
+ ec.next.ServeHTTP(rw, req)
+}
+
+// mapDeniedCharacters returns a map of unallowed encoded characters.
+func mapDeniedCharacters(config dynamic.EncodedCharacters) map[string]struct{} {
+ characters := make(map[string]struct{})
+
+ if !config.AllowEncodedSlash {
+ characters["%2F"] = struct{}{}
+ characters["%2f"] = struct{}{}
+ }
+ if !config.AllowEncodedBackSlash {
+ characters["%5C"] = struct{}{}
+ characters["%5c"] = struct{}{}
+ }
+ if !config.AllowEncodedNullCharacter {
+ characters["%00"] = struct{}{}
+ }
+ if !config.AllowEncodedSemicolon {
+ characters["%3B"] = struct{}{}
+ characters["%3b"] = struct{}{}
+ }
+ if !config.AllowEncodedPercent {
+ characters["%25"] = struct{}{}
+ }
+ if !config.AllowEncodedQuestionMark {
+ characters["%3F"] = struct{}{}
+ characters["%3f"] = struct{}{}
+ }
+ if !config.AllowEncodedHash {
+ characters["%23"] = struct{}{}
+ }
+
+ return characters
+}
diff --git a/pkg/middlewares/encodedcharacters/encoded_characters_test.go b/pkg/middlewares/encodedcharacters/encoded_characters_test.go
new file mode 100644
index 000000000..a32d5c528
--- /dev/null
+++ b/pkg/middlewares/encodedcharacters/encoded_characters_test.go
@@ -0,0 +1,191 @@
+package encodedcharacters
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/traefik/traefik/v3/pkg/config/dynamic"
+)
+
+func TestEncodedCharacters(t *testing.T) {
+ testCases := []struct {
+ desc string
+ config dynamic.EncodedCharacters
+ path string
+ expectedStatusCode int
+ }{
+ {
+ desc: "deny encoded slash",
+ config: dynamic.EncodedCharacters{},
+ path: "/foo%2fbar",
+ expectedStatusCode: http.StatusBadRequest,
+ },
+ {
+ desc: "allow encoded slash",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedSlash: true,
+ },
+ path: "/foo%2fbar",
+ expectedStatusCode: http.StatusOK,
+ },
+ {
+ desc: "deny encoded backslash",
+ config: dynamic.EncodedCharacters{},
+ path: "/foo%5cbar",
+ expectedStatusCode: http.StatusBadRequest,
+ },
+ {
+ desc: "allow encoded backslash",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedBackSlash: true,
+ },
+ path: "/foo%5cbar",
+ expectedStatusCode: http.StatusOK,
+ },
+ {
+ desc: "deny encoded null character",
+ config: dynamic.EncodedCharacters{},
+ path: "/foo%00bar",
+ expectedStatusCode: http.StatusBadRequest,
+ },
+ {
+ desc: "allow encoded null character",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedNullCharacter: true,
+ },
+ path: "/foo%00bar",
+ expectedStatusCode: http.StatusOK,
+ },
+ {
+ desc: "deny encoded semi colon",
+ config: dynamic.EncodedCharacters{},
+ path: "/foo%3bbar",
+ expectedStatusCode: http.StatusBadRequest,
+ },
+ {
+ desc: "allow encoded semi colon",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedSemicolon: true,
+ },
+ path: "/foo%3bbar",
+ expectedStatusCode: http.StatusOK,
+ },
+ {
+ desc: "deny encoded percent",
+ config: dynamic.EncodedCharacters{},
+ path: "/foo%25bar",
+ expectedStatusCode: http.StatusBadRequest,
+ },
+ {
+ desc: "allow encoded percent",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedPercent: true,
+ },
+ path: "/foo%25bar",
+ expectedStatusCode: http.StatusOK,
+ },
+ {
+ desc: "deny encoded question mark",
+ config: dynamic.EncodedCharacters{},
+ path: "/foo%3fbar",
+ expectedStatusCode: http.StatusBadRequest,
+ },
+ {
+ desc: "allow encoded question mark",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedQuestionMark: true,
+ },
+ path: "/foo%3fbar",
+ expectedStatusCode: http.StatusOK,
+ },
+ {
+ desc: "deny encoded hash",
+ config: dynamic.EncodedCharacters{},
+ path: "/foo%23bar",
+ expectedStatusCode: http.StatusBadRequest,
+ },
+ {
+ desc: "allow encoded hash",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedHash: true,
+ },
+ path: "/foo%23bar",
+ expectedStatusCode: http.StatusOK,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {})
+ handler := NewEncodedCharacters(t.Context(), next, test.config, "test-encoded-characters")
+
+ req := httptest.NewRequest(http.MethodGet, test.path, nil)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, req)
+ require.Equal(t, test.expectedStatusCode, recorder.Code)
+ })
+ }
+}
+
+func TestMapDeniedCharacters(t *testing.T) {
+ testCases := []struct {
+ desc string
+ config dynamic.EncodedCharacters
+ expectedDeniedChar map[string]struct{}
+ }{
+ {
+ desc: "deny all characters",
+ config: dynamic.EncodedCharacters{},
+ expectedDeniedChar: map[string]struct{}{
+ "%2F": {}, "%2f": {}, // slash
+ "%5C": {}, "%5c": {}, // backslash
+ "%00": {}, // null
+ "%3B": {}, "%3b": {}, // semicolon
+ "%25": {}, // percent
+ "%3F": {}, "%3f": {}, // question mark
+ "%23": {}, // hash
+ },
+ },
+ {
+ desc: "allow only encoded slash",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedSlash: true,
+ },
+ expectedDeniedChar: map[string]struct{}{
+ "%5C": {}, "%5c": {}, // backslash
+ "%00": {}, // null
+ "%3B": {}, "%3b": {}, // semicolon
+ "%25": {}, // percent
+ "%3F": {}, "%3f": {}, // question mark
+ "%23": {}, // hash
+ },
+ },
+ {
+ desc: "allow all characters",
+ config: dynamic.EncodedCharacters{
+ AllowEncodedSlash: true,
+ AllowEncodedBackSlash: true,
+ AllowEncodedNullCharacter: true,
+ AllowEncodedSemicolon: true,
+ AllowEncodedPercent: true,
+ AllowEncodedQuestionMark: true,
+ AllowEncodedHash: true,
+ },
+ expectedDeniedChar: map[string]struct{}{},
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ deniedMap := mapDeniedCharacters(test.config)
+ require.Equal(t, test.expectedDeniedChar, deniedMap)
+ require.Len(t, deniedMap, len(test.expectedDeniedChar))
+ })
+ }
+}
diff --git a/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/middlewarespec.go b/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/middlewarespec.go
index 4e4a4c063..48ace65fb 100644
--- a/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/middlewarespec.go
+++ b/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/middlewarespec.go
@@ -43,6 +43,7 @@ type MiddlewareSpecApplyConfiguration struct {
IPWhiteList *dynamic.IPWhiteList `json:"ipWhiteList,omitempty"`
IPAllowList *dynamic.IPAllowList `json:"ipAllowList,omitempty"`
Headers *dynamic.Headers `json:"headers,omitempty"`
+ EncodedCharacters *dynamic.EncodedCharacters `json:"encodedCharacters,omitempty"`
Errors *ErrorPageApplyConfiguration `json:"errors,omitempty"`
RateLimit *RateLimitApplyConfiguration `json:"rateLimit,omitempty"`
RedirectRegex *dynamic.RedirectRegex `json:"redirectRegex,omitempty"`
@@ -139,6 +140,14 @@ func (b *MiddlewareSpecApplyConfiguration) WithHeaders(value dynamic.Headers) *M
return b
}
+// WithEncodedCharacters sets the EncodedCharacters field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the EncodedCharacters field is set to the value of the last call.
+func (b *MiddlewareSpecApplyConfiguration) WithEncodedCharacters(value dynamic.EncodedCharacters) *MiddlewareSpecApplyConfiguration {
+ b.EncodedCharacters = &value
+ return b
+}
+
// WithErrors sets the Errors field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Errors field is set to the value of the last call.
diff --git a/pkg/provider/kubernetes/crd/kubernetes.go b/pkg/provider/kubernetes/crd/kubernetes.go
index de0b63159..309e792be 100644
--- a/pkg/provider/kubernetes/crd/kubernetes.go
+++ b/pkg/provider/kubernetes/crd/kubernetes.go
@@ -312,6 +312,7 @@ func (p *Provider) loadConfigurationFromCRD(ctx context.Context, client Client)
IPWhiteList: middleware.Spec.IPWhiteList,
IPAllowList: middleware.Spec.IPAllowList,
Headers: middleware.Spec.Headers,
+ EncodedCharacters: middleware.Spec.EncodedCharacters,
Errors: errorPage,
RateLimit: rateLimit,
RedirectRegex: middleware.Spec.RedirectRegex,
diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go
index d00e839d1..b237497f1 100644
--- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go
+++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go
@@ -36,6 +36,7 @@ type MiddlewareSpec struct {
IPWhiteList *dynamic.IPWhiteList `json:"ipWhiteList,omitempty"`
IPAllowList *dynamic.IPAllowList `json:"ipAllowList,omitempty"`
Headers *dynamic.Headers `json:"headers,omitempty"`
+ EncodedCharacters *dynamic.EncodedCharacters `json:"encodedCharacters,omitempty"`
Errors *ErrorPage `json:"errors,omitempty"`
RateLimit *RateLimit `json:"rateLimit,omitempty"`
RedirectRegex *dynamic.RedirectRegex `json:"redirectRegex,omitempty"`
diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go
index e354565fe..e0cddac6b 100644
--- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go
@@ -858,6 +858,11 @@ func (in *MiddlewareSpec) DeepCopyInto(out *MiddlewareSpec) {
*out = new(dynamic.Headers)
(*in).DeepCopyInto(*out)
}
+ if in.EncodedCharacters != nil {
+ in, out := &in.EncodedCharacters, &out.EncodedCharacters
+ *out = new(dynamic.EncodedCharacters)
+ **out = **in
+ }
if in.Errors != nil {
in, out := &in.Errors, &out.Errors
*out = new(ErrorPage)
diff --git a/pkg/server/middleware/middlewares.go b/pkg/server/middleware/middlewares.go
index 624a3c9c9..77098760c 100644
--- a/pkg/server/middleware/middlewares.go
+++ b/pkg/server/middleware/middlewares.go
@@ -20,6 +20,7 @@ import (
"github.com/traefik/traefik/v3/pkg/middlewares/compress"
"github.com/traefik/traefik/v3/pkg/middlewares/contenttype"
"github.com/traefik/traefik/v3/pkg/middlewares/customerrors"
+ "github.com/traefik/traefik/v3/pkg/middlewares/encodedcharacters"
"github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/headermodifier"
gapiredirect "github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/redirect"
"github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/urlrewrite"
@@ -192,6 +193,16 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) (
}
}
+ // EncodedCharacters
+ if config.EncodedCharacters != nil {
+ if middleware != nil {
+ return nil, badConf
+ }
+ middleware = func(next http.Handler) (http.Handler, error) {
+ return encodedcharacters.NewEncodedCharacters(ctx, next, *config.EncodedCharacters, middlewareName), nil
+ }
+ }
+
// CustomErrors
if config.Errors != nil {
if middleware != nil {