diff --git a/docs/content/migration/v2.md b/docs/content/migration/v2.md index 02924b976..d0a879781 100644 --- a/docs/content/migration/v2.md +++ b/docs/content/migration/v2.md @@ -677,3 +677,32 @@ it can lead to unsafe routing when the `sanitizePath` option is set to `false`. Setting the `sanitizePath` option to `false` is not safe. Ensure every request is properly url encoded instead. + +## v2.11.25 + +### Request Path Normalization + +Since `v2.11.25`, the request path is now normalized by decoding unreserved characters in the request path, +and also uppercasing the percent-encoded characters. +This follows [RFC 3986 percent-encoding normalization](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.2), +and [RFC 3986 case normalization](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1). + +The normalization happens before the request path is sanitized, +and cannot be disabled. +This notably helps with encoded dots characters (which are unreserved characters) to be sanitized properly. + +### Routing Path + +Since `v2.11.25`, the reserved characters [(as per RFC 3986)](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) are kept encoded in the request path when matching the router rules. +Those characters, when decoded, change the meaning of the request path for routing purposes, +and Traefik now keeps them encoded to avoid any ambiguity. + +### Request Path Matching Examples + +| Request Path | Router Rule | Traefik v2.11.24 | Traefik v2.11.25 | +|-------------------|------------------------|------------------|------------------| +| `/foo%2Fbar` | PathPrefix(`/foo/bar`) | Match | No match | +| `/foo/../bar` | PathPrefix(`/foo`) | No match | No match | +| `/foo/../bar` | PathPrefix(`/bar`) | Match | Match | +| `/foo/%2E%2E/bar` | PathPrefix(`/foo`) | Match | No match | +| `/foo/%2E%2E/bar` | PathPrefix(`/bar`) | No match | Match | diff --git a/docs/content/security/content-length.md b/docs/content/security/content-length.md index e09c62b6d..fff161881 100644 --- a/docs/content/security/content-length.md +++ b/docs/content/security/content-length.md @@ -5,7 +5,7 @@ description: "Enforce strict Content‑Length validation in Traefik by streaming Traefik acts as a streaming proxy. By default, it checks each chunk of data against the `Content-Length` header as it passes it on to the backend or client. This live check blocks truncated or over‑long streams without holding the entire message. -If you need Traefik to read and verify the full body before any data moves on, add the [buffering middleware](../../reference/routing-configuration/http/middlewares/buffering.md): +If you need Traefik to read and verify the full body before any data moves on, add the [buffering middleware](../middlewares/http/buffering.md): ```yaml http: diff --git a/go.mod b/go.mod index ec971dd9c..493de3354 100644 --- a/go.mod +++ b/go.mod @@ -391,7 +391,7 @@ require ( // Containous forks replace ( github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e - github.com/gorilla/mux => github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f + github.com/gorilla/mux => github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59 github.com/mailgun/minheap => github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 ) diff --git a/go.sum b/go.sum index c1bc34b23..aeff1bfa0 100644 --- a/go.sum +++ b/go.sum @@ -253,8 +253,8 @@ github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e h1:D+uTE github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e/go.mod h1:s8kLgBQolDbsJOPVIGCEEv9zGAKUUf/685Gi0Qqg8z8= github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 h1:aPspFRO6b94To3gl4yTDOEtpjFwXI7V2W+z0JcNljQ4= github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595/go.mod h1:+lHFbEasIiQVGzhVDVw/cn0ZaOzde2OwNncp1NhXV4c= -github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f h1:1uEtynq2C0ljy3630jt7EAxg8jZY2gy6YHdGwdqEpWw= -github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg= +github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59 h1:lJUOWjGohYjLKEfAz2nyI/dpzfKNPQLi5GLH7aaOZkw= +github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= diff --git a/integration/simple_test.go b/integration/simple_test.go index 7208b6599..b2c3166c6 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -1641,6 +1641,18 @@ func (s *SimpleSuite) TestSanitizePath() { target: "127.0.0.1:8000", expected: http.StatusFound, }, + { + desc: "Implicit encoded dot dots call to the route with a middleware", + request: "GET /without/%2E%2E/with HTTP/1.1\r\nHost: other.localhost\r\n\r\n", + target: "127.0.0.1:8000", + expected: http.StatusFound, + }, + { + desc: "Implicit with encoded unreserved character call to the route with a middleware", + request: "GET /%77ith HTTP/1.1\r\nHost: other.localhost\r\n\r\n", + target: "127.0.0.1:8000", + expected: http.StatusFound, + }, { desc: "Explicit call to the route with a middleware, and disable path sanitization", request: "GET /with HTTP/1.1\r\nHost: other.localhost\r\n\r\n", diff --git a/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-backend-resource.yml b/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-backend-resource.yml new file mode 100644 index 000000000..fa113eae8 --- /dev/null +++ b/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-backend-resource.yml @@ -0,0 +1,18 @@ +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: "" + namespace: testing + +spec: + rules: + - host: traefik.tchouk + http: + paths: + - path: /bar + backend: + resource: + kind: Service + name: service1 + pathType: Prefix + diff --git a/pkg/provider/kubernetes/ingress/fixtures/Ingress-without-backend.yml b/pkg/provider/kubernetes/ingress/fixtures/Ingress-without-backend.yml new file mode 100644 index 000000000..58a4cb4e6 --- /dev/null +++ b/pkg/provider/kubernetes/ingress/fixtures/Ingress-without-backend.yml @@ -0,0 +1,15 @@ +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: "" + namespace: testing + +spec: + rules: + - host: traefik.tchouk + http: + paths: + - path: /bar + backend: {} + pathType: Prefix + diff --git a/pkg/provider/kubernetes/ingress/kubernetes.go b/pkg/provider/kubernetes/ingress/kubernetes.go index d83f29cbd..591bdcf55 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes.go +++ b/pkg/provider/kubernetes/ingress/kubernetes.go @@ -315,6 +315,17 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl } for _, pa := range rule.HTTP.Paths { + if pa.Backend.Resource != nil { + // https://kubernetes.io/docs/concepts/services-networking/ingress/#resource-backend + logger.Error().Msg("Resource backends are not supported") + continue + } + + if pa.Backend.Service == nil { + logger.Error().Msg("Missing service definition") + continue + } + service, err := p.loadService(client, ingress.Namespace, pa.Backend) if err != nil { logger.Error(). @@ -492,15 +503,6 @@ func (p *Provider) shouldProcessIngress(ingress *netv1.Ingress, ingressClasses [ } func (p *Provider) loadService(client Client, namespace string, backend netv1.IngressBackend) (*dynamic.Service, error) { - if backend.Resource != nil { - // https://kubernetes.io/docs/concepts/services-networking/ingress/#resource-backend - return nil, errors.New("resource backends are not supported") - } - - if backend.Service == nil { - return nil, errors.New("missing service definition") - } - service, exists, err := client.GetService(namespace, backend.Service.Name) if err != nil { return nil, err diff --git a/pkg/provider/kubernetes/ingress/kubernetes_test.go b/pkg/provider/kubernetes/ingress/kubernetes_test.go index f6019b63c..548fc4124 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress/kubernetes_test.go @@ -522,6 +522,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { }, }, }, + { + desc: "Ingress with backend resource", + allowEmptyServices: true, + expected: &dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Middlewares: map[string]*dynamic.Middleware{}, + Routers: map[string]*dynamic.Router{}, + Services: map[string]*dynamic.Service{}, + }, + }, + }, + { + desc: "Ingress without backend", + allowEmptyServices: true, + expected: &dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Middlewares: map[string]*dynamic.Middleware{}, + Routers: map[string]*dynamic.Router{}, + Services: map[string]*dynamic.Service{}, + }, + }, + }, { desc: "Ingress with one service without endpoint", expected: &dynamic.Configuration{ diff --git a/pkg/server/server_entrypoint_tcp.go b/pkg/server/server_entrypoint_tcp.go index 6c1406624..5dcc64830 100644 --- a/pkg/server/server_entrypoint_tcp.go +++ b/pkg/server/server_entrypoint_tcp.go @@ -17,6 +17,7 @@ import ( "github.com/containous/alice" gokitmetrics "github.com/go-kit/kit/metrics" + "github.com/gorilla/mux" "github.com/pires/go-proxyproto" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -610,20 +611,6 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati return nil, err } - if configuration.HTTP.SanitizePath != nil && *configuration.HTTP.SanitizePath { - // sanitizePath is used to clean the URL path by removing /../, /./ and duplicate slash sequences, - // to make sure the path is interpreted by the backends as it is evaluated inside rule matchers. - handler = sanitizePath(handler) - } - - if configuration.HTTP.EncodeQuerySemicolons { - handler = encodeQuerySemicolons(handler) - } else { - handler = http.AllowQuerySemicolons(handler) - } - - handler = contenttype.DisableAutoDetection(handler) - debugConnection := os.Getenv(debugConnectionEnv) != "" if debugConnection || (configuration.Transport != nil && (configuration.Transport.KeepAliveMaxTime > 0 || configuration.Transport.KeepAliveMaxRequests > 0)) { handler = newKeepAliveMiddleware(handler, configuration.Transport.KeepAliveMaxRequests, configuration.Transport.KeepAliveMaxTime) @@ -635,6 +622,24 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati }) } + handler = contenttype.DisableAutoDetection(handler) + + if configuration.HTTP.EncodeQuerySemicolons { + handler = encodeQuerySemicolons(handler) + } else { + handler = http.AllowQuerySemicolons(handler) + } + + handler = routingPath(handler) + + // Note that the Path sanitization has to be done after the path normalization, + // hence the wrapping has to be done before the normalize path wrapping. + if configuration.HTTP.SanitizePath != nil && *configuration.HTTP.SanitizePath { + handler = sanitizePath(handler) + } + + handler = normalizePath(handler) + handler = denyFragment(handler) serverHTTP := &http.Server{ @@ -763,7 +768,7 @@ func denyFragment(h http.Handler) http.Handler { }) } -// sanitizePath removes the "..", "." and duplicate slash segments from the URL. +// sanitizePath removes the "..", "." and duplicate slash segments from the URL according to https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.3. // It cleans the request URL Path and RawPath, and updates the request URI. func sanitizePath(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -779,3 +784,158 @@ func sanitizePath(h http.Handler) http.Handler { h.ServeHTTP(rw, r2) }) } + +// unreservedCharacters contains the mapping of the percent-encoded form to the ASCII form +// of the unreserved characters according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.3. +var unreservedCharacters = map[string]rune{ + "%41": 'A', "%42": 'B', "%43": 'C', "%44": 'D', "%45": 'E', "%46": 'F', + "%47": 'G', "%48": 'H', "%49": 'I', "%4A": 'J', "%4B": 'K', "%4C": 'L', + "%4D": 'M', "%4E": 'N', "%4F": 'O', "%50": 'P', "%51": 'Q', "%52": 'R', + "%53": 'S', "%54": 'T', "%55": 'U', "%56": 'V', "%57": 'W', "%58": 'X', + "%59": 'Y', "%5A": 'Z', + + "%61": 'a', "%62": 'b', "%63": 'c', "%64": 'd', "%65": 'e', "%66": 'f', + "%67": 'g', "%68": 'h', "%69": 'i', "%6A": 'j', "%6B": 'k', "%6C": 'l', + "%6D": 'm', "%6E": 'n', "%6F": 'o', "%70": 'p', "%71": 'q', "%72": 'r', + "%73": 's', "%74": 't', "%75": 'u', "%76": 'v', "%77": 'w', "%78": 'x', + "%79": 'y', "%7A": 'z', + + "%30": '0', "%31": '1', "%32": '2', "%33": '3', "%34": '4', + "%35": '5', "%36": '6', "%37": '7', "%38": '8', "%39": '9', + + "%2D": '-', "%2E": '.', "%5F": '_', "%7E": '~', +} + +// normalizePath removes from the RawPath unreserved percent-encoded characters as they are equivalent to their non-encoded +// form according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 and capitalizes percent-encoded characters +// according to https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1. +func normalizePath(h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rawPath := req.URL.RawPath + + // When the RawPath is empty the encoded form of the Path is equivalent to the original request Path. + // Thus, the normalization is not needed as no unreserved characters were encoded and the encoded version + // of Path obtained with URL.EscapedPath contains only percent-encoded characters in upper case. + if rawPath == "" { + h.ServeHTTP(rw, req) + return + } + + var normalizedRawPathBuilder strings.Builder + for i := 0; i < len(rawPath); i++ { + if rawPath[i] != '%' { + normalizedRawPathBuilder.WriteString(string(rawPath[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(rawPath) { + rw.WriteHeader(http.StatusBadRequest) + return + } + + encodedCharacter := strings.ToUpper(rawPath[i : i+3]) + if r, unreserved := unreservedCharacters[encodedCharacter]; unreserved { + normalizedRawPathBuilder.WriteRune(r) + } else { + normalizedRawPathBuilder.WriteString(encodedCharacter) + } + + i += 2 + } + + normalizedRawPath := normalizedRawPathBuilder.String() + + // We do not have to alter the request URL as the original RawPath is already normalized. + if normalizedRawPath == rawPath { + h.ServeHTTP(rw, req) + return + } + + r2 := new(http.Request) + *r2 = *req + + // Decoding unreserved characters only alter the RAW version of the URL, + // as unreserved percent-encoded characters are equivalent to their non encoded form. + r2.URL.RawPath = normalizedRawPath + + // Because the reverse proxy director is building query params from RequestURI it needs to be updated as well. + r2.RequestURI = r2.URL.RequestURI() + + h.ServeHTTP(rw, r2) + }) +} + +// reservedCharacters contains the mapping of the percent-encoded form to the ASCII form +// of the reserved characters according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.2. +// By extension to https://datatracker.ietf.org/doc/html/rfc3986#section-2.1 the percent character is also considered a reserved character. +// Because decoding the percent character would change the meaning of the URL. +var reservedCharacters = map[string]rune{ + "%3A": ':', + "%2F": '/', + "%3F": '?', + "%23": '#', + "%5B": '[', + "%5D": ']', + "%40": '@', + "%21": '!', + "%24": '$', + "%26": '&', + "%27": '\'', + "%28": '(', + "%29": ')', + "%2A": '*', + "%2B": '+', + "%2C": ',', + "%3B": ';', + "%3D": '=', + "%25": '%', +} + +// routingPath decodes non-allowed characters in the EscapedPath and stores it in the context to be able to use it for routing. +// This allows using the decoded version of the non-allowed characters in the routing rules for a better UX. +// For example, the rule PathPrefix(`/foo bar`) will match the following request path `/foo%20bar`. +func routingPath(h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + escapedPath := req.URL.EscapedPath() + + var routingPathBuilder strings.Builder + for i := 0; i < len(escapedPath); i++ { + if escapedPath[i] != '%' { + routingPathBuilder.WriteString(string(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 + } + + encodedCharacter := escapedPath[i : i+3] + if _, reserved := reservedCharacters[encodedCharacter]; reserved { + routingPathBuilder.WriteString(encodedCharacter) + } else { + // This should never happen as the standard library will reject requests containing invalid percent-encodings. + decodedCharacter, err := url.PathUnescape(encodedCharacter) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + routingPathBuilder.WriteString(decodedCharacter) + } + + i += 2 + } + + h.ServeHTTP(rw, req.WithContext( + context.WithValue( + req.Context(), + mux.RoutingPathKey, + routingPathBuilder.String(), + ), + )) + }) +} diff --git a/pkg/server/server_entrypoint_tcp_test.go b/pkg/server/server_entrypoint_tcp_test.go index 9b54bce74..6ce62639b 100644 --- a/pkg/server/server_entrypoint_tcp_test.go +++ b/pkg/server/server_entrypoint_tcp_test.go @@ -13,10 +13,12 @@ import ( "testing" "time" + "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ptypes "github.com/traefik/paerser/types" "github.com/traefik/traefik/v3/pkg/config/static" + "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" tcprouter "github.com/traefik/traefik/v3/pkg/server/router/tcp" "github.com/traefik/traefik/v3/pkg/tcp" "golang.org/x/net/http2" @@ -424,3 +426,287 @@ func TestSanitizePath(t *testing.T) { }) } } + +func TestNormalizePath(t *testing.T) { + unreservedDecoded := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + unreserved := []string{ + "%41", "%42", "%43", "%44", "%45", "%46", "%47", "%48", "%49", "%4A", "%4B", "%4C", "%4D", "%4E", "%4F", "%50", "%51", "%52", "%53", "%54", "%55", "%56", "%57", "%58", "%59", "%5A", + "%61", "%62", "%63", "%64", "%65", "%66", "%67", "%68", "%69", "%6A", "%6B", "%6C", "%6D", "%6E", "%6F", "%70", "%71", "%72", "%73", "%74", "%75", "%76", "%77", "%78", "%79", "%7A", + "%30", "%31", "%32", "%33", "%34", "%35", "%36", "%37", "%38", "%39", + "%2D", "%2E", "%5F", "%7E", + } + + reserved := []string{ + "%3A", "%2F", "%3F", "%23", "%5B", "%5D", "%40", "%21", "%24", "%26", "%27", "%28", "%29", "%2A", "%2B", "%2C", "%3B", "%3D", "%25", + } + reservedJoined := strings.Join(reserved, "") + + unallowedCharacter := "%0a" // line feed + unallowedCharacterUpperCased := "%0A" // line feed upper case + + var callCount int + handler := normalizePath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + + wantRawPath := "/" + unreservedDecoded + reservedJoined + unallowedCharacterUpperCased + assert.Equal(t, wantRawPath, r.URL.RawPath) + })) + + req := httptest.NewRequest(http.MethodGet, "http://foo/"+strings.Join(unreserved, "")+reservedJoined+unallowedCharacter, http.NoBody) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, 1, callCount) +} + +func TestNormalizePath_malformedPercentEncoding(t *testing.T) { + tests := []struct { + desc string + path string + wantErr bool + }{ + { + desc: "well formed path", + path: "/%20", + }, + { + desc: "percent sign at the end", + path: "/%", + wantErr: true, + }, + { + desc: "incomplete percent encoding at the end", + path: "/%f", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var callCount int + handler := normalizePath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + })) + + req := httptest.NewRequest(http.MethodGet, "http://foo", http.NoBody) + req.URL.RawPath = test.path + + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if test.wantErr { + assert.Equal(t, http.StatusBadRequest, res.Code) + assert.Equal(t, 0, callCount) + } else { + assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, 1, callCount) + } + }) + } +} + +func TestRoutingPath(t *testing.T) { + tests := []struct { + desc string + path string + expRoutingPath string + expStatus int + }{ + { + desc: "unallowed percent-encoded character is decoded", + path: "/foo%20bar", + expRoutingPath: "/foo bar", + expStatus: http.StatusOK, + }, + { + desc: "reserved percent-encoded character is kept encoded", + path: "/foo%2Fbar", + expRoutingPath: "/foo%2Fbar", + expStatus: http.StatusOK, + }, + { + desc: "multiple mixed characters", + path: "/foo%20bar%2Fbaz%23qux", + expRoutingPath: "/foo bar%2Fbaz%23qux", + expStatus: http.StatusOK, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var gotRoute string + handler := routingPath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRoute, _ = r.Context().Value(mux.RoutingPathKey).(string) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://foo"+test.path, http.NoBody) + + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + assert.Equal(t, test.expStatus, res.Code) + assert.Equal(t, test.expRoutingPath, gotRoute) + }) + } +} + +// TestPathOperations tests the whole behavior of normalizePath, sanitizePath, and routingPath combined through the use of the createHTTPServer func. +// It aims to guarantee the server entrypoint handler is secure regarding a large variety of cases that could lead to path traversal attacks. +func TestPathOperations(t *testing.T) { + // Create a listener for the server. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + _ = ln.Close() + }) + + // Define the server configuration. + configuration := &static.EntryPoint{} + configuration.SetDefaults() + + // Create the HTTP server using createHTTPServer. + server, err := createHTTPServer(context.Background(), ln, configuration, false, requestdecorator.New(nil)) + require.NoError(t, err) + + server.Switcher.UpdateHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Path", r.URL.Path) + w.Header().Set("RawPath", r.URL.EscapedPath()) + w.Header().Set("RoutingPath", r.Context().Value(mux.RoutingPathKey).(string)) + w.WriteHeader(http.StatusOK) + })) + + go func() { + // server is expected to return an error if the listener is closed. + _ = server.Server.Serve(ln) + }() + + client := http.Client{ + Transport: &http.Transport{ + ResponseHeaderTimeout: 1 * time.Second, + }, + } + + tests := []struct { + desc string + rawPath string + expectedPath string + expectedRaw string + expectedRoutingPath string + expectedStatus int + }{ + { + desc: "normalize and sanitize path", + rawPath: "/a/../b/%41%42%43//%2f/", + expectedPath: "/b/ABC///", + expectedRaw: "/b/ABC/%2F/", + expectedRoutingPath: "/b/ABC/%2F/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with traversal attempt", + rawPath: "/../../b/", + expectedPath: "/b/", + expectedRaw: "/b/", + expectedRoutingPath: "/b/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with multiple traversal attempts", + rawPath: "/a/../../b/../c/", + expectedPath: "/c/", + expectedRaw: "/c/", + expectedRoutingPath: "/c/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with mixed traversal and valid segments", + rawPath: "/a/../b/./c/../d/", + expectedPath: "/b/d/", + expectedRaw: "/b/d/", + expectedRoutingPath: "/b/d/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with trailing slash and traversal", + rawPath: "/a/b/../", + expectedPath: "/a/", + expectedRaw: "/a/", + expectedRoutingPath: "/a/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with encoded traversal sequences", + rawPath: "/a/%2E%2E/%2E%2E/b/", + expectedPath: "/b/", + expectedRaw: "/b/", + expectedRoutingPath: "/b/", + expectedStatus: http.StatusOK, + }, + { + desc: "path with over-encoded traversal sequences", + rawPath: "/a/%252E%252E/%252E%252E/b/", + expectedPath: "/a/%2E%2E/%2E%2E/b/", + expectedRaw: "/a/%252E%252E/%252E%252E/b/", + expectedRoutingPath: "/a/%252E%252E/%252E%252E/b/", + expectedStatus: http.StatusOK, + }, + { + desc: "routing path with unallowed percent-encoded character", + rawPath: "/foo%20bar", + expectedPath: "/foo bar", + expectedRaw: "/foo%20bar", + expectedRoutingPath: "/foo bar", + expectedStatus: http.StatusOK, + }, + { + desc: "routing path with reserved percent-encoded character", + rawPath: "/foo%2Fbar", + expectedPath: "/foo/bar", + expectedRaw: "/foo%2Fbar", + expectedRoutingPath: "/foo%2Fbar", + expectedStatus: http.StatusOK, + }, + { + desc: "routing path with unallowed and reserved percent-encoded character", + rawPath: "/foo%20%2Fbar", + expectedPath: "/foo /bar", + expectedRaw: "/foo%20%2Fbar", + expectedRoutingPath: "/foo %2Fbar", + expectedStatus: http.StatusOK, + }, + { + desc: "path with traversal and encoded slash", + rawPath: "/a/..%2Fb/", + expectedPath: "/a/../b/", + expectedRaw: "/a/..%2Fb/", + expectedRoutingPath: "/a/..%2Fb/", + expectedStatus: http.StatusOK, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, "http://"+ln.Addr().String()+test.rawPath, http.NoBody) + require.NoError(t, err) + + res, err := client.Do(req) + require.NoError(t, err) + + assert.Equal(t, test.expectedStatus, res.StatusCode) + assert.Equal(t, test.expectedPath, res.Header.Get("Path")) + assert.Equal(t, test.expectedRaw, res.Header.Get("RawPath")) + assert.Equal(t, test.expectedRoutingPath, res.Header.Get("RoutingPath")) + }) + } +}