Skip to content

Commit 59a8252

Browse files
mergify[bot]efd6
andauthored
x-pack/filebeat/input/{cel,httpjson}: strip sensitive headers on cross-origin redirects (#51434) (#51514)
When redirect.forward_headers is true, Authorization, Proxy-Authorization, and Cookie headers are now removed automatically on redirects that cross to a different host or downgrade from HTTPS to HTTP. This matches the behaviour of Go's net/http default redirect policy and prevents credential leakage to unintended origins via a compromised upstream server. A new redirect.sensitive_headers configuration option (defaulting to ["Authorization", "Proxy-Authorization", "Cookie"]) controls which headers are stripped. Operators who need cross-origin credential forwarding can set it to [] to restore the previous behaviour. Assisted-By: Cursor (cherry picked from commit 4941d3f) Co-authored-by: Dan Kortschak <dan.kortschak@elastic.co>
1 parent 188adea commit 59a8252

10 files changed

Lines changed: 349 additions & 28 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
kind: bug-fix
2+
summary: Strip sensitive headers on cross-origin redirects in httpjson and CEL inputs.
3+
description: |
4+
When redirect.forward_headers is true, the Authorization, Proxy-Authorization,
5+
and Cookie headers are now automatically removed on redirects that cross to a
6+
different host or downgrade from HTTPS to HTTP. A new redirect.sensitive_headers
7+
configuration option controls which headers are stripped; set it to [] to restore
8+
the previous behaviour of forwarding all headers unconditionally.
9+
component: filebeat

docs/reference/filebeat/filebeat-input-cel.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1101,14 +1101,25 @@ The maximum time to wait before a retry is attempted. Default: `60s`.
11011101

11021102
### `resource.redirect.forward_headers` [_resource_redirect_forward_headers]
11031103

1104-
When set to `true` request headers are forwarded in case of a redirect. Default: `false`.
1104+
When set to `true` request headers are forwarded in case of a redirect. Headers listed in `redirect.sensitive_headers` are removed automatically on cross-origin or HTTPS-to-HTTP redirects. Default: `false`.
11051105

11061106

11071107
### `resource.redirect.headers_ban_list` [_resource_redirect_headers_ban_list]
11081108

11091109
When `redirect.forward_headers` is set to `true`, all headers *except* the ones defined in this list will be forwarded. Default: `[]`.
11101110

11111111

1112+
### `resource.redirect.sensitive_headers` [_resource_redirect_sensitive_headers]
1113+
1114+
```{applies_to}
1115+
stack: ga 9.3+
1116+
```
1117+
1118+
A list of header names that are automatically removed when a redirect crosses to a different host or downgrades from HTTPS to HTTP. This prevents credential leakage to unintended origins. Default: `["Authorization", "Proxy-Authorization", "Cookie"]`.
1119+
1120+
Set to `[]` to disable cross-origin header stripping and forward all headers regardless of the redirect target (not recommended unless the target shares the same authentication domain).
1121+
1122+
11121123
### `resource.redirect.max_redirects` [_resource_redirect_max_redirects]
11131124

11141125
The maximum number of redirects to follow for a request. Default: `10`.

docs/reference/filebeat/filebeat-input-httpjson.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,14 +735,25 @@ The maximum time to wait before a retry is attempted. Default: `60s`.
735735

736736
### `request.redirect.forward_headers` [_request_redirect_forward_headers]
737737

738-
When set to `true` request headers are forwarded in case of a redirect. Default: `false`.
738+
When set to `true` request headers are forwarded in case of a redirect. Headers listed in `redirect.sensitive_headers` are removed automatically on cross-origin or HTTPS-to-HTTP redirects. Default: `false`.
739739

740740

741741
### `request.redirect.headers_ban_list` [_request_redirect_headers_ban_list]
742742

743743
When `redirect.forward_headers` is set to `true`, all headers *except* the ones defined in this list will be forwarded. Default: `[]`.
744744

745745

746+
### `request.redirect.sensitive_headers` [_request_redirect_sensitive_headers]
747+
748+
```{applies_to}
749+
stack: ga 9.3+
750+
```
751+
752+
A list of header names that are automatically removed when a redirect crosses to a different host or downgrades from HTTPS to HTTP. This prevents credential leakage to unintended origins. Default: `["Authorization", "Proxy-Authorization", "Cookie"]`.
753+
754+
Set to `[]` to disable cross-origin header stripping and forward all headers regardless of the redirect target (not recommended unless the target shares the same authentication domain).
755+
756+
746757
### `request.redirect.max_redirects` [_request_redirect_max_redirects]
747758

748759
The maximum number of redirects to follow for a request. Default: `10`.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
package cel
6+
7+
import (
8+
"net/http"
9+
"net/url"
10+
"testing"
11+
12+
"github.com/elastic/elastic-agent-libs/logp/logptest"
13+
)
14+
15+
func TestCheckRedirectSensitiveHeaders(t *testing.T) {
16+
log := logptest.NewTestingLogger(t, "")
17+
18+
tests := []struct {
19+
name string
20+
prevURL string
21+
reqURL string
22+
sensitiveHeaders []string
23+
wantAuth bool
24+
wantProxyAuth bool
25+
wantCookie bool
26+
wantCustom bool
27+
}{
28+
{
29+
name: "same_origin_preserves_all_headers",
30+
prevURL: "https://api.example.com/v1/data",
31+
reqURL: "https://api.example.com/v1/other",
32+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
33+
wantAuth: true,
34+
wantProxyAuth: true,
35+
wantCookie: true,
36+
wantCustom: true,
37+
},
38+
{
39+
name: "cross-origin_strips_sensitive_headers",
40+
prevURL: "https://api.example.com/v1/data",
41+
reqURL: "https://evil.example.net/capture",
42+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
43+
wantAuth: false,
44+
wantProxyAuth: false,
45+
wantCookie: false,
46+
wantCustom: true,
47+
},
48+
{
49+
name: "scheme_downgrade_strips_sensitive_headers",
50+
prevURL: "https://api.example.com/v1/data",
51+
reqURL: "http://api.example.com/v1/data",
52+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
53+
wantAuth: false,
54+
wantProxyAuth: false,
55+
wantCookie: false,
56+
wantCustom: true,
57+
},
58+
{
59+
name: "empty_sensitive_headers_preserves_all_cross-origin",
60+
prevURL: "https://api.example.com/v1/data",
61+
reqURL: "https://other.example.net/resource",
62+
sensitiveHeaders: []string{},
63+
wantAuth: true,
64+
wantProxyAuth: true,
65+
wantCookie: true,
66+
wantCustom: true,
67+
},
68+
{
69+
name: "scheme_upgrade_same_host_preserves_headers",
70+
prevURL: "http://api.example.com/v1/data",
71+
reqURL: "https://api.example.com/v1/secure",
72+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
73+
wantAuth: true,
74+
wantProxyAuth: true,
75+
wantCookie: true,
76+
wantCustom: true,
77+
},
78+
}
79+
80+
for _, test := range tests {
81+
t.Run(test.name, func(t *testing.T) {
82+
cfg := &ResourceConfig{
83+
RedirectForwardHeaders: true,
84+
RedirectSensitiveHeaders: test.sensitiveHeaders,
85+
RedirectMaxRedirects: 10,
86+
}
87+
88+
prev := &http.Request{
89+
URL: mustParseURL(t, test.prevURL),
90+
Header: http.Header{
91+
"Authorization": {"Bearer secret"},
92+
"Proxy-Authorization": {"Basic proxy-creds"},
93+
"Cookie": {"session=abc123"},
94+
"X-Custom": {"keep-me"},
95+
},
96+
}
97+
98+
req := &http.Request{
99+
URL: mustParseURL(t, test.reqURL),
100+
Header: http.Header{},
101+
}
102+
103+
fn := checkRedirect(cfg, log)
104+
err := fn(req, []*http.Request{prev})
105+
if err != nil {
106+
t.Fatalf("checkRedirect returned error: %v", err)
107+
}
108+
109+
check(t, req.Header, "Authorization", test.wantAuth)
110+
check(t, req.Header, "Proxy-Authorization", test.wantProxyAuth)
111+
check(t, req.Header, "Cookie", test.wantCookie)
112+
check(t, req.Header, "X-Custom", test.wantCustom)
113+
})
114+
}
115+
}
116+
117+
func check(t *testing.T, h http.Header, key string, wantPresent bool) {
118+
t.Helper()
119+
_, got := h[key]
120+
if got != wantPresent {
121+
if wantPresent {
122+
t.Errorf("expected header %s to be present, but it was stripped", key)
123+
} else {
124+
t.Errorf("expected header %s to be stripped, but it is present with value %q", key, h.Get(key))
125+
}
126+
}
127+
}
128+
129+
func mustParseURL(t *testing.T, raw string) *url.URL {
130+
t.Helper()
131+
u, err := url.Parse(raw)
132+
if err != nil {
133+
t.Fatalf("failed to parse URL %q: %v", raw, err)
134+
}
135+
return u
136+
}

x-pack/filebeat/input/cel/config.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,10 @@ func defaultConfig() config {
184184
WaitMin: &waitMin,
185185
WaitMax: &waitMax,
186186
},
187-
RedirectForwardHeaders: false,
188-
RedirectMaxRedirects: 10,
189-
Transport: transport,
187+
RedirectForwardHeaders: false,
188+
RedirectSensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
189+
RedirectMaxRedirects: 10,
190+
Transport: transport,
190191
},
191192
}
192193
}
@@ -278,15 +279,16 @@ func (c keepAlive) settings() httpcommon.WithKeepaliveSettings {
278279
}
279280

