From 94eba471f19034627e94bdea3e2dd79d24de1af1 Mon Sep 17 00:00:00 2001 From: "Gina A." <70909035+gndz07@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:24:12 +0100 Subject: [PATCH] Add encodedCharacters middleware --- .../kubernetes-crd-definition-v1.yml | 33 +++ .../traefik.io_middlewares.yaml | 33 +++ .../http/middlewares/encodedcharacters.md | 62 ++++++ .../http/middlewares/overview.md | 5 +- .../other-providers/file.toml | 123 +++++------ .../other-providers/file.yaml | 41 ++-- docs/content/security/request-path.md | 5 + docs/mkdocs.yml | 1 + integration/fixtures/k8s/01-traefik-crd.yml | 33 +++ pkg/config/dynamic/middlewares.go | 21 ++ pkg/config/dynamic/zz_generated.deepcopy.go | 21 ++ .../encodedcharacters/encoded_characters.go | 100 +++++++++ .../encoded_characters_test.go | 191 ++++++++++++++++++ .../traefikio/v1alpha1/middlewarespec.go | 9 + pkg/provider/kubernetes/crd/kubernetes.go | 1 + .../crd/traefikio/v1alpha1/middleware.go | 1 + .../v1alpha1/zz_generated.deepcopy.go | 5 + pkg/server/middleware/middlewares.go | 11 + 18 files changed, 621 insertions(+), 75 deletions(-) create mode 100644 docs/content/reference/routing-configuration/http/middlewares/encodedcharacters.md create mode 100644 pkg/middlewares/encodedcharacters/encoded_characters.go create mode 100644 pkg/middlewares/encodedcharacters/encoded_characters_test.go 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 Traefik Hub API Gateway' : '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 {