Path traversal / Input validation
Description
The commit adds safeguards for path normalization and X-Forwarded-Prefix handling in the dashboard and routing path. Specifically:
- It validates X-Forwarded-Prefix, rejecting values that parse as an absolute URL (having a Host or Scheme).
- It introduces path normalization checks in the replace-path-regex middleware: after normalizing the URL path, it rejects the request if the normalized path differs from the original path, preventing potential path-traversal or path-assembly bypass via unusual path constructs.
These changes mitigate header-based attacks and path-traversal weaknesses that could occur when untrusted input influences URL paths processed by Traefik or downstream middlewares.
Prior to this patch, crafted paths containing traversal sequences (e.g., ..) or manipulated URL joins could be forwarded to backends in a different form than originally requested, potentially bypassing access controls or exposing unintended resources.
Proof of Concept
PoC (high-level, actionable steps):
1) Environment setup:
- Run Traefik in front of a simple upstream service (e.g., a local HTTP server exposing a sensitive file at /secret).
- Configure a route/middleware that rewrites or matches paths in a way that would reveal the sensitive resource if path normalization is bypassed.
2) Attack on vulnerable path (pre-fix behavior):
- Send a request with a traversal-like path that would be normalized by the backend URL resolver to a sensitive path, e.g. GET http://localhost:8080/static/../../secret
- If Traefik previously forwarded the request without detecting the normalization discrepancy, the upstream could serve the sensitive resource (e.g., /secret).
3) Attack on headers (pre-fix behavior):
- Send a request with X-Forwarded-Prefix set to an absolute URL (e.g., X-Forwarded-Prefix: http://evil.example) to try bypassing path constraints.
- Depending on upstream handling, this could influence path interpretation before the fix rejects such headers.
4) Verification after fix:
- With the fix in place, Traefik should reject the request with 400 Bad Request if path normalization alters the path (e.g., /static/../../secret becomes a different path after JoinPath).
- The header-based attempt should be rejected as the header is parsed and deemed invalid.
5) Expected outcomes:
- Vulnerable behavior (pre-fix): traversal-like path could be forwarded to backend, potentially exposing restricted resources.
- Patched behavior (post-fix): Traefik rejects the request when normalization would change the path, mitigating the vulnerability.
Notes:
- The exact backend resource names and route configuration depend on your local test setup. The key is to illustrate that paths containing traversal components or manipulated prefixes are either forwarded (pre-fix) or rejected (post-fix).
Commit Details
Author: romain
Date: 2026-07-07 14:42 UTC
Message:
Merge branch v3.6 into v3.7
Triage Assessment
Vulnerability Type: Path traversal / Input validation
Confidence: MEDIUM
Reasoning:
The commit introduces input validation and URL/path normalization safeguards: sanitizing the X-Forwarded-Prefix header and rejecting requests where path normalization would alter the path. This reduces risk of header-based attacks and path traversal weaknesses.
Verification Assessment
Vulnerability Type: Path traversal / Input validation
Confidence: MEDIUM
Affected Versions: 3.6.x and earlier (pre-merge 3.7.x); 3.7.0-ea.3 includes this fix
Code Diff
diff --git a/go.mod b/go.mod
index bc8908c419f..fd380c16c69 100644
--- a/go.mod
+++ b/go.mod
@@ -85,20 +85,20 @@ require (
go.opentelemetry.io/collector/pdata v1.41.0
go.opentelemetry.io/contrib/bridges/otellogrus v0.13.0
go.opentelemetry.io/contrib/propagators/autoprop v0.63.0
- go.opentelemetry.io/otel v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
- go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
- go.opentelemetry.io/otel/log v0.19.0
- go.opentelemetry.io/otel/metric v1.43.0
- go.opentelemetry.io/otel/sdk v1.43.0
- go.opentelemetry.io/otel/sdk/log v0.19.0
- go.opentelemetry.io/otel/sdk/metric v1.43.0
- go.opentelemetry.io/otel/trace v1.43.0
+ go.opentelemetry.io/otel v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0
+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
+ go.opentelemetry.io/otel/log v0.20.0
+ go.opentelemetry.io/otel/metric v1.44.0
+ go.opentelemetry.io/otel/sdk v1.44.0
+ go.opentelemetry.io/otel/sdk/log v0.20.0
+ go.opentelemetry.io/otel/sdk/metric v1.44.0
+ go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/crypto v0.52.0
golang.org/x/mod v0.36.0
golang.org/x/net v0.55.0
@@ -238,7 +238,7 @@ require (
github.com/gophercloud/gophercloud v1.14.1 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/gravitational/trace v1.5.1 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/cronexpr v1.1.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -393,8 +393,8 @@ require (
golang.org/x/term v0.43.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
google.golang.org/api v0.280.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
@@ -406,7 +406,7 @@ require (
nhooyr.io/websocket v1.8.7 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
- software.sslmate.com/src/go-pkcs12 v0.7.1 // indirect
+ software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect
)
// Dynamic config extension.
diff --git a/integration/https_test.go b/integration/https_test.go
index 7821219ad06..702de16c455 100644
--- a/integration/https_test.go
+++ b/integration/https_test.go
@@ -501,7 +501,7 @@ func (s *HTTPSSuite) TestWithDefaultCertificateNoSNI() {
assert.Equal(s.T(), "h2", proto)
}
-// TestWithOverlappingCertificate involves a client sending a SNI hostname of
+// TestWithOverlappingStaticCertificate involves a client sending a SNI hostname of
// "www.snitest.com", which matches the CN of two static certificates:
// 'wildcard.snitest.com.crt', and `www.snitest.com.crt`. The test
// verifies that traefik returns the non-wildcard certificate.
@@ -533,7 +533,7 @@ func (s *HTTPSSuite) TestWithOverlappingStaticCertificate() {
assert.Equal(s.T(), "h2", proto)
}
-// TestWithOverlappingCertificate involves a client sending a SNI hostname of
+// TestWithOverlappingDynamicCertificate involves a client sending a SNI hostname of
// "www.snitest.com", which matches the CN of two dynamic certificates:
// 'wildcard.snitest.com.crt', and `www.snitest.com.crt`. The test
// verifies that traefik returns the non-wildcard certificate.
diff --git a/pkg/api/dashboard/dashboard.go b/pkg/api/dashboard/dashboard.go
index d5c5c10129b..86805c5f253 100644
--- a/pkg/api/dashboard/dashboard.go
+++ b/pkg/api/dashboard/dashboard.go
@@ -82,7 +82,15 @@ func Append(router *mux.Router, basePath string, customAssets fs.FS) error {
HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
xfPrefix := req.Header.Get("X-Forwarded-Prefix")
- // Validates that the X-Forwarded-Prefix value contains a relative URL.
+ // X-Forwarded-Prefix is a trusted header: the upstream hop is
+ // responsible for setting a valid, sanitized value and stripping
+ // any value coming from an untrusted client.
+ //
+ // The check below only rejects an obviously absolute URL (carrying a Host or
+ // Scheme). It is intentionally not an exhaustive validator: values a browser
+ // may later re-interpret as cross-origin (e.g. backslash sequences like
+ // "\\foo.com", normalised to "//foo.com" per the WHATWG URL spec) are the
+ // trusted upstream's responsibility.
if u, err := url.Parse(xfPrefix); err != nil || u.Host != "" || u.Scheme != "" {
log.Error().Msgf("X-Forwarded-Prefix contains an invalid value: %s, defaulting to empty prefix", xfPrefix)
xfPrefix = ""
diff --git a/pkg/middlewares/replacepathregex/replace_path_regex.go b/pkg/middlewares/replacepathregex/replace_path_regex.go
index 42cd79404b5..05b64e2bb82 100644
--- a/pkg/middlewares/replacepathregex/replace_path_regex.go
+++ b/pkg/middlewares/replacepathregex/replace_path_regex.go
@@ -46,6 +46,8 @@ func (rp *replacePathRegex) GetTracingInformation() (string, string) {
}
func (rp *replacePathRegex) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+ logger := middlewares.GetLogger(req.Context(), rp.name, typeName)
+
currentPath := req.URL.RawPath
if currentPath == "" {
currentPath = req.URL.EscapedPath()
@@ -68,6 +70,20 @@ func (rp *replacePathRegex) ServeHTTP(rw http.ResponseWriter, req *http.Request)
}
req.RequestURI = req.URL.RequestURI()
+
+ // 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.
+ 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.Debug().Msgf("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
+ }
}
rp.next.ServeHTTP(rw, req)
diff --git a/pkg/middlewares/replacepathregex/replace_path_regex_test.go b/pkg/middlewares/replacepathregex/replace_path_regex_test.go
index a1be75fcdf2..5ba92acedb8 100644
--- a/pkg/middlewares/replacepathregex/replace_path_regex_test.go
+++ b/pkg/middlewares/replacepathregex/replace_path_regex_test.go
@@ -13,13 +13,15 @@ import (
func TestReplacePathRegex(t *testing.T) {
testCases := []struct {
- desc string
- path string
- config dynamic.ReplacePathRegex
- expectedPath string
- expectedRawPath string
- expectedHeader string
- expectsError bool
+ desc string
+ path string
+ config dynamic.ReplacePathRegex
+ expectedPath string
+ expectedRawPath string
+ expectedHeader string
+ expectedRequestURI string
+ expectedStatusCode int
+ expectsError bool
}{
{
desc: "simple regex",
@@ -28,9 +30,10 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/who-am-i/$1",
Regex: `^/whoami/(.*)`,
},
- expectedPath: "/who-am-i/and/whoami",
- expectedRawPath: "/who-am-i/and/whoami",
- expectedHeader: "/whoami/and/whoami",
+ expectedPath: "/who-am-i/and/whoami",
+ expectedHeader: "/whoami/and/whoami",
+ expectedRequestURI: "/who-am-i/and/whoami",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "simple replace (no regex)",
@@ -39,9 +42,10 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/who-am-i",
Regex: `/whoami`,
},
- expectedPath: "/who-am-i/and/who-am-i",
- expectedRawPath: "/who-am-i/and/who-am-i",
- expectedHeader: "/whoami/and/whoami",
+ expectedPath: "/who-am-i/and/who-am-i",
+ expectedHeader: "/whoami/and/whoami",
+ expectedRequestURI: "/who-am-i/and/who-am-i",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "empty replacement",
@@ -50,9 +54,10 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "",
Regex: `/whoami`,
},
- expectedPath: "/and",
- expectedRawPath: "/and",
- expectedHeader: "/whoami/and/whoami",
+ expectedPath: "/and",
+ expectedHeader: "/whoami/and/whoami",
+ expectedRequestURI: "/and",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "empty trimmed replacement",
@@ -61,9 +66,10 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: " ",
Regex: `/whoami`,
},
- expectedPath: "/and",
- expectedRawPath: "/and",
- expectedHeader: "/whoami/and/whoami",
+ expectedPath: "/and",
+ expectedHeader: "/whoami/and/whoami",
+ expectedRequestURI: "/and",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "no match",
@@ -72,7 +78,9 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/whoami",
Regex: `/no-match`,
},
- expectedPath: "/whoami/and/whoami",
+ expectedPath: "/whoami/and/whoami",
+ expectedRequestURI: "/whoami/and/whoami",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "multiple replacement",
@@ -81,9 +89,10 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/downloads/$1-$2",
Regex: `^(?i)/downloads/([^/]+)/([^/]+)$`,
},
- expectedPath: "/downloads/src-source.go",
- expectedRawPath: "/downloads/src-source.go",
- expectedHeader: "/downloads/src/source.go",
+ expectedPath: "/downloads/src-source.go",
+ expectedHeader: "/downloads/src/source.go",
+ expectedRequestURI: "/downloads/src-source.go",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "invalid regular expression",
@@ -102,9 +111,11 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/foo%2Fbar",
Regex: `/aaa/bbb`,
},
- expectedPath: "/foo/bar",
- expectedRawPath: "/foo%2Fbar",
- expectedHeader: "/aaa/bbb",
+ expectedPath: "/foo/bar",
+ expectedRawPath: "/foo%2Fbar",
+ expectedHeader: "/aaa/bbb",
+ expectedRequestURI: "/foo%2Fbar",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "path and regex with escaped char",
@@ -113,9 +124,10 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/foo/bar",
Regex: `/aaa%2Fbbb`,
},
- expectedPath: "/foo/bar",
- expectedRawPath: "/foo/bar",
- expectedHeader: "/aaa%2Fbbb",
+ expectedPath: "/foo/bar",
+ expectedHeader: "/aaa%2Fbbb",
+ expectedRequestURI: "/foo/bar",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "path with escaped char (no match)",
@@ -124,8 +136,10 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/foo/bar",
Regex: `/aaa/bbb`,
},
- expectedPath: "/aaa/bbb",
- expectedRawPath: "/aaa%2Fbbb",
+ expectedPath: "/aaa/bbb",
+ expectedRawPath: "/aaa%2Fbbb",
+ expectedRequestURI: "/aaa%2Fbbb",
+ expectedStatusCode: http.StatusOK,
},
{
desc: "path with percent encoded backspace char",
@@ -134,19 +148,29 @@ func TestReplacePathRegex(t *testing.T) {
Replacement: "/$1",
Regex: `^/foo/(.*)`,
},
- expectedPath: "/\bbar",
- expectedRawPath: "/%08bar",
+ expectedPath: "/\bbar",
+ expectedRequestURI: "/%08bar",
+ expectedStatusCode: http.StatusOK,
+ },
+ {
+ desc: "path with ..",
+ path: "/foo../bar",
+ config: dynamic.ReplacePathRegex{
+ Replacement: "/$1",
+ Regex: `^/foo(.*)`,
+ },
+ expectedStatusCode: http.StatusBadRequest,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- var actualPath, actualRawPath, actualHeader, requestURI string
+ var actualPath, actualRawPath, actualHeader, actualRequestURI string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
actualPath = r.URL.Path
actualRawPath = r.URL.RawPath
actualHeader = r.Header.Get(replacepath.ReplacedPathHeader)
- requestURI = r.RequestURI
+ actualRequestURI = r.RequestURI
})
handler, err := New(t.Context(), next, test.config, "foo-replace-path-regexp")
@@ -162,16 +186,11 @@ func TestReplacePathRegex(t *testing.T) {
resp, err := http.Get(server.URL + test.path)
require.NoError(t, err, "Unexpected error while making test request")
- require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Equal(t, test.expectedStatusCode, resp.StatusCode)
assert.Equal(t, test.expectedPath, actualPath, "Unexpected path.")
assert.Equal(t, test.expectedRawPath, actualRawPath, "Unexpected raw path.")
-
- if actualRawPath == "" {
- assert.Equal(t, actualPath, requestURI, "Unexpected request URI.")
- } else {
- assert.Equal(t, actualRawPath, requestURI, "Unexpected request URI.")
- }
+ assert.Equal(t, test.expectedRequestURI, actualRequestURI, "Unexpected request URI.")
if test.expectedHeader != "" {
assert.Equal(t, test.expectedHeader, actualHeader, "Unexpected '%s' header.", replacepath.ReplacedPathHeader)
diff --git a/pkg/provider/kubernetes/crd/fixtures/tcp/with_servers_transport_cross_provider.yml b/pkg/provider/kubernetes/crd/fixtures/tcp/with_servers_transport_cross_provider.yml
new file mode 100644
index 00000000000..967d7e4e923
--- /dev/null
+++ b/pkg/provider/kubernetes/crd/fixtures/tcp/with_servers_transport_cross_provider.yml
@@ -0,0 +1,16 @@
+apiVersion: traefik.io/v1alpha1
+kind: IngressRouteTCP
+metadata:
+ name: test.route
+ namespace: default
+
+spec:
+ entryPoints:
+ - foo
+
+ routes:
+ - match: HostSNI(`foo.com`)
+ services:
+ - name: whoamitcp
+ port: 8000
+ serversTransport: foo@file
diff --git a/pkg/provider/kubernetes/crd/kubernetes_tcp.go b/pkg/provider/kubernetes/crd/kubernetes_tcp.go
index d62e13edb34..70084973bb4 100644
--- a/pkg/provider/kubernetes/crd/kubernetes_
... [truncated]