Path traversal / path-prefix normalization bypass via StripPrefix normalization

HIGH
traefik/traefik
Commit: 892bcc288b1c
Affected: 3.7.0-ea.3 and earlier in the 3.7.x ea line (pre-release 3.7.0-ea.*)
2026-06-06 04:06 UTC

Description

The commit patches a real vulnerability where a request path, after being processed by StripPrefix or StripPrefixRegex, could be normalized to a different path than the one used for stripping. An attacker could craft a path that, after normalization, bypasses the intended prefix stripping and potentially access resources beyond the allowed path. The fix stores the original stripped path, runs path.JoinPath() to canonicalize, then rejects the request with 400 if the canonicalized path differs from the original stripped path. This prevents bypass via URL normalization differences (e.g., dot segments like . or .. or similar manipulations) after prefix stripping.

Proof of Concept

PoC (pre-fix behavior demonstrated): Prerequisites: - Traefik configured with StripPrefix middleware for the prefix "/api" and a backend at http://127.0.0.1:8080/ - A simple backend that echoes the received path (for visibility) 1) Start a minimal backend that prints the forwarded path: Python (server.py): from http.server import BaseHTTPRequestHandler, HTTPServer class H(BaseHTTPRequestHandler): def do_GET(self): print("FORWARDED PATH:", self.path) self.send_response(200) self.end_headers() self.wfile.write(b"ok") HTTPServer(("127.0.0.1", 8080), H).serve_forever() 2) Start Traefik with StripPrefix middleware for "/api" pointing to http://127.0.0.1:8080 3) Attack request (before fix behavior): curl -i "http://localhost:<traefik-port>/api./foo" What happens before the fix (illustrative): - The StripPrefix would strip the "/api" prefix, producing a path such as ". /foo" (or similar non-normalized form). - The subsequent JoinPath() normalization could yield a canonical path like "/foo" and Traefik would forward the request to the backend with path "/foo" while the original request was "/api./foo". - The backend would see "/foo" and the attack could bypass certain path-based constraints because the original request was intended to be under "/api" but reached "/foo" behind the scenes. After the fix (what you should observe): Traefik rejects the request with 400 Bad Request when the original stripped path and the normalized path differ, preventing the bypass: - curl returns 400 Bad Request and the backend is not reached. Notes: - The exact console output will depend on your Traefik and backend setup. The core idea is that requests like "/api./foo" or "/api../secret" could previously be normalized to a backend path that bypasses the intended prefix, which this patch now rejects.

Commit Details

Author: Romain

Date: 2026-05-28 13:56 UTC

Message:

Reject requests with different paths after StripPrefix and StripPrefixRegex normalisation

Triage Assessment

Vulnerability Type: Path Traversal

Confidence: HIGH

Reasoning:

The patch adds a check after path normalization to ensure the path remains equivalent to the stripped/normalized path. If normalization changes the path, the request is rejected with 400. This prevents potential bypasses where a request could evade prefix stripping through normalization differences, guarding against path-based bypass vulnerabilities.

Verification Assessment

Vulnerability Type: Path traversal / path-prefix normalization bypass via StripPrefix normalization

Confidence: HIGH

Affected Versions: 3.7.0-ea.3 and earlier in the 3.7.x ea line (pre-release 3.7.0-ea.*)

Code Diff

