Skip to content

Commit ba7d09c

Browse files
easwarsalimony
andcommitted
xds/rbac: lowercase header matcher names before matching
The RBAC filter passes the name of a header matcher to the matching engine unchanged. The metadata that the engine matches against always has lowercase keys. A name that contains an uppercase character therefore matches no header, and the rule that holds it never fires. The policy parses, reports no error and looks active. A DENY policy written this way fails open. The A41 validation reads the same unnormalized name, so the rejection of :scheme and grpc- prefixed matchers misses the name Grpc-Status. A Host matcher also keeps its name, although A41 makes host and :authority equivalent. Lowercase the name in normalizeHeaderMatcher. That function already owns the A41 rules and already rewrites the name in place, so the matching engine, the :scheme and grpc- rejection, and the host alias all read one normalized name. Envoy holds each header matcher name in a LowerCaseString, grpc-java lowercases the name before it looks the header up, and authz/rbac_translator.go lowercases the name on the non-xDS path. The new test in test/xds shows the effect on an end user. A DENY policy on the header name User-Agent returns OK for every RPC before the change and PermissionDenied after it. The unit tests cover the name that the parse gives to the engine, the case of the :scheme and grpc- rejection, and the host alias, at the top level and inside a nested rule. RELEASE NOTES: - xds/rbac: Fix a bug where a header matcher whose name was not lowercase, such as `X-Role`, matched no header, which could cause DENY rules to fail open. - xds/rbac: Fix a bug where a `:scheme` or `grpc-` prefixed header matcher was accepted when its name was not lowercase. - xds/rbac: Fix a bug where a `Host` header matcher was not replaced with `:authority`. Co-authored-by: Markus Magnuson <331091+alimony@users.noreply.github.com>
1 parent c92e985 commit ba7d09c

3 files changed

Lines changed: 126 additions & 12 deletions

File tree

internal/xds/httpfilter/rbac/rbac.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,27 @@ func normalizePrincipalHeaders(principal *v3rbacpb.Principal) error {
166166
return nil
167167
}
168168

169-
// normalizeHeaderMatcher rejects header matchers that A41 forbids (:scheme or a
170-
// grpc- prefixed name) and rewrites a "host" matcher to ":authority".
169+
// normalizeHeaderMatcher lowercases the name of a header matcher, rejects the
170+
// names that A41 forbids (:scheme or a grpc- prefixed name) and rewrites a
171+
// "host" matcher to ":authority".
171172
func normalizeHeaderMatcher(header *v3routepb.HeaderMatcher) error {
173+
// The keys of the metadata the matchers run against are always lowercase,
174+
// so a name that contains an uppercase character matches no header at all
175+
// and the rule using it never fires. Lowercase the name, as Envoy and
176+
// grpc-java do, both to make it match and to keep the checks below from
177+
// being evaded by the case of the name.
172178
name := header.GetName()
173-
if name == ":scheme" {
179+
lowerName := strings.ToLower(name)
180+
if lowerName != name {
181+
header.Name = lowerName
182+
}
183+
if lowerName == ":scheme" {
174184
return fmt.Errorf("rbac: header matcher for %q is %q", name, ":scheme")
175185
}
176-
if strings.HasPrefix(name, "grpc-") {
186+
if strings.HasPrefix(lowerName, "grpc-") {
177187
return fmt.Errorf("rbac: header matcher for %q starts with %q", name, "grpc-")
178188
}
179-
if name == "host" {
189+
if lowerName == "host" {
180190
header.Name = ":authority"
181191
}
182192
return nil

internal/xds/httpfilter/rbac/rbac_test.go

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,19 @@ func Test(t *testing.T) {
3636
grpctest.RunSubTests(t, s{})
3737
}
3838

39-
func headerPermission(name string) *v3rbacpb.Permission {
40-
return &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{
39+
func headerMatcher(name string) *v3routepb.HeaderMatcher {
40+
return &v3routepb.HeaderMatcher{
4141
Name: name,
4242
HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PresentMatch{PresentMatch: true},
43-
}}}
43+
}
44+
}
45+
46+
func headerPermission(name string) *v3rbacpb.Permission {
47+
return &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Header{Header: headerMatcher(name)}}
4448
}
4549

4650
func headerPrincipal(name string) *v3rbacpb.Principal {
47-
return &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{
48-
Name: name,
49-
HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PresentMatch{PresentMatch: true},
50-
}}}
51+
return &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Header{Header: headerMatcher(name)}}
5152
}
5253

