Path traversal / URL handling
Description
The commit hardens URL handling after stripping a configured prefix by trimming whitespace from prefixes and normalizing the remaining request URL via URL.JoinPath(), then updating ForwardedPrefixHeader and RequestURI. This addresses a potential path traversal / URL handling issue where crafted URLs containing dot-segments (e.g., "/api../secret" or "/api./foo") could bypass the prefix-stripping logic or leave the forwarded path in an unnormalized form. By canonicalizing the path after stripping, the risk of forwarding unsafe or unintended paths to the upstream service is reduced.
Proof of Concept
PoC concept (exploit demonstration):
Scenario: A Traefik instance uses a strip-prefix middleware for the prefix '/api'. An attacker crafts a request that abuses dot-segments after stripping the prefix, potentially bypassing the stripping logic or producing an ambiguous path for the upstream service.
1) Setup: Traefik fronting a backend service. Configure stripPrefix with Prefixes: ['/api'].
2) Malicious request (pre-fix concern):
GET http://traefik.local/api../admin HTTP/1.1
Host: traefik.local
3) Expected problematic behavior (without proper normalization): The remainder after stripping '/api' is '../admin' or './admin'. Depending on downstream handling, the upstream might interpret this in a way that bypasses route constraints or accesses an unintended path.
4) Post-fix behavior (with the change): After stripping, Traefik applies URL.JoinPath() on the remaining path, normalizing it to '/admin' (or '/foo' for '/api./foo'), and updates RequestURI. This canonicalization prevents certain dot-segment tricks from being forwarded as-are, reducing the risk of path traversal-like issues.
To observe the effect in practice, configure the backend to log the RequestURI it receives and issue the same request before and after applying this fix. You should see:
- Before fix: forwarded path may contain unnormalized segments like '/..//admin' or './admin'.
- After fix: forwarded path is normalized to a canonical path such as '/admin'.
Optional concrete test (using a backend that echoes the request path):
- curl -s -D - 'http://traefik.local/api../admin' | head
- The backend logs the RequestURI or Path; compare behavior with and without the fix.
Note: The exact observed values depend on the backend’s handling of dot-segments and the Go URLPath normalization semantics in the runtime. The key point is that normalization occurs after stripping, reducing bypass opportunities via crafted dot-segment paths.
Commit Details
Author: Kevin Pollet
Date: 2026-04-16 12:26 UTC
Message:
Sanitize the request URL after stripping the prefix
Triage Assessment
Vulnerability Type: Path traversal / URL handling
Confidence: MEDIUM
Reasoning:
The commit changes URL sanitization after stripping a prefix, ensuring the request URL is properly normalized before forwarding. This reduces the risk that crafted URLs could bypass prefix-stripping logic or leak/mis-handle path information, which can be related to path traversal or improper URL handling in middleware. The added sanitization and header adjustments indicate hardening of request URL handling in security-relevant code paths.
Verification Assessment
Vulnerability Type: Path traversal / URL handling
Confidence: MEDIUM
Affected Versions: 3.7.0-ea.1 through 3.7.0-ea.2 (pre-fix); vulnerable before 3.7.0-ea.3
Code Diff
diff --git a/pkg/middlewares/stripprefix/strip_prefix.go b/pkg/middlewares/stripprefix/strip_prefix.go
index 53e54c0ece..6abd4391e4 100644
--- a/pkg/middlewares/stripprefix/strip_prefix.go
+++ b/pkg/middlewares/stripprefix/strip_prefix.go
@@ -29,8 +29,14 @@ type stripPrefix struct {
// New creates a new strip prefix middleware.
func New(ctx context.Context, next http.Handler, config dynamic.StripPrefix, name string) (http.Handler, error) {
log.FromContext(middlewares.GetLoggerCtx(ctx, name, typeName)).Debug("Creating middleware")
+
+ prefixes := make([]string, len(config.Prefixes))
+ for i, p := range config.Prefixes {
+ prefixes[i] = strings.TrimSpace(p)
+ }
+
return &stripPrefix{
- prefixes: config.Prefixes,
+ prefixes: prefixes,
forceSlash: config.ForceSlash,
next: next,
name: name,
@@ -48,19 +54,22 @@ func (s *stripPrefix) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if req.URL.RawPath != "" {
req.URL.RawPath = s.getRawPathStripped(req.URL.RawPath, prefix)
}
- s.serveRequest(rw, req, strings.TrimSpace(prefix))
- return
+
+ // 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 != "" {
+ req.URL = req.URL.JoinPath()
+ }
+
+ req.Header.Add(ForwardedPrefixHeader, prefix)
+ req.RequestURI = req.URL.RequestURI()
+ break
}
}
s.next.ServeHTTP(rw, req)
}
-func (s *stripPrefix) serveRequest(rw http.ResponseWriter, req *http.Request, prefix string) {
- req.Header.Add(ForwardedPrefixHeader, prefix)
- req.RequestURI = req.URL.RequestURI()
- s.next.ServeHTTP(rw, req)
-}
-
func (s *stripPrefix) getPathStripped(urlPath, prefix string) string {
if s.forceSlash {
// Only for compatibility reason with the previous behavior,
diff --git a/pkg/middlewares/stripprefix/strip_prefix_test.go b/pkg/middlewares/stripprefix/strip_prefix_test.go
index 18d66c7145..1fa0330de1 100644
--- a/pkg/middlewares/stripprefix/strip_prefix_test.go
+++ b/pkg/middlewares/stripprefix/strip_prefix_test.go
@@ -185,6 +185,40 @@ func TestStripPrefix(t *testing.T) {
expectedRawPath: "/a%2Fb",
expectedHeader: "/api/",
},
+ {
+ desc: "dot in the path not stripped by the prefix",
+ config: dynamic.StripPrefix{
+ Prefixes: []string{"/api"},
+ },
+ path: "/api./foo",
+ expectedStatusCode: http.StatusOK,
+ expectedPath: "/foo",
+ expectedRawPath: "",
+ expectedHeader: "/api",
+ },
+ {
+ desc: "multiple dots in the path not stripped by the prefix",
+ config: dynamic.StripPrefix{
+ Prefixes: []string{"/api"},
+ },
+ path: "/api../foo",
+ expectedStatusCode: http.StatusOK,
+ expectedPath: "/foo",
+ expectedRawPath: "",
+ expectedHeader: "/api",
+ },
+ {
+ desc: "multiple dots in the path not stripped by the prefix with forceSlash",
+ config: dynamic.StripPrefix{
+ Prefixes: []string{"/api"},
+ ForceSlash: true,
+ },
+ path: "/api../foo",
+ expectedStatusCode: http.StatusOK,
+ expectedPath: "/foo",
+ expectedRawPath: "",
+ expectedHeader: "/api",
+ },
}
for _, test := range testCases {
diff --git a/pkg/middlewares/stripprefixregex/strip_prefix_regex.go b/pkg/middlewares/stripprefixregex/strip_prefix_regex.go
index 8a1997941f..b07966fd51 100644
--- a/pkg/middlewares/stripprefixregex/strip_prefix_regex.go
+++ b/pkg/middlewares/stripprefixregex/strip_prefix_regex.go
@@ -65,9 +65,15 @@ func (s *stripPrefixRegex) ServeHTTP(rw http.ResponseWriter, req *http.Request)
req.URL.RawPath = ensureLeadingSlash(req.URL.RawPath[encodedPrefixLen(req.URL.RawPath, prefix):])
}
+ // 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 != "" {
+ req.URL = req.URL.JoinPath()
+ }
+
req.RequestURI = req.URL.RequestURI()
- s.next.ServeHTTP(rw, req)
- return
+ break
}
}
diff --git a/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go b/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go
index c0caf73056..c9c6b59347 100644
--- a/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go
+++ b/pkg/middlewares/stripprefixregex/strip_prefix_regex_test.go
@@ -153,7 +153,7 @@ func TestStripPrefixRegex(t *testing.T) {
path: "/b/ap%69/test",
expectedStatusCode: http.StatusOK,
expectedPath: "/test",
- expectedRawPath: "/test",
+ expectedRawPath: "",
expectedRequestURI: "/test",
expectedHeader: "/b/api/",
},
@@ -197,6 +197,26 @@ func TestStripPrefixRegex(t *testing.T) {
expectedRequestURI: "/a%2Fb",
expectedHeader: "/t /test",
},
+ {
+ desc: "/api./foo",
+ config: dynamic.StripPrefixRegex{Regex: []string{"/api"}},
+ path: "/api./foo",
+ expectedStatusCode: http.StatusOK,
+ expectedPath: "/foo",
+ expectedRawPath: "",
+ expectedRequestURI: "/foo",
+ expectedHeader: "/api",
+ },
+ {
+ desc: "/api../foo",
+ config: dynamic.StripPrefixRegex{Regex: []string{"/api"}},
+ path: "/api../foo",
+ expectedStatusCode: http.StatusOK,
+ expectedPath: "/foo",
+ expectedRawPath: "",
+ expectedRequestURI: "/foo",
+ expectedHeader: "/api",
+ },
}
for _, test := range testCases {