Information Disclosure
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)