5354
func rbacConfig(perm *v3rbacpb.Permission, principal *v3rbacpb.Principal) *rpb.RBAC {
@@ -125,3 +126,60 @@ func (s) TestNestedHostHeaderAliasing(t *testing.T) {
125126
t.Errorf("Nested principal host matcher name = %q, want %q", gotPrincipal, ":authority")
126127
}
127128
}
129+
130+
// TestHeaderMatcherNameIsLowercased checks that a header matcher name is
131+
// lowercased before it reaches the matching engine. gRPC lowercases every
132+
// metadata key, so a name that carries an uppercase character matches no
133+
// header at all, and a DENY policy using one fails open.
134+
func (s) TestHeaderMatcherNameIsLowercased(t *testing.T) {
135+
tests := []struct {
136+
name string
137+
wantName string
138+
}{
139+
{name: "X-Role", wantName: "x-role"},
140+
{name: "USER-AGENT", wantName: "user-agent"},
141+
// The host to :authority alias must not depend on the case either.
142+
{name: "Host", wantName: ":authority"},
143+
}
144+
for _, test := range tests {
145+
t.Run(test.name, func(t *testing.T) {
146+
// The permission matcher is nested and the principal matcher is at
147+
// the top level, so both the recursive walk and the top level are
148+
// covered.
149+
permHeader, principalHeader := headerMatcher(test.name), headerMatcher(test.name)
150+
perm := &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_NotRule{
151+
NotRule: &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Header{Header: permHeader}},
152+
}}
153+
principal := &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Header{Header: principalHeader}}
154+
155+
if _, err := parseConfig(rbacConfig(perm, principal)); err != nil {
156+
t.Fatalf("parseConfig() failed: %v", err)
157+
}
158+
if got := permHeader.GetName(); got != test.wantName {
159+
t.Errorf("Permission header matcher name = %q, want %q", got, test.wantName)
160+
}
161+
if got := principalHeader.GetName(); got != test.wantName {
162+
t.Errorf("Principal header matcher name = %q, want %q", got, test.wantName)
163+
}
164+
})
165+
}
166+
}
167+
168+
// TestHeaderMatcherValidationIsCaseInsensitive checks that the A41 rejection of
169+
// :scheme and grpc- prefixed header matchers cannot be evaded by spelling the
170+
// name in another case.
171+
func (s) TestHeaderMatcherValidationIsCaseInsensitive(t *testing.T) {
172+
anyPermission := &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Any{Any: true}}
173+
anyPrincipal := &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Any{Any: true}}
174+
175+
for _, name := range []string{":Scheme", "Grpc-Timeout", "GRPC-STATUS"} {
176+
t.Run(name, func(t *testing.T) {
177+
if _, err := parseConfig(rbacConfig(headerPermission(name), anyPrincipal)); err == nil {
178+
t.Errorf("parseConfig() succeeded for permission header matcher %q; want error rejecting a :scheme/grpc- header matcher", name)
179+
}
180+
if _, err := parseConfig(rbacConfig(anyPermission, headerPrincipal(name))); err == nil {
181+
t.Errorf("parseConfig() succeeded for principal header matcher %q; want error rejecting a :scheme/grpc- header matcher", name)
182+
}
183+
})
184+
}
185+
}

test/xds/xds_server_rbac_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,52 @@ func (s) TestRBACHTTPFilter(t *testing.T) {
584584
wantStatusEmptyCall: codes.OK,
585585
wantStatusUnaryCall: codes.OK,
586586
},
587+
// This test tests that a "Host" header matcher behaves the same as a
588+
// "host" one. gRPC lowercases every metadata key, so the alias to
589+
// :authority must not depend on how the control plane spells the name.
590+
{
591+
name: "match-on-host-canonical-case",
592+
rbacCfg: &rpb.RBAC{
593+
Rules: &v3rbacpb.RBAC{
594+
Action: v3rbacpb.RBAC_ALLOW,
595+
Policies: map[string]*v3rbacpb.Policy{
596+
"match-on-authority": {
597+
Permissions: []*v3rbacpb.Permission{
598+
{Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: "Host", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PrefixMatch{PrefixMatch: "my-service-fallback"}}}},
599+
},
600+
Principals: []*v3rbacpb.Principal{
601+
{Identifier: &v3rbacpb.Principal_Any{Any: true}},
602+
},
603+
},
604+
},
605+
},
606+
},
607+
wantStatusEmptyCall: codes.OK,
608+
wantStatusUnaryCall: codes.OK,
609+
},
610+
// This test tests that a header matcher whose name is not lowercase
611+
// still matches the header. Every RPC carries a user agent, so the
612+
// RBAC Configuration below denies every RPC tried.
613+
{
614+
name: "deny-header-name-in-canonical-case",
615+
rbacCfg: &rpb.RBAC{
616+
Rules: &v3rbacpb.RBAC{
617+
Action: v3rbacpb.RBAC_DENY,
618+
Policies: map[string]*v3rbacpb.Policy{
619+
"user-agent": {
620+
Permissions: []*v3rbacpb.Permission{
621+
{Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: "User-Agent", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PresentMatch{PresentMatch: true}}}},
622+
},
623+
Principals: []*v3rbacpb.Principal{
624+
{Identifier: &v3rbacpb.Principal_Any{Any: true}},
625+
},
626+
},
627+
},
628+
},
629+
},
630+
wantStatusEmptyCall: codes.PermissionDenied,
631+
wantStatusUnaryCall: codes.PermissionDenied,
632+
},
587633
// This test tests that the RBAC HTTP Filter hard codes the :method
588634
// header to POST. Since the RBAC Configuration says to deny every RPC
589635
// with a method :POST, every RPC tried should be denied.

0 commit comments

Comments
 (0)