Skip to content

Commit 7a9373c

Browse files
botengyaophlax
authored andcommitted
fix multivalue header bypass in rbac
[CVE-2026-26308](GHSA-ghc4-35x6-crw5) Signed-off-by: Boteng Yao <boteng@google.com> Signed-off-by: Ryan Northey <ryan@synca.io>
1 parent 84a7af1 commit 7a9373c

9 files changed

Lines changed: 267 additions & 2 deletions

File tree

changelogs/current.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ bug_fixes:
5252
- area: network
5353
change: |
5454
Fixed a crash in ``Utility::getAddressWithPort`` when called with a scoped IPv6 address (e.g., ``fe80::1%eth0``).
55+
- area: rbac
56+
change: |
57+
Fixed RBAC header matcher to validate each header value individually instead of concatenating multiple header values
58+
into a single string. This prevents potential bypasses when requests contain multiple values for the same header.
59+
The new behavior is enabled by the runtime guard ``envoy.reloadable_features.rbac_match_headers_individually``.
5560
5661
removed_config_or_runtime:
5762
# *Normally occurs at the end of the* :ref:`deprecation period <deprecated>`

envoy/http/header_map.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,11 @@ class HeaderMatcher {
809809
* Check whether header matcher matches any headers in a given HeaderMap.
810810
*/
811811
virtual bool matchesHeaders(const HeaderMap& headers) const PURE;
812+
813+
/**
814+
* Matches headers validating each value individually.
815+
*/
816+
virtual bool matchesHeadersIndividually(const HeaderMap& headers) const PURE;
812817
};
813818

814819
using HeaderMatcherSharedPtr = std::shared_ptr<HeaderMatcher>;

source/common/http/header_utility.h

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ class HeaderUtility {
9292
return present_ != invert_match_;
9393
};
9494

95+
bool matchesHeadersIndividually(const HeaderMap& request_headers) const override {
96+
return matchesHeaders(request_headers);
97+
};
98+
9599
private:
96100
const LowerCaseString name_;
97101
const bool invert_match_;
@@ -125,6 +129,35 @@ class HeaderUtility {
125129
return specificMatchesHeaders(value) != invert_match_;
126130
};
127131

132+
// Matches each header value individually.
133+
bool matchesHeadersIndividually(const HeaderMap& request_headers) const override {
134+
const auto header_values = request_headers.get(name_);
135+
136+
if (header_values.empty()) {
137+
if (!treat_missing_as_empty_) {
138+
return false;
139+
}
140+
// treat_missing_as_empty_ is true, match against empty string
141+
return specificMatchesHeaders(EMPTY_STRING) != invert_match_;
142+
}
143+
144+
// Validate each header value individually
145+
for (size_t i = 0; i < header_values.size(); ++i) {
146+
absl::string_view value = header_values[i]->value().getStringView();
147+
bool matches = specificMatchesHeaders(value);
148+
if (!invert_match_ && matches) {
149+
return true;
150+
}
151+
if (invert_match_ && matches) {
152+
return false;
153+
}
154+
}
155+
156+
// For normal match: no value matched, return false
157+
// For invert_match: no value matched the pattern, return true
158+
return invert_match_;
159+
}
160+
128161
protected:
129162
// A matcher specific implementation to match the given header_value.
130163
virtual bool specificMatchesHeaders(absl::string_view header_value) const PURE;