280281
type ResourceConfig struct {
281-
URL *urlConfig `config:"url" validate:"required"`
282-
Headers http.Header `config:"headers"`
283-
Retry retryConfig `config:"retry"`
284-
RedirectForwardHeaders bool `config:"redirect.forward_headers"`
285-
RedirectHeadersBanList []string `config:"redirect.headers_ban_list"`
286-
RedirectMaxRedirects int `config:"redirect.max_redirects"`
287-
MaxBodySize int64 `config:"max_body_size"`
288-
RateLimit *rateLimitConfig `config:"rate_limit"`
289-
KeepAlive keepAlive `config:"keep_alive"`
282+
URL *urlConfig `config:"url" validate:"required"`
283+
Headers http.Header `config:"headers"`
284+
Retry retryConfig `config:"retry"`
285+
RedirectForwardHeaders bool `config:"redirect.forward_headers"`
286+
RedirectHeadersBanList []string `config:"redirect.headers_ban_list"`
287+
RedirectSensitiveHeaders []string `config:"redirect.sensitive_headers"`
288+
RedirectMaxRedirects int `config:"redirect.max_redirects"`
289+
MaxBodySize int64 `config:"max_body_size"`
290+
RateLimit *rateLimitConfig `config:"rate_limit"`
291+
KeepAlive keepAlive `config:"keep_alive"`
290292

291293
Transport httpcommon.HTTPTransportSettings `config:",inline"`
292294

x-pack/filebeat/input/cel/input.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,13 @@ func checkRedirect(cfg *ResourceConfig, log *logp.Logger) func(*http.Request, []
11061106
log.Debugf("http client: forwarding headers from previous request: %#v", prev.Header)
11071107
req.Header = prev.Header.Clone()
11081108

1109+
if req.URL.Host != prev.URL.Host || (prev.URL.Scheme == "https" && req.URL.Scheme == "http") {
1110+
for _, k := range cfg.RedirectSensitiveHeaders {
1111+
log.Debugf("http client: cross-origin redirect to %s: removing sensitive header %s", req.URL.Host, k)
1112+
req.Header.Del(k)
1113+
}
1114+
}
1115+
11091116
for _, k := range cfg.RedirectHeadersBanList {
11101117
log.Debugf("http client: ban header %v", k)
11111118
req.Header.Del(k)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
package httpjson
6+
7+
import (
8+
"net/http"
9+
"net/url"
10+
"testing"
11+
12+
"github.com/elastic/elastic-agent-libs/logp/logptest"
13+
)
14+
15+
func TestCheckRedirectSensitiveHeaders(t *testing.T) {
16+
log := logptest.NewTestingLogger(t, "")
17+
18+
tests := []struct {
19+
name string
20+
prevURL string
21+
reqURL string
22+
sensitiveHeaders []string
23+
wantAuth bool
24+
wantProxyAuth bool
25+
wantCookie bool
26+
wantCustom bool
27+
}{
28+
{
29+
name: "same_origin_preserves_all_headers",
30+
prevURL: "https://api.example.com/v1/data",
31+
reqURL: "https://api.example.com/v1/other",
32+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
33+
wantAuth: true,
34+
wantProxyAuth: true,
35+
wantCookie: true,
36+
wantCustom: true,
37+
},
38+
{
39+
name: "cross-origin_strips_sensitive_headers",
40+
prevURL: "https://api.example.com/v1/data",
41+
reqURL: "https://evil.example.net/capture",
42+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
43+
wantAuth: false,
44+
wantProxyAuth: false,
45+
wantCookie: false,
46+
wantCustom: true,
47+
},
48+
{
49+
name: "scheme_downgrade_strips_sensitive_headers",
50+
prevURL: "https://api.example.com/v1/data",
51+
reqURL: "http://api.example.com/v1/data",
52+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
53+
wantAuth: false,
54+
wantProxyAuth: false,
55+
wantCookie: false,
56+
wantCustom: true,
57+
},
58+
{
59+
name: "empty_sensitive_headers_preserves_all_cross-origin",
60+
prevURL: "https://api.example.com/v1/data",
61+
reqURL: "https://other.example.net/resource",
62+
sensitiveHeaders: []string{},
63+
wantAuth: true,
64+
wantProxyAuth: true,
65+
wantCookie: true,
66+
wantCustom: true,
67+
},
68+
{
69+
name: "scheme_upgrade_same_host_preserves_headers",
70+
prevURL: "http://api.example.com/v1/data",
71+
reqURL: "https://api.example.com/v1/secure",
72+
sensitiveHeaders: []string{"Authorization", "Proxy-Authorization", "Cookie"},
73+
wantAuth: true,
74+
wantProxyAuth: true,
75+
wantCookie: true,
76+
wantCustom: true,
77+
},
78+
}
79+
80+
for _, test := range tests {
81+
t.Run(test.name, func(t *testing.T) {
82+
cfg := &requestConfig{
83+
RedirectForwardHeaders: true,
84+
RedirectSensitiveHeaders: test.sensitiveHeaders,
85+
RedirectMaxRedirects: 10,
86+
}
87+
88+
prev := &http.Request{
89+
URL: mustParseURL(t, test.prevURL),
90+
Header: http.Header{
91+
"Authorization": {"Bearer secret"},
92+
"Proxy-Authorization": {"Basic proxy-creds"},
93+
"Cookie": {"session=abc123"},
94+
"X-Custom": {"keep-me"},
95+
},
96+
}
97+
98+
req := &http.Request{
99+
URL: mustParseURL(t, test.reqURL),
100+
Header: http.Header{},
101+
}
102+
103+
fn := checkRedirect(cfg, log)
104+
err := fn(req, []*http.Request{prev})
105+
if err != nil {
106+
t.Fatalf("checkRedirect returned error: %v", err)
107+
}
108+
109+
check(t, req.Header, "Authorization", test.wantAuth)
110+
check(t, req.Header, "Proxy-Authorization", test.wantProxyAuth)
111+
check(t, req.Header, "Cookie", test.wantCookie)
112+
check(t, req.Header, "X-Custom", test.wantCustom)
113+
})
114+
}
115+
}
116+
117+
func check(t *testing.T, h http.Header, key string, wantPresent bool) {
118+
t.Helper()
119+
_, got := h[key]
120+
if got != wantPresent {
121+
if wantPresent {
122+
t.Errorf("expected header %s to be present, but it was stripped", key)
123+
} else {
124+
t.Errorf("expected header %s to be stripped, but it is present with value %q", key, h.Get(key))
125+
}
126+
}
127+
}
128+
129+
func mustParseURL(t *testing.T, raw string) *url.URL {
130+
t.Helper()
131+
u, err := url.Parse(raw)
132+
if err != nil {
133+
t.Fatalf("failed to parse URL %q: %v", raw, err)
134+
}
135+
return u
136+
}

0 commit comments

Comments
 (0)