Information Disclosure

HIGH
traefik/traefik
Commit: 0fdea20eb1e1
Affected: 3.7.0-ea.0 to 3.7.0-ea.2 (pre-fix) in the 3.7.x series; fix included in 3.7.0-ea.3
2026-04-30 17:01 UTC

Description

The commit adds an explicit ErrorRequestHeaders option to the Errors middleware (ErrorPage). By default, when ErrorRequestHeaders is nil, all the original request headers are forwarded to the error page backend. This creates an information disclosure surface where sensitive headers (e.g., Authorization, Cookie, etc.) could be leaked through error pages. The fix introduces a configurable allowlist (or empty list to forward no headers) to constrain which headers are sent to the error page backend, thereby reducing the risk of leaking sensitive header data in error responses. It also updates tests to cover the three modes: forward all headers (default), forward only allowlisted headers, and forward no headers.

Proof of Concept

PoC reproduction outline: 1) Prepare a simple error page backend (echo service) that prints the headers it receives, e.g.: - Python Flask (port 8081): from flask import Flask, request app = Flask(__name__) @app.route('/test') def test(): return f"X-Request-Id={request.headers.get('X-Request-Id')}\nAuthorization={request.headers.get('Authorization')}\n" app.run(port=8081) 2) Configure Traefik dynamic config for an error page without any ErrorRequestHeaders (default behavior): - Service: http://localhost:8081/test - Status: [503] - Query: /test 3) Trigger an error that would route to the error page, sending sensitive headers: curl -i http://localhost/anypath \ -H 'Authorization: Bearer secret' \ -H 'X-Request-Id: trace-123' 4) Observe the error page backend response showing both headers (e.g., Authorization and X-Request-Id), demonstrating leakage via the error page. 5) Update the dynamic config to set ErrorRequestHeaders to an allowlist, e.g. ["X-Request-Id"] (or [] to forward no headers): - ErrorPage{Service: 'error', Query: '/test', Status: ['503'], ErrorRequestHeaders: ['X-Request-Id']} 6) Trigger the same error again (same curl). The error page backend should now receive only X-Request-Id and not Authorization, proving the mitigation. Notes: - The Traefik config syntax varies by provider (file, docker, etc.). Adapt the snippet to your setup. - The tests in custom_errors_test.go already cover: (i) forwarding all headers by default, (ii) forwarding only allowlisted headers, and (iii) forwarding no headers.

Commit Details

Author: Gina A.

Date: 2026-04-24 12:40 UTC

Message:

Add errorRequestHeaders option to Errors middleware

Triage Assessment

Vulnerability Type: Information Disclosure

Confidence: HIGH

Reasoning:

The change adds an explicit control (ErrorRequestHeaders) over which request headers are forwarded to the error page backend. By default it forwards all headers for error pages, which could leak sensitive information in headers. The option to allowlist or disable forwarding reduces information disclosure risk and enforces header sanitization for error responses.

Verification Assessment

Vulnerability Type: Information Disclosure

Confidence: HIGH

Affected Versions: 3.7.0-ea.0 to 3.7.0-ea.2 (pre-fix) in the 3.7.x series; fix included in 3.7.0-ea.3

Code Diff

diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index 5609baa15a..15a31f9e9a 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -25,6 +25,7 @@ - "traefik.http.middlewares.middleware08.digestauth.removeheader=true" - "traefik.http.middlewares.middleware08.digestauth.users=foobar, foobar" - "traefik.http.middlewares.middleware08.digestauth.usersfile=foobar" +- "traefik.http.middlewares.middleware09.errors.errorrequestheaders=foobar, foobar" - "traefik.http.middlewares.middleware09.errors.query=foobar" - "traefik.http.middlewares.middleware09.errors.service=foobar" - "traefik.http.middlewares.middleware09.errors.status=foobar, foobar" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index 9469eb118e..01050476d2 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -148,6 +148,7 @@ status = ["foobar", "foobar"] service = "foobar" query = "foobar" + errorRequestHeaders = ["foobar", "foobar"] [http.middlewares.Middleware10] [http.middlewares.Middleware10.forwardAuth] address = "foobar" diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index f4820dc2fd..c616239a63 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -159,6 +159,9 @@ http: - foobar service: foobar query: foobar + errorRequestHeaders: + - foobar + - foobar Middleware10: forwardAuth: address: foobar diff --git a/docs/content/reference/dynamic-configuration/marathon-labels.json b/docs/content/reference/dynamic-configuration/marathon-labels.json index 2a3f2a366e..cbf98a2b06 100644 --- a/docs/content/reference/dynamic-configuration/marathon-labels.json +++ b/docs/content/reference/dynamic-configuration/marathon-labels.json @@ -25,6 +25,7 @@ "traefik.http.middlewares.middleware08.digestauth.removeheader": "true", "traefik.http.middlewares.middleware08.digestauth.users": "foobar, foobar", "traefik.http.middlewares.middleware08.digestauth.usersfile": "foobar", +"traefik.http.middlewares.middleware09.errors.errorrequestheaders": "foobar, foobar", "traefik.http.middlewares.middleware09.errors.query": "foobar", "traefik.http.middlewares.middleware09.errors.service": "foobar", "traefik.http.middlewares.middleware09.errors.status": "foobar, foobar", diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index 780e1d8fd2..88b1c56152 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -194,6 +194,10 @@ type ErrorPage struct { // Query defines the URL for the error page (hosted by service). // The {status} variable can be used in order to insert the status code in the URL. Query string `json:"query,omitempty" toml:"query,omitempty" yaml:"query,omitempty" export:"true"` + // ErrorRequestHeaders defines the list of request headers forwarded to the error page service. + // When nil (not set), all original request headers are forwarded. + // Set to an empty list to forward no headers, or list specific headers to forward only those. + ErrorRequestHeaders []string `json:"errorRequestHeaders,omitempty" toml:"errorRequestHeaders,omitempty" yaml:"errorRequestHeaders,omitempty" export:"true"` } // +k8s:deepcopy-gen=true diff --git a/pkg/middlewares/customerrors/custom_errors.go b/pkg/middlewares/customerrors/custom_errors.go index 3ee2ed0a2b..666e7d415e 100644 --- a/pkg/middlewares/customerrors/custom_errors.go +++ b/pkg/middlewares/customerrors/custom_errors.go @@ -40,6 +40,7 @@ type customErrors struct { backendHandler http.Handler httpCodeRanges types.HTTPCodeRanges backendQuery string + requestHeaders []string } // New creates a new custom error pages middleware. @@ -62,6 +63,7 @@ func New(ctx context.Context, next http.Handler, config dynamic.ErrorPage, servi backendHandler: backend, httpCodeRanges: httpCodeRanges, backendQuery: config.Query, + requestHeaders: config.ErrorRequestHeaders, }, nil } @@ -104,7 +106,15 @@ func (c *customErrors) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - utils.CopyHeaders(pageReq.Header, req.Header) + if c.requestHeaders != nil { + for _, header := range c.requestHeaders { + if values := req.Header.Values(header); len(values) > 0 { + pageReq.Header[http.CanonicalHeaderKey(header)] = values + } + } + } else { + utils.CopyHeaders(pageReq.Header, req.Header) + } c.backendHandler.ServeHTTP(newCodeModifier(rw, code), pageReq.WithContext(req.Context())) diff --git a/pkg/middlewares/customerrors/custom_errors_test.go b/pkg/middlewares/customerrors/custom_errors_test.go index bc3afe638e..f0bc1b1f15 100644 --- a/pkg/middlewares/customerrors/custom_errors_test.go +++ b/pkg/middlewares/customerrors/custom_errors_test.go @@ -23,6 +23,7 @@ func TestHandler(t *testing.T) { backendCode int backendErrorHandler http.HandlerFunc validate func(t *testing.T, recorder *httptest.ResponseRecorder) + requestHeaders map[string]string }{ { desc: "no error", @@ -154,6 +155,60 @@ func TestHandler(t *testing.T) { assert.Contains(t, recorder.Body.String(), "My 503 page.") }, }, + { + desc: "forward all headers by default", + errorPage: &dynamic.ErrorPage{Service: "error", Query: "/test", Status: []string{"503"}}, + requestHeaders: map[string]string{ + "X-Request-Id": "trace-abc", + "Authorization": "Bearer secret", + }, + backendCode: http.StatusServiceUnavailable, + backendErrorHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, r.Header.Get("X-Request-Id")) + fmt.Fprintln(w, r.Header.Get("Authorization")) + }), + validate: func(t *testing.T, recorder *httptest.ResponseRecorder) { + t.Helper() + assert.Contains(t, recorder.Body.String(), "trace-abc") + assert.Contains(t, recorder.Body.String(), "Bearer secret") + }, + }, + { + desc: "forward only allowlisted headers", + errorPage: &dynamic.ErrorPage{Service: "error", Query: "/test", Status: []string{"503"}, ErrorRequestHeaders: []string{"X-Request-Id"}}, + requestHeaders: map[string]string{ + "X-Request-Id": "trace-abc", + "Authorization": "Bearer secret", + }, + backendCode: http.StatusServiceUnavailable, + backendErrorHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, r.Header.Get("X-Request-Id")) + fmt.Fprintln(w, r.Header.Get("Authorization")) + }), + validate: func(t *testing.T, recorder *httptest.ResponseRecorder) { + t.Helper() + assert.Contains(t, recorder.Body.String(), "trace-abc") + assert.NotContains(t, recorder.Body.String(), "Bearer secret") + }, + }, + { + desc: "forward no headers", + errorPage: &dynamic.ErrorPage{Service: "error", Query: "/test", Status: []string{"503"}, ErrorRequestHeaders: []string{}}, + requestHeaders: map[string]string{ + "X-Request-Id": "trace-abc", + "Authorization": "Bearer secret", + }, + backendCode: http.StatusServiceUnavailable, + backendErrorHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, r.Header.Get("X-Request-Id")) + fmt.Fprintln(w, r.Header.Get("Authorization")) + }), + validate: func(t *testing.T, recorder *httptest.ResponseRecorder) { + t.Helper() + assert.NotContains(t, recorder.Body.String(), "trace-abc") + assert.NotContains(t, recorder.Body.String(), "Bearer secret") + }, + }, } for _, test := range testCases { @@ -174,6 +229,9 @@ func TestHandler(t *testing.T) { require.NoError(t, err) req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost/test?foo=bar&baz=buz", nil) + for k, v := range test.requestHeaders { + req.Header.Set(k, v) + } recorder := httptest.NewRecorder() errorPageHandler.ServeHTTP(recorder, req)
← Back to Alerts View on GitHub →