source/common/runtime/runtime_features.cc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ RUNTIME_GUARD(envoy_reloadable_features_quic_send_server_preferred_address_to_al
7272
RUNTIME_GUARD(envoy_reloadable_features_quic_signal_headers_only_to_http1_backend);
7373
RUNTIME_GUARD(envoy_reloadable_features_quic_upstream_reads_fixed_number_packets);
7474
RUNTIME_GUARD(envoy_reloadable_features_quic_upstream_socket_use_address_cache_for_read);
75+
RUNTIME_GUARD(envoy_reloadable_features_rbac_match_headers_individually);
7576
RUNTIME_GUARD(envoy_reloadable_features_reject_empty_trusted_ca_file);
7677
RUNTIME_GUARD(envoy_reloadable_features_report_load_when_rq_active_is_non_zero);
7778
RUNTIME_GUARD(envoy_reloadable_features_reset_ignore_upstream_reason);

source/extensions/filters/common/rbac/matchers.cc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include "envoy/upstream/upstream.h"
66

77
#include "source/common/config/utility.h"
8+
#include "source/common/runtime/runtime_features.h"
89
#include "source/extensions/filters/common/rbac/matcher_extension.h"
910
#include "source/extensions/filters/common/rbac/principal_extension.h"
1011

@@ -187,9 +188,18 @@ bool NotMatcher::matches(const Network::Connection& connection,
187188
return !matcher_->matches(connection, headers, info);
188189
}
189190

191+
HeaderMatcher::HeaderMatcher(const envoy::config::route::v3::HeaderMatcher& matcher,
192+
Server::Configuration::CommonFactoryContext& context)
193+
: header_(Http::HeaderUtility::createHeaderData(matcher, context)),
194+
match_headers_individually_(Runtime::runtimeFeatureEnabled(
195+
"envoy.reloadable_features.rbac_match_headers_individually")) {}
196+
190197
bool HeaderMatcher::matches(const Network::Connection&,
191198
const Envoy::Http::RequestHeaderMap& headers,
192199
const StreamInfo::StreamInfo&) const {
200+
if (match_headers_individually_) {
201+
return header_->matchesHeadersIndividually(headers);
202+
}
193203
return header_->matchesHeaders(headers);
194204
}
195205

source/extensions/filters/common/rbac/matchers.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,14 @@ class NotMatcher : public Matcher {
103103
class HeaderMatcher : public Matcher {
104104
public:
105105
HeaderMatcher(const envoy::config::route::v3::HeaderMatcher& matcher,
106-
Server::Configuration::CommonFactoryContext& context)
107-
: header_(Http::HeaderUtility::createHeaderData(matcher, context)) {}
106+
Server::Configuration::CommonFactoryContext& context);
108107

109108
bool matches(const Network::Connection& connection, const Envoy::Http::RequestHeaderMap& headers,
110109
const StreamInfo::StreamInfo&) const override;
111110

112111
private:
113112
const Envoy::Http::HeaderUtility::HeaderDataPtr header_;
113+
const bool match_headers_individually_;
114114
};
115115

116116
/**

test/common/http/header_utility_test.cc

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,96 @@ name: match-header
237237
EXPECT_TRUE(HeaderUtility::matchHeaders(headers, header_data));
238238
}
239239

240+
// Tests for matchesHeadersIndividually - validates each header value individually
241+
TEST_F(MatchHeadersTest, MatchesHeadersIndividuallyExactMatch) {
242+
// With old behavior: headers with values "true" and "false" would concatenate to "true,false"
243+
// and not match "true". With new behavior, each value is checked individually.
244+
TestRequestHeaderMapImpl headers{{"match-header", "true"}, {"match-header", "false"}};
245+
246+
const std::string yaml = R"EOF(
247+
name: match-header
248+
string_match:
249+
exact: "true"
250+
)EOF";
251+
252+
auto matcher = HeaderUtility::createHeaderData(parseHeaderMatcherFromYaml(yaml), context_);
253+
254+
EXPECT_FALSE(matcher->matchesHeaders(headers));
255+
EXPECT_TRUE(matcher->matchesHeadersIndividually(headers));
256+
}
257+
258+
// Test the invert_match case - if ANY value matches, the inverted result is false
259+
TEST_F(MatchHeadersTest, MatchesHeadersIndividuallyExactMatchInvert) {
260+
TestRequestHeaderMapImpl headers{{"match-header", "true"}, {"match-header", "other"}};
261+
262+
const std::string yaml = R"EOF(
263+
name: match-header
264+
string_match:
265+
exact: "true"
266+
invert_match: true
267+
)EOF";
268+
269+
auto matcher = HeaderUtility::createHeaderData(parseHeaderMatcherFromYaml(yaml), context_);
270+
271+
EXPECT_FALSE(matcher->matchesHeadersIndividually(headers));
272+
}
273+
274+
// Test no values match
275+
TEST_F(MatchHeadersTest, MatchesHeadersIndividuallyNoMatch) {
276+
TestRequestHeaderMapImpl headers{{"match-header", "foo"}, {"match-header", "bar"}};
277+
278+
const std::string yaml = R"EOF(
279+
name: match-header
280+
string_match:
281+
exact: "true"
282+
)EOF";
283+
284+
auto matcher = HeaderUtility::createHeaderData(parseHeaderMatcherFromYaml(yaml), context_);
285+
286+
EXPECT_FALSE(matcher->matchesHeadersIndividually(headers));
287+
}
288+
289+
// Test single value matches
290+
TEST_F(MatchHeadersTest, MatchesHeadersIndividuallySingleValue) {
291+
TestRequestHeaderMapImpl headers{{"match-header", "true"}};
292+
293+
const std::string yaml = R"EOF(
294+
name: match-header
295+
string_match:
296+
exact: "true"
297+
)EOF";
298+
299+
auto matcher = HeaderUtility::createHeaderData(parseHeaderMatcherFromYaml(yaml), context_);
300+
301+
EXPECT_TRUE(matcher->matchesHeaders(headers));
302+
EXPECT_TRUE(matcher->matchesHeadersIndividually(headers));
303+
}
304+
305+
// matchesHeadersIndividually on HeaderDataPresentMatch delegates to matchesHeaders.
306+
TEST_F(MatchHeadersTest, MatchesHeadersIndividuallyPresentMatch) {
307+
TestRequestHeaderMapImpl present{{"match-header", "val"}};
308+
TestRequestHeaderMapImpl absent{{"other-header", "val"}};
309+
310+
auto make = [&](const std::string& yaml) {
311+
return HeaderUtility::createHeaderData(parseHeaderMatcherFromYaml(yaml), context_);
312+
};
313+
314+
// present_match: true
315+
auto matcher = make("name: match-header\npresent_match: true");
316+
EXPECT_TRUE(matcher->matchesHeadersIndividually(present));
317+
EXPECT_FALSE(matcher->matchesHeadersIndividually(absent));
318+
319+
// present_match: true, invert_match: true
320+
matcher = make("name: match-header\npresent_match: true\ninvert_match: true");
321+
EXPECT_FALSE(matcher->matchesHeadersIndividually(present));
322+
EXPECT_TRUE(matcher->matchesHeadersIndividually(absent));
323+
324+
// present_match: true, treat_missing_header_as_empty: true — always matches.
325+
matcher = make("name: match-header\npresent_match: true\ntreat_missing_header_as_empty: true");
326+
EXPECT_TRUE(matcher->matchesHeadersIndividually(present));
327+
EXPECT_TRUE(matcher->matchesHeadersIndividually(absent));
328+
}
329+
240330
TEST_F(MatchHeadersTest, MustMatchAllHeaderData) {
241331
TestRequestHeaderMapImpl matching_headers_1{{"match-header-A", "1"}, {"match-header-B", "2"}};
242332
TestRequestHeaderMapImpl matching_headers_2{

test/extensions/filters/common/rbac/matchers_test.cc

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,51 @@ TEST(HeaderMatcher, MultipleHeaderValues) {
10131013
checkMatcher(matcher5, true, Envoy::Network::MockConnection(), headers);
10141014
}
10151015

1016+
TEST(HeaderMatcher, TreatMissingAsEmpty) {
1017+
NiceMock<Server::Configuration::MockServerFactoryContext> factory_context;
1018+
envoy::config::route::v3::HeaderMatcher config;
1019+
config.set_name("optional-header");
1020+
config.set_treat_missing_header_as_empty(true);
1021+
1022+
Envoy::Http::TestRequestHeaderMapImpl headers;
1023+
Envoy::Http::LowerCaseString header_name("optional-header");
1024+
1025+
// Missing header with exact empty string match should succeed
1026+
config.mutable_string_match()->set_exact("");
1027+
RBAC::HeaderMatcher matcher1(config, factory_context);
1028+
checkMatcher(matcher1, true, Envoy::Network::MockConnection(), headers);
1029+
1030+
// Missing header with non-empty exact match should fail
1031+
config.mutable_string_match()->set_exact("some-value");
1032+
RBAC::HeaderMatcher matcher2(config, factory_context);
1033+
checkMatcher(matcher2, false, Envoy::Network::MockConnection(), headers);
1034+
1035+
// Missing header with prefix match on empty prefix should succeed
1036+
config.mutable_string_match()->set_prefix("");
1037+
RBAC::HeaderMatcher matcher3(config, factory_context);
1038+
checkMatcher(matcher3, true, Envoy::Network::MockConnection(), headers);
1039+
1040+
// Missing header with non-empty prefix should fail
1041+
config.mutable_string_match()->set_prefix("pre");
1042+
RBAC::HeaderMatcher matcher4(config, factory_context);
1043+
checkMatcher(matcher4, false, Envoy::Network::MockConnection(), headers);
1044+
1045+
// Header present with matching value should still work
1046+
headers.setReference(header_name, "some-value");
1047+
config.mutable_string_match()->set_exact("some-value");
1048+
RBAC::HeaderMatcher matcher5(config, factory_context);
1049+
checkMatcher(matcher5, true, Envoy::Network::MockConnection(), headers);
1050+
1051+
// With invert_match=true, missing header treated as empty should match
1052+
// when the pattern doesn't match empty string
1053+
headers.remove(header_name);
1054+
config.set_invert_match(true);
1055+
config.mutable_string_match()->set_exact("non-empty-value");
1056+
RBAC::HeaderMatcher matcher6(config, factory_context);
1057+
// Empty string doesn't match "non-empty-value", and invert_match=true, so should return true
1058+
checkMatcher(matcher6, true, Envoy::Network::MockConnection(), headers);
1059+
}
1060+
10161061
TEST(AuthenticatedMatcher, EmptyCertificateFields) {
10171062
Envoy::Network::MockConnection conn;
10181063
auto ssl = std::make_shared<Ssl::MockConnectionInfo>();

test/extensions/filters/http/rbac/rbac_filter_integration_test.cc

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ name: rbac
4242
- any: true
4343
)EOF";
4444

45+
const std::string RBAC_CONFIG_WITH_CUSTOM_HEADER_DENY = R"EOF(
46+
name: rbac
47+
typed_config:
48+
"@type": type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC
49+
rules:
50+
action: DENY
51+
policies:
52+
"deny policy":
53+
permissions:
54+
- header:
55+
name: "x-internal"
56+
string_match:
57+
exact: "true"
58+
principals:
59+
- any: true
60+
)EOF";
61+
4562
const std::string FILTER_STATE_SETTER_CONFIG = R"EOF(
4663
name: test-filter-state-setter
4764
typed_config:
@@ -735,6 +752,65 @@ TEST_P(RBACIntegrationTest, DeniedWithDenyAction) {
735752
testing::HasSubstr("rbac_access_denied_matched_policy[deny_policy]"));
736753
}
737754

755+
// Test that RBAC cannot be bypassed by sending duplicate headers.
756+
TEST_P(RBACIntegrationTest, MultiValueHeaderBypassPrevented) {
757+
useAccessLog("%RESPONSE_CODE_DETAILS%");
758+
config_helper_.addRuntimeOverride("envoy.reloadable_features.rbac_match_headers_individually",
759+
"true");
760+
config_helper_.prependFilter(RBAC_CONFIG_WITH_CUSTOM_HEADER_DENY);
761+
initialize();
762+
763+
codec_client_ = makeHttpConnection(lookupPort("http"));
764+
765+
// Create headers with duplicate x-internal values
766+
Http::TestRequestHeaderMapImpl headers{
767+
{":method", "GET"},
768+
{":path", "/"},
769+
{":scheme", "http"},
770+
{":authority", "sni.lyft.com"},
771+
{"x-forwarded-for", "10.0.0.1"},
772+
{"x-internal", "true"},
773+
};
774+
headers.addCopy(Http::LowerCaseString("x-internal"), "other");
775+
776+
auto response = codec_client_->makeRequestWithBody(headers, 1024);
777+
ASSERT_TRUE(response->waitForEndStream());
778+
ASSERT_TRUE(response->complete());
779+
// With the fix, one of the values is "true" so request should be denied.
780+
EXPECT_EQ("403", response->headers().getStatusValue());
781+
EXPECT_THAT(waitForAccessLog(access_log_name_),
782+
testing::HasSubstr("rbac_access_denied_matched_policy[deny_policy]"));
783+
}
784+
785+
// Test that duplicate headers bypass RBAC when individual matching is disabled (old behavior).
786+
TEST_P(RBACIntegrationTest, MultiValueHeaderConcatenatedMatchAllowsBypass) {
787+
config_helper_.addRuntimeOverride("envoy.reloadable_features.rbac_match_headers_individually",
788+
"false");
789+
config_helper_.prependFilter(RBAC_CONFIG_WITH_CUSTOM_HEADER_DENY);
790+
initialize();
791+
792+
codec_client_ = makeHttpConnection(lookupPort("http"));
793+
794+
// Create headers with duplicate x-internal values
795+
Http::TestRequestHeaderMapImpl headers{
796+
{":method", "GET"},
797+
{":path", "/"},
798+
{":scheme", "http"},
799+
{":authority", "sni.lyft.com"},
800+
{"x-forwarded-for", "10.0.0.1"},
801+
{"x-internal", "true"},
802+
};
803+
headers.addCopy(Http::LowerCaseString("x-internal"), "other");
804+
805+
auto response = codec_client_->makeRequestWithBody(headers, 1024);
806+
waitForNextUpstreamRequest();
807+
upstream_request_->encodeHeaders(Http::TestResponseHeaderMapImpl{{":status", "200"}}, true);
808+
809+
ASSERT_TRUE(response->waitForEndStream());
810+
ASSERT_TRUE(response->complete());
811+
EXPECT_EQ("200", response->headers().getStatusValue());
812+
}
813+
738814
TEST_P(RBACIntegrationTest, RouteMetadataMatcherAllow) {
739815
config_helper_.prependFilter(RBAC_CONFIG_WITH_SOURCED_METADATA_ROUTE);
740816
// Set route metadata

0 commit comments

Comments
 (0)