Header injection / URL handling

MEDIUM
traefik/traefik
Commit: b93f02cd07b7
Affected: 3.7.0-ea.3 and earlier (prior to this patch in master)
2026-07-09 18:08 UTC

Description

The commit adds validation for the X-Forwarded-Prefix header in the API dashboard. Previously, a crafted value in X-Forwarded-Prefix could influence request routing or origin behavior via header/URL manipulation. The fix parses the header as a URL and rejects obviously absolute URLs (with a Host or Scheme), resetting the prefix to an empty value instead of trusting the client-provided value. This mitigates a potential header/URL injection attack that could cause misrouting or cross-origin concerns. The change is a concrete security fix (not just a dependency bump or refactor).

Proof of Concept

Proof-of-Concept (exploitation steps for testing in a vulnerable build): Prereqs: - Traefik API dashboard accessible (e.g., http://traefik.example/dashboard/). - An attacker or tester can send crafted HTTP requests to the dashboard service (e.g., within the same network). Attack steps (vulnerable behavior prior to the fix): 1) Attacker sends a request to the dashboard with a malicious X-Forwarded-Prefix header intended to influence origin or routing: curl -s -D - -H "X-Forwarded-Prefix: https://evil.example.com/prefix" http://traefik.example/dashboard/ 2) If the server trusts the header and uses the value to construct links, redirects, or origin handling, the dashboard could generate responses that reference the attacker-controlled domain (e.g., base URLs, redirect URLs, or asset links), potentially enabling UI-driven CSRF-like or phishing vectors or CORS-related behavior. 3) The attacker can observe any mismatched origin/redirects or loaded resources pointing to evil.example.com, indicating that the header affected routing or URL generation. Mitigation verification (post-fix expected behavior): - With the patch, Traefik parses X-Forwarded-Prefix; if the value is an absolute URL (scheme or host present), it is rejected and reset to empty. The attacker-controlled value should no longer influence routing or origins. Minimal test snippet (for automated checks): 1) curl -s -H "X-Forwarded-Prefix: https://evil.example.com/attack" http://traefik.example/dashboard/ | grep -i -E "Location|base|href|origin|dashboard" -n 2) The response should not contain attacker-controlled URLs and should reflect a sanitized/empty prefix instead of the injected value.

Commit Details

Author: romain

Date: 2026-07-08 15:39 UTC

Message:

Merge branch v3.7 into master

Triage Assessment

Vulnerability Type: Header injection / URL handling vulnerability

Confidence: MEDIUM

Reasoning:

The commit adds validation for the X-Forwarded-Prefix header in the API dashboard, rejecting obviously absolute URLs and resetting the prefix to empty. This mitigates a potential header/URL manipulation vulnerability where a crafted X-Forwarded-Prefix could influence request routing or origin behavior. The change is focused on input validation of a security-sensitive header, representing a concrete security fix rather than a mere refactor or dependency bump.

Verification Assessment

Vulnerability Type: Header injection / URL handling

Confidence: MEDIUM

Affected Versions: 3.7.0-ea.3 and earlier (prior to this patch in master)

Code Diff

diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 766790e785f..04a5853954d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -7,7 +7,7 @@ on: paths-ignore: - 'docs/**' - '**.md' - - 'script/gcg/**' + - 'script/release/**' env: CGO_ENABLED: 0 diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 83946905a3e..b7fd54b044f 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -11,7 +11,7 @@ on: paths-ignore: - 'docs/**' - '**.md' - - 'script/gcg/**' + - 'script/release/**' env: CGO_ENABLED: 0 diff --git a/.github/workflows/test-unit.yaml b/.github/workflows/test-unit.yaml index c5ea09071db..5f89a2dd92c 100644 --- a/.github/workflows/test-unit.yaml +++ b/.github/workflows/test-unit.yaml @@ -11,7 +11,7 @@ on: paths-ignore: - 'docs/**' - '**.md' - - 'script/gcg/**' + - 'script/release/**' permissions: contents: read diff --git a/.go-version b/.go-version index 5e2b9500276..24cffb85f86 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.25 +1.26 diff --git a/go.mod b/go.mod index 201fc7add2e..9523782b6b1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/traefik/traefik/v3 -go 1.25.0 +go 1.26.0 require ( github.com/BurntSushi/toml v1.6.0 @@ -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/http_test.go b/integration/http_test.go index b463735ff0f..ac68c16c49c 100644 --- a/integration/http_test.go +++ b/integration/http_test.go @@ -41,7 +41,7 @@ func (s *HTTPSuite) TestSimpleConfiguration() { Services: map[string]*dynamic.Service{ "serviceHTTP": { LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://bacon:80", @@ -80,5 +80,3 @@ func startTestServerWithResponse(response []byte) (ts *httptest.Server) { ts.Start() return ts } - -func pointer[T any](v T) *T { return &v } 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/api/handler.go b/pkg/api/handler.go index 49424d9be05..841ee03d64d 100644 --- a/pkg/api/handler.go +++ b/pkg/api/handler.go @@ -189,7 +189,7 @@ func extractType(element any) string { } } - if field.Kind() == reflect.Ptr && field.Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Elem().Kind() == reflect.Struct { if !field.IsNil() { return v.Type().Field(i).Name } diff --git a/pkg/api/handler_http_test.go b/pkg/api/handler_http_test.go index 4f736eed711..78216868410 100644 --- a/pkg/api/handler_http_test.go +++ b/pkg/api/handler_http_test.go @@ -18,8 +18,6 @@ import ( "github.com/traefik/traefik/v3/pkg/config/static" ) -func pointer[T any](v T) *T { return &v } - func TestHandler_HTTP(t *testing.T) { type expected struct { statusCode int @@ -414,7 +412,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", @@ -431,7 +429,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.2", @@ -500,7 +498,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", @@ -517,7 +515,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.2", @@ -534,7 +532,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.3", @@ -564,7 +562,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", @@ -582,7 +580,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.2", @@ -613,7 +611,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", @@ -631,7 +629,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.2", @@ -662,7 +660,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", @@ -691,7 +689,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", @@ -720,7 +718,7 @@ func TestHandler_HTTP(t *testing.T) { si := &runtime.ServiceInfo{ Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", diff --git a/pkg/api/handler_overview.go b/pkg/api/handler_overview.go index d9d55281cf7..ac526a44ff1 100644 --- a/pkg/api/handler_overview.go +++ b/pkg/api/handler_overview.go @@ -231,7 +231,7 @@ func getProviders(conf static.Configuration) []string { v := reflect.ValueOf(conf.Providers).Elem() for i := range v.NumField() { field := v.Field(i) - if field.Kind() == reflect.Ptr && field.Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Elem().Kind() == reflect.Struct { if !field.IsNil() { providers = append(providers, v.Type().Field(i).Name) } @@ -261,7 +261,7 @@ func getMetrics(conf static.Configuration) string { v := reflect.ValueOf(conf.Metrics).Elem() for i := range v.NumField() { field := v.Field(i) - if field.Kind() == reflect.Ptr && field.Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Elem().Kind() == reflect.Struct { if !field.IsNil() { return v.Type().Field(i).Name } @@ -279,7 +279,7 @@ func getTracing(conf static.Configuration) string { v := reflect.ValueOf(conf.Tracing).Elem() for i := range v.NumField() { field := v.Field(i) - if field.Kind() == reflect.Ptr && field.Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Elem().Kind() == reflect.Struct { if !field.IsNil() { return v.Type().Field(i).Name } diff --git a/pkg/api/handler_test.go b/pkg/api/handler_test.go index 3adfffe0617..46c9bb2080b 100644 --- a/pkg/api/handler_test.go +++ b/pkg/api/handler_test.go @@ -38,7 +38,7 @@ func TestHandler_RawData(t *testing.T) { "foo-service@myprovider": { Service: &dynamic.Service{ LoadBalancer: &dynamic.ServersLoadBalancer{ - PassHostHeader: pointer(true), + PassHostHeader: new(true), Servers: []dynamic.Server{ { URL: "http://127.0.0.1", diff --git a/pkg/cli/deprecation.go b/pkg/cli/deprecation.go index febd8e4fce7..4e5bf1bd8aa 100644 --- a/pkg/cli/deprecation.go +++ b/pkg/cli/deprecation.go @@ -238,9 +238,7 @@ func findTypedField(rType reflect.Type, node *parser.Node) (reflect.StructField, return reflect.Struc ... [truncated]
← Back to Alerts View on GitHub →