Add encodedCharacters middleware

This commit is contained in:
Gina A.
2026-01-21 10:24:12 +01:00
committed by GitHub
parent 954eaab5f7
commit 94eba471f1
18 changed files with 621 additions and 75 deletions
+21
View File
@@ -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 {
@@ -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)
@@ -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
}
@@ -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))
})
}
}
@@ -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.
@@ -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,
@@ -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"`
@@ -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)
+11
View File
@@ -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 {