diff --git a/pkg/middlewares/stripprefix/strip_prefix.go b/pkg/middlewares/stripprefix/strip_prefix.go index 6abd4391e4..29a6afddca 100644 --- a/pkg/middlewares/stripprefix/strip_prefix.go +++ b/pkg/middlewares/stripprefix/strip_prefix.go @@ -48,6 +48,8 @@ func (s *stripPrefix) GetTracingInformation() (string, ext.SpanKindEnum) { } func (s *stripPrefix) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + logger := log.FromContext(middlewares.GetLoggerCtx(req.Context(), s.name, typeName)) + for _, prefix := range s.prefixes { if strings.HasPrefix(req.URL.Path, prefix) { req.URL.Path = s.getPathStripped(req.URL.Path, prefix) @@ -58,10 +60,18 @@ func (s *stripPrefix) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // Here we are sanitizing the URL when the path is not empty, // as the JoinPath method is adding a leading slash if the path is empty // to be aligned with ensureLeadingSlash behavior. - if req.URL.Path != "" { + path := req.URL.Path + if path != "" { req.URL = req.URL.JoinPath() } + // Stop here if the normalization of the path produces a different path. + if path != req.URL.Path { + logger.Debugf("Rejecting request, sanitized path: %q is not equivalent to stripped path: %q", path, req.URL.Path) + http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + req.Header.Add(ForwardedPrefixHeader, prefix) req.RequestURI = req.URL.RequestURI() break diff --git a/pkg/middlewares/stripprefix/strip_prefix_test.go b/pkg/middlewares/stripprefix/strip_prefix_test.go index 1fa0330de1..b551154af7 100644 --- a/pkg/middlewares/stripprefix/strip_prefix_test.go +++ b/pkg/middlewares/stripprefix/strip_prefix_test.go @@ -191,10 +191,7 @@ func TestStripPrefix(t *testing.T) { Prefixes: []string{"/api"}, }, path: "/api./foo", - expectedStatusCode: http.StatusOK, - expectedPath: "/foo", - expectedRawPath: "", - expectedHeader: "/api", + expectedStatusCode: http.StatusBadRequest, }, { desc: "multiple dots in the path not stripped by the prefix", @@ -202,10 +199,7 @@ func TestStripPrefix(t *testing.T) { Prefixes: []string{"/api"}, }, path: "/api../foo", - expectedStatusCode: http.StatusOK, - expectedPath: "/foo", - expectedRawPath: "", - expectedHeader: "/api", + expectedStatusCode: http.StatusBadRequest, }, { desc: "multiple dots in the path not stripped by the prefix with forceSlash", @@ -214,10 +208,7 @@ func TestStripPrefix(t *testing.T) { ForceSlash: true, }, path: "/api../foo", - expectedStatusCode: http.StatusOK, - expectedPath: "/foo", - expectedRawPath: "", - expectedHeader: "/api", + expectedStatusCode: http.StatusBadRequest, }, } @@ -244,6 +235,10 @@ func TestStripPrefix(t *testing.T) { handler.ServeHTTP(resp, req) assert.Equal(t, test.expectedStatusCode, resp.Code, "Unexpected status code.") + if test.expectedStatusCode != http.StatusOK { + return + } + assert.Equal(t, test.expectedPath, actualPath, "Unexpected path.") assert.Equal(t, test.expectedRawPath, actualRawPath, "Unexpected raw path.") assert.Equal(t, test.expectedHeader, actualHeader, "Unexpected '%s' header.", ForwardedPrefixHeader) diff --git a/pkg/middlewares/stripprefixregex/strip_prefix_regex.go b/pkg/middlewares/stripprefixregex/strip_prefix_regex.go index b07966fd51..825e91bd77 100644 --- a/pkg/middlewares/stripprefixregex/strip_prefix_regex.go +++ b/pkg/middlewares/stripprefixregex/strip_prefix_regex.go @@ -50,6 +50,8 @@ func (s *stripPrefixRegex) GetTracingInformation() (string, ext.SpanKindEnum) { } func (s *stripPrefixRegex) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + logger := log.FromContext(middlewares.GetLoggerCtx(req.Context(), s.name, typeName)) + for _, exp := range s.expressions { parts := exp.FindStringSubmatch(req.URL.Path) if len(parts) > 0 && len(parts[0]) > 0 { @@ -68,10 +70,18 @@ func (s *stripPrefixRegex) ServeHTTP(rw http.ResponseWriter, req *http.Request) // Here we are sanitizing the URL when the path is not empty, // as the JoinPath method is adding a leading slash if the path is empty // to be aligned with ensureLeadingSlash behavior. - if req.URL.Path != "" { + path := req.URL.Path + if path != "" { req.URL = req.URL.JoinPath() } + // Stop here if the normalization of the path produces a different path. + if path != req.URL.Path { + logger.Debugf("Rejecting request, sanitized path: %q is not equivalent to stripped path: %q", path, req.URL.Path) + http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + req.RequestURI = req.URL.RequestURI() break } diff --git a/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go b/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go index c9c6b59347..3803421960 100644 --- a/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go +++ b/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go @@ -201,21 +201,13 @@ func TestStripPrefixRegex(t *testing.T) { desc: "/api./foo", config: dynamic.StripPrefixRegex{Regex: []string{"/api"}}, path: "/api./foo", - expectedStatusCode: http.StatusOK, - expectedPath: "/foo", - expectedRawPath: "", - expectedRequestURI: "/foo", - expectedHeader: "/api", + expectedStatusCode: http.StatusBadRequest, }, { desc: "/api../foo", config: dynamic.StripPrefixRegex{Regex: []string{"/api"}}, path: "/api../foo", - expectedStatusCode: http.StatusOK, - expectedPath: "/foo", - expectedRawPath: "", - expectedRequestURI: "/foo", - expectedHeader: "/api", + expectedStatusCode: http.StatusBadRequest, }, }
← Back to Alerts View on GitHub →