Path traversal / Input validation

MEDIUM
traefik/traefik
Commit: 2306225189ed
Affected: 3.6.x and earlier (pre-merge 3.7.x); 3.7.0-ea.3 includes this fix
2026-07-09 18:07 UTC

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]
← Back to Alerts View on GitHub →