Header injection / URL handling
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]