snapshot of https://github.com/grpc/grpc-go.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
codecov / upload (push) Failing after 13m59s
CodeQL / Analyze (push) Failing after 1s
Testing / vet-proto (push) Failing after 1s
Testing / tests (arm, latest) (push) Waiting to run
Testing / extras (latest) (push) Failing after 1s
Testing / static checks (latest-1) (push) Failing after 1s
Testing / tests (-race, latest) (push) Failing after 1s
Testing / tests (i386, latest) (push) Failing after 1s
Testing / tests (latest) (push) Failing after 1s
Testing / tests (latest-1) (push) Failing after 1s
Lock Threads / lock (push) Failing after 1s
govulncheck / govulncheck (.) (push) Failing after 1s
govulncheck / govulncheck (./cmd/protoc-gen-go-grpc) (push) Failing after 1s
govulncheck / govulncheck (./gcp/observability) (push) Failing after 1s
govulncheck / govulncheck (./security/advancedtls) (push) Failing after 1s
govulncheck / govulncheck (./stats/opencensus) (push) Failing after 1s
Stale bot / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 20:40:15 +05:00
commit e241b33f26
1365 changed files with 339570 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package extauthz
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/internal/xds/grpcservice"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/internal/xds/matcher"
)
// config contains the configuration for the external authorization filter.
type config struct {
httpfilter.FilterConfig
// grpcService is the configuration for the external authorization server.
grpcService *grpcservice.Config
// filterEnabled specifies the percentage of requests to be authorized by
// the external authorization server.
filterEnabled fraction
// denyAtDisabled specifies whether to deny requests when external
// authorization is disabled via the filterEnabled configuration. If true,
// requests will be denied with a status based on statusOnError.
denyAtDisable bool
// failureModeAllow specifies the behavior when the call to the external
// authorization server fails. If true, the request will be allowed in such
// cases. If false, the request will be denied with a status based on
// statusOnError.
failureModeAllow bool
// failureModeAllowHeaderAdd specifies whether to add the
// `x-envoy-auth-failure-mode-allowed: true` header to the data plane RPC
// when the call to the external authorization server fails.
failureModeAllowHeaderAdd bool
// statusOnError is the gRPC status code to use when the external
// authorization is disabled or when the call to the external authorization
// server fails.
//
// The default value is PERMISSION_DENIED.
statusOnError codes.Code
// allowedHeaders specifies the headers that are allowed to be sent to the
// external authorization server. If unset, all headers are allowed.
allowedHeaders []matcher.StringMatcher
// disallowedHeaders specifies the headers that will not be sent to the
// external authorization server. This takes precedence over the
// allowedHeaders field.
disallowedHeaders []matcher.StringMatcher
// decoderHeaderMutationRules specifies the rules for what modifications an
// external authorization server may make to headers sent on the data plane
// RPC.
decoderHeaderMutationRules httpfilter.HeaderMutationRules
// includePeerCertificate specifies whether to include the peer certificate
// in the request sent to the external authorization server.
includePeerCertificate bool
}
// fraction uses a numerator and denominator to specify a fractional value. If
// the denominator specified is less than the numerator, the final fractional
// value is capped at 1.
type fraction struct {
// numerator is the numerator of the fraction.
numerator uint32
// denominator is the denominator of the fraction.
denominator uint32
}

View File

@@ -0,0 +1,184 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package extauthz implements the xDS External Authorization HTTP filter.
package extauthz
import (
"fmt"
"net/http"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/internal/envconfig"
"google.golang.org/grpc/internal/transport"
"google.golang.org/grpc/internal/xds/grpcservice"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/internal/xds/matcher"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3extauthzpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_authz/v3"
v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3"
)
func init() {
if envconfig.XDSClientExtAuthzEnabled {
httpfilter.Register(builder{})
}
}
var (
// TODO: Parse via grpcservice.Parse with the filter parse options,
// as ext_proc does, when ext_authz is wired up for gRFC A102.
parseGRPCServiceConfig = func(*v3corepb.GrpcService) (*grpcservice.Config, error) {
return nil, fmt.Errorf("parseGRPCServiceConfig not implemented")
}
)
type builder struct{}
func (builder) TypeURLs() []string {
return []string{
"type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz",
"type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute",
}
}
func parseFilterEnabled(fp *v3corepb.RuntimeFractionalPercent) (fraction, error) {
if fp == nil {
return fraction{numerator: 100, denominator: 100}, nil
}
fracPercent := fp.GetDefaultValue()
if fracPercent == nil {
return fraction{}, fmt.Errorf("extauthz: missing default_value in filter_enabled")
}
den := uint32(100)
switch fracPercent.GetDenominator() {
case v3typepb.FractionalPercent_TEN_THOUSAND:
den = 10000
case v3typepb.FractionalPercent_MILLION:
den = 1000000
}
// If the numerator exceeds the denominator, cap the fractional value at 100%.
num := min(fracPercent.GetNumerator(), den)
return fraction{numerator: num, denominator: den}, nil
}
// grpcStatusCode converts an HTTP status code to a gRPC status code.
func grpcStatusCode(httpStatus int32) codes.Code {
if code, ok := transport.HTTPStatusConvTab[int(httpStatus)]; ok {
return code
}
return codes.Unknown
}
func (builder) ParseFilterConfig(cfg proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
m, ok := cfg.(*anypb.Any)
if !ok {
return nil, fmt.Errorf("extauthz: error parsing config %v: unknown type %T, want *anypb.Any", cfg, cfg)
}
msg := new(v3extauthzpb.ExtAuthz)
if err := m.UnmarshalTo(msg); err != nil {
return nil, fmt.Errorf("extauthz: failed to unmarshal config: %v", err)
}
if msg.GetGrpcService() == nil {
return nil, fmt.Errorf("extauthz: empty grpc_service provided in config %v", cfg)
}
server, err := parseGRPCServiceConfig(msg.GetGrpcService())
if err != nil {
return nil, fmt.Errorf("extauthz: failed to parse grpc_service: %v", err)
}
filterEnabled, err := parseFilterEnabled(msg.GetFilterEnabled())
if err != nil {
return nil, err
}
var denyAtDisable bool
if denyAtDisableFlag := msg.GetDenyAtDisable(); denyAtDisableFlag != nil {
if denyAtDisableFlag.GetDefaultValue() == nil {
return nil, fmt.Errorf("extauthz: missing default_value in deny_at_disable")
}
denyAtDisable = denyAtDisableFlag.GetDefaultValue().GetValue()
}
httpStatus := int32(http.StatusForbidden)
if st := msg.GetStatusOnError().GetCode(); st != 0 {
httpStatus = int32(st)
}
statusOnError := grpcStatusCode(httpStatus)
mutationRules, err := httpfilter.HeaderMutationRulesFromProto(msg.GetDecoderHeaderMutationRules())
if err != nil {
return nil, err
}
var allowedHeaders, disallowedHeaders []matcher.StringMatcher
if allowed := msg.GetAllowedHeaders(); allowed != nil {
allowedHeaders, err = httpfilter.ConvertStringMatchers(allowed.GetPatterns())
if err != nil {
return nil, err
}
}
if disallowed := msg.GetDisallowedHeaders(); disallowed != nil {
disallowedHeaders, err = httpfilter.ConvertStringMatchers(disallowed.GetPatterns())
if err != nil {
return nil, err
}
}
return config{
grpcService: server,
filterEnabled: filterEnabled,
denyAtDisable: denyAtDisable,
failureModeAllow: msg.GetFailureModeAllow(),
failureModeAllowHeaderAdd: msg.GetFailureModeAllowHeaderAdd(),
statusOnError: statusOnError,
allowedHeaders: allowedHeaders,
disallowedHeaders: disallowedHeaders,
decoderHeaderMutationRules: mutationRules,
includePeerCertificate: msg.GetIncludePeerCertificate(),
}, nil
}
// ParseFilterConfigOverride parses the provided override configuration.
//
// Note that ExtAuthzPerRoute is unmarshaled to verify its syntax during xDS
// resource validation, no filter configuration object is returned. Per-route
// disabling is supported via the generic FilterConfig wrapper mechanism rather
// than the ExtAuthzPerRoute.disabled field directly.
func (builder) ParseFilterConfigOverride(overrideCfg proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
m, ok := overrideCfg.(*anypb.Any)
if !ok {
return nil, fmt.Errorf("extauthz: error parsing override config %v: unknown type %T, want *anypb.Any", overrideCfg, overrideCfg)
}
msg := new(v3extauthzpb.ExtAuthzPerRoute)
if err := m.UnmarshalTo(msg); err != nil {
return nil, fmt.Errorf("extauthz: failed to unmarshal override config %v: %v", overrideCfg, err)
}
return nil, nil
}
func (builder) IsTerminal() bool {
return false
}

View File

@@ -0,0 +1,439 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package extauthz
import (
"fmt"
"regexp"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/internal/grpctest"
"google.golang.org/grpc/internal/testutils"
"google.golang.org/grpc/internal/xds/grpcservice"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/internal/xds/matcher"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/wrapperspb"
mutationpb "github.com/envoyproxy/go-control-plane/envoy/config/common/mutation_rules/v3"
corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3extauthzpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_authz/v3"
matcherpb "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3"
)
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
// testParseGRPCServiceConfig is a helper function that parses a GrpcService
// proto message into a GRPCServiceConfig. This is a temporary test
// implementation that will be removed once gRFC A102 is implemented.
func testParseGRPCServiceConfig(grpcService *corepb.GrpcService) (*grpcservice.Config, error) {
if grpcService.GetGoogleGrpc() == nil {
return nil, fmt.Errorf("only google_grpc grpc_service is supported")
}
if grpcService.GetGoogleGrpc().GetTargetUri() == "" {
return nil, fmt.Errorf("targetURI must be a non-empty string")
}
return &grpcservice.Config{
TargetURI: grpcService.GetGoogleGrpc().GetTargetUri(),
}, nil
}
var cmpOpts = []cmp.Option{
cmp.AllowUnexported(
config{},
fraction{},
),
cmp.Transformer("RegexpToString", func(r *regexp.Regexp) string {
if r == nil {
return ""
}
return r.String()
}),
cmp.Comparer(func(x, y matcher.StringMatcher) bool {
return x.Equal(y)
}),
}
// Test verifies that ParseFilterConfig successfully parses valid external
// authorization filter configurations into their internal representation.
func (s) TestParseFilterConfig_Success(t *testing.T) {
origParseGRPCServiceConfig := parseGRPCServiceConfig
defer func() { parseGRPCServiceConfig = origParseGRPCServiceConfig }()
parseGRPCServiceConfig = testParseGRPCServiceConfig
tests := []struct {
name string
desc string
cfg proto.Message
wantCfg httpfilter.FilterConfig
}{
{
name: "DefaultConfig",
desc: "verifies default fallback values when only grpc_service is provided",
cfg: testutils.MarshalAny(t, &v3extauthzpb.ExtAuthz{
Services: &v3extauthzpb.ExtAuthz_GrpcService{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
},
}),
wantCfg: config{
grpcService: &grpcservice.Config{
TargetURI: "localhost:1234",
},
filterEnabled: fraction{
numerator: 100,
denominator: 100,
},
statusOnError: codes.PermissionDenied,
},
},
{
name: "FullConfig",
desc: "verifies all config fields are parsed and mapped correctly when explicitly configured",
cfg: testutils.MarshalAny(t, &v3extauthzpb.ExtAuthz{
Services: &v3extauthzpb.ExtAuthz_GrpcService{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:5678",
},
},
},
},
FilterEnabled: &corepb.RuntimeFractionalPercent{
DefaultValue: &v3typepb.FractionalPercent{
Numerator: 50,
Denominator: v3typepb.FractionalPercent_TEN_THOUSAND,
},
},
DenyAtDisable: &corepb.RuntimeFeatureFlag{
DefaultValue: wrapperspb.Bool(true),
},
FailureModeAllow: true,
FailureModeAllowHeaderAdd: true,
StatusOnError: &v3typepb.HttpStatus{
Code: v3typepb.StatusCode_Unauthorized,
},
DecoderHeaderMutationRules: &mutationpb.HeaderMutationRules{
AllowExpression: &matcherpb.RegexMatcher{Regex: ".*"},
DisallowExpression: &matcherpb.RegexMatcher{Regex: "a"},
},
AllowedHeaders: &matcherpb.ListStringMatcher{
Patterns: []*matcherpb.StringMatcher{{
MatchPattern: &matcherpb.StringMatcher_Exact{Exact: "allow-header"},
}},
},
DisallowedHeaders: &matcherpb.ListStringMatcher{
Patterns: []*matcherpb.StringMatcher{
{
MatchPattern: &matcherpb.StringMatcher_Exact{Exact: "disallow-header"},
},
},
},
IncludePeerCertificate: true,
}),
wantCfg: config{
grpcService: &grpcservice.Config{
TargetURI: "localhost:5678",
},
filterEnabled: fraction{
numerator: 50,
denominator: 10000,
},
denyAtDisable: true,
failureModeAllow: true,
failureModeAllowHeaderAdd: true,
statusOnError: codes.Unauthenticated,
decoderHeaderMutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:.*)$"),
DisallowExpr: regexp.MustCompile("^(?:a)$"),
},
allowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("allow-header", false),
},
disallowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("disallow-header", false),
},
includePeerCertificate: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Log(tt.desc)
b := builder{}
got, err := b.ParseFilterConfig(tt.cfg, httpfilter.ParseOptions{})
if err != nil {
t.Fatalf("ParseFilterConfig() failed with unexpected error: %v", err)
}
if diff := cmp.Diff(tt.wantCfg, got, cmpOpts...); diff != "" {
t.Fatalf("ParseFilterConfig() returned unexpected config (-want, +got):\n%s", diff)
}
})
}
}
// Test verifies that ParseFilterConfig returns an error when provided with
// invalid or unsupported configurations.
func (s) TestParseFilterConfig_Failure(t *testing.T) {
origParseGRPCServiceConfig := parseGRPCServiceConfig
defer func() { parseGRPCServiceConfig = origParseGRPCServiceConfig }()
parseGRPCServiceConfig = testParseGRPCServiceConfig
tests := []struct {
name string
desc string
cfg proto.Message
wantErr string
}{
{
name: "InvalidConfigType",
desc: "verifies error when input message is not a valid proto message",
cfg: &v3extauthzpb.ExtAuthz{},
wantErr: "extauthz: error parsing config",
},
{
name: "Config_Unmarshaling_Failed",
desc: "verifies error when input proto message cannot be unmarshaled into an ExtAuthz message",
cfg: &anypb.Any{
TypeUrl: "type.googleapis.com/invalid",
Value: []byte("invalid"),
},
wantErr: "extauthz: failed to unmarshal config",
},
{
name: "MissingGrpcService",
desc: "verifies error when required grpc_service is missing",
cfg: testutils.MarshalAny(t, &v3extauthzpb.ExtAuthz{}),
wantErr: "extauthz: empty grpc_service provided",
},
{
name: "UnsupportedGrpcService_EnvoyGrpc",
desc: "verifies error when unsupported envoy_grpc service is used",
cfg: testutils.MarshalAny(t, &v3extauthzpb.ExtAuthz{
Services: &v3extauthzpb.ExtAuthz_GrpcService{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_EnvoyGrpc_{
EnvoyGrpc: &corepb.GrpcService_EnvoyGrpc{
ClusterName: "cluster",
},
},
},
},
}),
wantErr: "extauthz: failed to parse grpc_service: only google_grpc grpc_service is supported",
},
{
name: "InvalidServerConfig_EmptyTargetURI",
desc: "verifies error when google_grpc has empty target URI",
cfg: testutils.MarshalAny(t, &v3extauthzpb.ExtAuthz{
Services: &v3extauthzpb.ExtAuthz_GrpcService{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "",
},
},
},
},
}),
wantErr: "extauthz: failed to parse grpc_service: targetURI must be a non-empty string",
},
{
name: "MissingDefaultValueInFilterEnabled",
desc: "verifies error when filter_enabled lacks default value",
cfg: testutils.MarshalAny(t, &v3extauthzpb.ExtAuthz{
Services: &v3extauthzpb.ExtAuthz_GrpcService{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
},
FilterEnabled: &corepb.RuntimeFractionalPercent{},
}),
wantErr: "extauthz: missing default_value in filter_enabled",
},
{
name: "MissingDefaultValueInDenyAtDisable",
desc: "verifies error when deny_at_disable lacks default value",
cfg: testutils.MarshalAny(t, &v3extauthzpb.ExtAuthz{
Services: &v3extauthzpb.ExtAuthz_GrpcService{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
},
DenyAtDisable: &corepb.RuntimeFeatureFlag{},
}),
wantErr: "extauthz: missing default_value in deny_at_disable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Log(tt.desc)
b := builder{}
if _, err := b.ParseFilterConfig(tt.cfg, httpfilter.ParseOptions{}); err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ParseFilterConfig() returned error = %v, wantErr containing %v", err, tt.wantErr)
}
})
}
}
// Test verifies that ParseFilterConfigOverride successfully unmarshals valid
// per-route override configurations.
func (s) TestParseFilterConfigOverride_Success(t *testing.T) {
override := testutils.MarshalAny(t, &v3extauthzpb.ExtAuthzPerRoute{
Override: &v3extauthzpb.ExtAuthzPerRoute_Disabled{
Disabled: true,
},
})
b := builder{}
got, err := b.ParseFilterConfigOverride(override, httpfilter.ParseOptions{})
if err != nil {
t.Fatalf("ParseFilterConfigOverride() failed with unexpected error: %v", err)
}
if got != nil {
t.Fatalf("ParseFilterConfigOverride() = %v, want nil", got)
}
}
// Test verifies that ParseFilterConfigOverride returns an error when provided
// with an invalid proto message type or a malformed Any message.
func (s) TestParseFilterConfigOverride_Failure(t *testing.T) {
tests := []struct {
name string
desc string
override proto.Message
wantErr string
}{
{
name: "InvalidOverrideType",
desc: "verifies error when input override is not an Any message",
override: &v3extauthzpb.ExtAuthzPerRoute{},
wantErr: "extauthz: error parsing override config",
},
{
name: "Unmarshal_Failed",
desc: "verifies error when Any message unmarshal to ExtAuthzPerRoute fails",
override: &anypb.Any{
TypeUrl: "type.googleapis.com/invalid",
Value: []byte("invalid"),
},
wantErr: "extauthz: failed to unmarshal override config",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Log(tt.desc)
b := builder{}
if _, err := b.ParseFilterConfigOverride(tt.override, httpfilter.ParseOptions{}); err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ParseFilterConfigOverride() returned error = %v, wantErr containing %v", err, tt.wantErr)
}
})
}
}
// Test verifies that parseFilterEnabled correctly parses the
// RuntimeFractionalPercent configuration into its internal representation.
func (s) TestParseFilterEnabled(t *testing.T) {
tests := []struct {
name string
fp *corepb.RuntimeFractionalPercent
want fraction
}{
{
name: "NilFraction",
fp: nil,
want: fraction{numerator: 100, denominator: 100},
},
{
name: "DenominatorHundred",
fp: &corepb.RuntimeFractionalPercent{
DefaultValue: &v3typepb.FractionalPercent{
Numerator: 10,
Denominator: v3typepb.FractionalPercent_HUNDRED,
},
},
want: fraction{numerator: 10, denominator: 100},
},
{
name: "DenominatorMillion",
fp: &corepb.RuntimeFractionalPercent{
DefaultValue: &v3typepb.FractionalPercent{
Numerator: 5,
Denominator: v3typepb.FractionalPercent_MILLION,
},
},
want: fraction{numerator: 5, denominator: 1000000},
},
{
name: "DefaultDenominator",
fp: &corepb.RuntimeFractionalPercent{
DefaultValue: &v3typepb.FractionalPercent{
Numerator: 25,
},
},
want: fraction{numerator: 25, denominator: 100},
},
{
name: "CappedToHundredPercent",
fp: &corepb.RuntimeFractionalPercent{
DefaultValue: &v3typepb.FractionalPercent{
Numerator: 200,
Denominator: v3typepb.FractionalPercent_HUNDRED,
},
},
want: fraction{numerator: 100, denominator: 100},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseFilterEnabled(tt.fp)
if err != nil {
t.Fatalf("parseFilterEnabled(%v) failed: %v", tt.fp, err)
}
if diff := cmp.Diff(tt.want, got, cmp.AllowUnexported(fraction{})); diff != "" {
t.Fatalf("parseFilterEnabled(%v) returned unexpected fraction (-want, +got):\n%s", tt.fp, diff)
}
})
}
}

View File

@@ -0,0 +1,326 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package httpfilter
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
imetadata "google.golang.org/grpc/internal/metadata"
"google.golang.org/grpc/internal/xds/matcher"
"google.golang.org/grpc/metadata"
v3mutationpb "github.com/envoyproxy/go-control-plane/envoy/config/common/mutation_rules/v3"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3matcherpb "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
)
// maxHeaderSize is the maximum length, in bytes, of a header key or value in a
// mutation received from an external processing server.
const maxHeaderSize = 16384
// HeaderMutationRules specifies the rules for what modifications an external
// processing server may make to headers sent on the data plane RPC.
type HeaderMutationRules struct {
// AllowExpr specifies a regular expression that matches the headers that can
// be mutated.
AllowExpr *regexp.Regexp
// DisallowExpr specifies a regular expression that matches the headers that
// cannot be mutated. This overrides the above allowExpr if a header matches
// both.
DisallowExpr *regexp.Regexp
// DisallowAll specifies that no header mutations are allowed. This overrides
// all other settings.
DisallowAll bool
// DisallowIsError specifies whether to return an error if a header mutation
// is disallowed. If true, the data plane RPC will be failed with a grpc
// status code of Unknown.
DisallowIsError bool
}
// ConvertStringMatchers converts a slice of protobuf StringMatcher messages to
// a slice of matcher.StringMatcher.
func ConvertStringMatchers(patterns []*v3matcherpb.StringMatcher) ([]matcher.StringMatcher, error) {
matchers := make([]matcher.StringMatcher, 0, len(patterns))
for _, p := range patterns {
sm, err := matcher.StringMatcherFromProto(p)
if err != nil {
return nil, err
}
matchers = append(matchers, sm)
}
return matchers, nil
}
// HeaderMutationRulesFromProto converts a protobuf HeaderMutationRules proto
// message to a HeaderMutationRules struct.
func HeaderMutationRulesFromProto(mr *v3mutationpb.HeaderMutationRules) (HeaderMutationRules, error) {
var rules HeaderMutationRules
if mr == nil {
return rules, nil
}
if allowExpr := mr.GetAllowExpression(); allowExpr != nil {
re, err := matcher.CompileSafeRegex(allowExpr.GetRegex())
if err != nil {
return rules, fmt.Errorf("httpfilter: %v", err)
}
rules.AllowExpr = re
}
if disallowExpr := mr.GetDisallowExpression(); disallowExpr != nil {
re, err := matcher.CompileSafeRegex(disallowExpr.GetRegex())
if err != nil {
return rules, fmt.Errorf("httpfilter: %v", err)
}
rules.DisallowExpr = re
}
rules.DisallowAll = mr.GetDisallowAll().GetValue()
rules.DisallowIsError = mr.GetDisallowIsError().GetValue()
return rules, nil
}
// ApplyAdditions takes a set of header mutations (for additions and
// modifications) received from an external server and applies them to the
// provided metadata, subject to the rules defined in hmr.
//
// If the DisallowAll field is true, no mutations are performed, and the input
// metadata is returned unmodified.
//
// It iterates through each header mutation, performs validation on the header
// key and value, and checks if the mutation is permitted by the AllowExpr and
// DisallowExpr regular expressions.
//
// A mutation for any of the following headers fails validation and an error is
// returned:
// - Pseudo-headers (keys starting with ':').
// - The 'host' header.
// - Headers with keys in the reserved 'grpc-' space.
// - Headers with non-lowercase keys.
// - Headers with keys or values exceeding 16384 bytes.
// - Headers with an invalid gRPC header name or header value.
//
// If a mutation is disallowed by the mutation rules and DisallowIsError is
// true, an error is returned. Otherwise, the disallowed mutation is silently
// ignored.
//
// The input metadata must not be nil.
func (hmr *HeaderMutationRules) ApplyAdditions(hvos []*v3corepb.HeaderValueOption, input metadata.MD) error {
if hmr == nil {
hmr = &HeaderMutationRules{}
}
if input == nil {
return fmt.Errorf("input metadata is nil")
}
if hmr.DisallowAll {
return nil
}
for _, hvo := range hvos {
header := hvo.GetHeader()
key := header.GetKey()
if err := validateHeaderKey(key); err != nil {
return fmt.Errorf("invalid header mutation: %v", err)
}
value := header.GetValue()
if strings.HasSuffix(key, "-bin") {
value = string(header.GetRawValue())
}
if len(value) > maxHeaderSize {
return fmt.Errorf("invalid header mutation: value for header key %q exceeds the maximum length of %d bytes", key, maxHeaderSize)
}
// ValidatePair rejects values carrying bytes outside %x20-%x7E. It
// skips the value check for "-bin" keys, whose values the transport
// base64 encodes.
if err := imetadata.ValidatePair(key, value); err != nil {
return fmt.Errorf("invalid header mutation: %v", err)
}
if !hmr.allow(key) {
if hmr.DisallowIsError {
return fmt.Errorf("header mutation disallowed by headerMutationRules for header key %q", key)
}
continue
}
// Perform the mutation on output metadata using the append_action
// field from the header value option.
switch hvo.GetAppendAction() {
case v3corepb.HeaderValueOption_APPEND_IF_EXISTS_OR_ADD:
input.Append(key, value)
case v3corepb.HeaderValueOption_ADD_IF_ABSENT:
if input.Get(key) == nil {
input.Set(key, value)
}
case v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD:
input.Set(key, value)
case v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS:
if input.Get(key) != nil {
input.Set(key, value)
}
}
}
return nil
}
// ApplyRemovals takes a set of headers (for removal) received from an external
// processing server and applies them to the provided metadata, subject to the
// rules defined in hmr.
//
// This method is very similar to ApplyAdditions, except that headers are
// removed here instead of added or mutated as is the case in the latter. See
// ApplyAdditions for more details.
//
// The input metadata must not be nil.
func (hmr *HeaderMutationRules) ApplyRemovals(headersToRemove []string, input metadata.MD) error {
if hmr == nil {
hmr = &HeaderMutationRules{}
}
if input == nil {
return fmt.Errorf("input metadata is nil")
}
if hmr.DisallowAll {
return nil
}
for _, header := range headersToRemove {
if err := validateHeaderKey(header); err != nil {
return fmt.Errorf("invalid header mutation: %v", err)
}
if !hmr.allow(header) {
if hmr.DisallowIsError {
return fmt.Errorf("header mutation disallowed by headerMutationRules for header %q", header)
}
continue
}
input.Delete(header)
}
return nil
}
// validateHeaderKey returns a non-nil error if key may not be mutated by an
// external processing server, either because the key is reserved or because it
// is not a valid gRPC header name.
func validateHeaderKey(key string) error {
switch {
case len(key) == 0:
return fmt.Errorf("header key is empty")
case key[0] == ':':
return fmt.Errorf("header key %q is a pseudo-header", key)
case key == "host":
return fmt.Errorf("header key %q is reserved", key)
case strings.HasPrefix(key, "grpc-"):
return fmt.Errorf("header key %q is in the reserved 'grpc-' space", key)
case key != strings.ToLower(key):
return fmt.Errorf("header key %q is not lowercase", key)
case len(key) > maxHeaderSize:
return fmt.Errorf("header key exceeds the maximum length of %d bytes", maxHeaderSize)
}
return imetadata.ValidateKey(key)
}
func (hmr *HeaderMutationRules) allow(key string) bool {
if hmr.DisallowExpr != nil && hmr.DisallowExpr.MatchString(key) {
return false
}
if hmr.AllowExpr != nil && hmr.AllowExpr.MatchString(key) {
return true
}
if hmr.AllowExpr != nil {
return false
}
return true
}
// ConstructHeaderMap constructs a HeaderMap from the given metadata and raw
// appended metadata slice, using the following rules:
// - if the header is matched by the disallowed_headers config field, it will
// not be added to the map, otherwise,
// - if the allowed_headers config field is unset or matches the header, the
// header will be added to the map, otherwise,
// - the header will be excluded from the map.
func ConstructHeaderMap(md metadata.MD, added [][]string, allowedHeaders, disallowedHeaders []matcher.StringMatcher) *v3corepb.HeaderMap {
headerMap := &v3corepb.HeaderMap{}
// Process the base metadata map.
for key, values := range md {
if isDisallowedHeader(key, disallowedHeaders) {
continue
}
if isAllowedHeader(key, allowedHeaders) {
for _, value := range values {
headerMap.Headers = append(headerMap.Headers, constructHeader(key, value))
}
}
}
// Process the raw appended metadata slice.
for _, kvs := range added {
for i := 0; i < len(kvs); i += 2 {
key := strings.ToLower(kvs[i])
if isDisallowedHeader(key, disallowedHeaders) {
continue
}
if isAllowedHeader(key, allowedHeaders) {
headerMap.Headers = append(headerMap.Headers, constructHeader(key, kvs[i+1]))
}
}
}
if len(headerMap.Headers) == 0 {
return nil
}
return headerMap
}
func constructHeader(key, value string) *v3corepb.HeaderValue {
rawValue := []byte(value)
if strings.HasSuffix(key, "-bin") {
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(rawValue)))
base64.StdEncoding.Encode(encoded, rawValue)
rawValue = encoded
}
return &v3corepb.HeaderValue{
Key: key,
RawValue: rawValue,
}
}
// isDisallowedHeader returns true if the given header key matches any of the
// provided disallowed header matchers.
func isDisallowedHeader(key string, matchers []matcher.StringMatcher) bool {
for _, m := range matchers {
if m.Match(key) {
return true
}
}
return false
}
// isAllowedHeader returns true if the allowed header matchers list is empty, or
// if the given header key matches any of the provided allowed header matchers.
func isAllowedHeader(key string, matchers []matcher.StringMatcher) bool {
if len(matchers) == 0 {
return true
}
for _, m := range matchers {
if m.Match(key) {
return true
}
}
return false
}

View File

@@ -0,0 +1,712 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package httpfilter
import (
"regexp"
"sort"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"google.golang.org/grpc/internal/grpctest"
"google.golang.org/grpc/internal/xds/matcher"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/wrapperspb"
v3mutationpb "github.com/envoyproxy/go-control-plane/envoy/config/common/mutation_rules/v3"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3matcherpb "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
)
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
func (s) TestHeaderMutationRulesFromProto_HappyPath(t *testing.T) {
mr := &v3mutationpb.HeaderMutationRules{
AllowExpression: &v3matcherpb.RegexMatcher{Regex: "^allow$"},
DisallowExpression: &v3matcherpb.RegexMatcher{Regex: "^disallow$"},
DisallowAll: wrapperspb.Bool(true),
DisallowIsError: wrapperspb.Bool(false),
}
want := HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:^allow$)$"),
DisallowExpr: regexp.MustCompile("^(?:^disallow$)$"),
DisallowAll: true,
DisallowIsError: false,
}
got, err := HeaderMutationRulesFromProto(mr)
if err != nil {
t.Fatalf("HeaderMutationRulesFromProto() unexpected error: %v", err)
}
if diff := cmp.Diff(got, want,
cmp.Transformer("RegexpToString", func(r *regexp.Regexp) string {
if r == nil {
return ""
}
return r.String()
}),
); diff != "" {
t.Fatalf("HeaderMutationRulesFromProto() mismatch (-got +want):\n%s", diff)
}
}
func (s) TestHeaderMutationRulesFromProto_Errors(t *testing.T) {
tests := []struct {
name string
mutationRules *v3mutationpb.HeaderMutationRules
wantErr string
}{
{
name: "invalid allow expression",
mutationRules: &v3mutationpb.HeaderMutationRules{
AllowExpression: &v3matcherpb.RegexMatcher{Regex: "["},
},
wantErr: "httpfilter: error parsing regexp",
},
{
name: "invalid disallow expression",
mutationRules: &v3mutationpb.HeaderMutationRules{
DisallowExpression: &v3matcherpb.RegexMatcher{Regex: "["},
},
wantErr: "httpfilter: error parsing regexp",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := HeaderMutationRulesFromProto(tt.mutationRules)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("HeaderMutationRulesFromProto() error = %v, wantErr %q", err, tt.wantErr)
}
})
}
}
func (s) TestHeaderMutationRules_ApplyAdditons(t *testing.T) {
tests := []struct {
name string
hmr *HeaderMutationRules
hvos []*v3corepb.HeaderValueOption
inputMD metadata.MD
wantMD metadata.MD
wantErr bool
}{
{
name: "NilReceiver",
hmr: nil,
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "DisallowAll",
hmr: &HeaderMutationRules{DisallowAll: true},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{"x": []string{"y"}},
wantMD: metadata.MD{"x": []string{"y"}},
},
{
name: "DisallowExprMatchAndDisallowIsErrorIsFalse",
hmr: &HeaderMutationRules{DisallowExpr: regexp.MustCompile("^a$")},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{},
},
{
name: "DisallowExprMatchAndDisallowIsErrorIsTrue",
hmr: &HeaderMutationRules{DisallowExpr: regexp.MustCompile("^a$"), DisallowIsError: true},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "AllowExprMatch",
hmr: &HeaderMutationRules{AllowExpr: regexp.MustCompile("^a$")},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
{Header: &v3corepb.HeaderValue{Key: "b", Value: "2"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "AllowExprNoMatch",
hmr: &HeaderMutationRules{AllowExpr: regexp.MustCompile("^a$")},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "b", Value: "2"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{},
},
{
name: "DisallowOverridesAllow",
hmr: &HeaderMutationRules{AllowExpr: regexp.MustCompile("."), DisallowExpr: regexp.MustCompile("^a$")},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
{Header: &v3corepb.HeaderValue{Key: "b", Value: "2"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{"b": []string{"2"}},
},
{
name: "InvalidHeaderKeyEmpty",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderKeyPseudoHeader",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: ":path", Value: "/"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderKeyHost",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "host", Value: "example.com"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderKeyNotLowercase",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "A", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderKeyTooLong",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: strings.Repeat("a", 16385), Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderKeyGRPCPrefix",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "grpc-accept-encoding", Value: "identity"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderKeyCRLF",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "x-user\r\nauthorization", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderKeyWithSpace",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "x user", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderValueCRLF",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "x-user", Value: "alice\r\nauthorization: Bearer token"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderValueNonPrintable",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "x-nul", Value: "a\x00b"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidHeaderValueTooLong",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: strings.Repeat("1", 16385)}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "InvalidBinaryHeaderValueTooLong",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a-bin", RawValue: make([]byte, 16385)}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantErr: true,
},
{
name: "BinaryHeaderValue",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a-bin", RawValue: []byte{1, 2, 3}}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{"a-bin": []string{string([]byte{1, 2, 3})}},
},
{
name: "AppendIfExistsOrAdd_Exists",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_APPEND_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{"a": []string{"0"}},
wantMD: metadata.MD{"a": []string{"0", "1"}},
},
{
name: "AppendIfExistsOrAdd_Add",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_APPEND_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "AddIfAbsent_Absent",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_ADD_IF_ABSENT},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "AddIfAbsent_Present",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_ADD_IF_ABSENT},
},
inputMD: metadata.MD{"a": []string{"1"}},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "OverwriteIfExistsOrAdd_Absent",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "OverwriteIfExistsOrAdd_Present",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD},
},
inputMD: metadata.MD{"a": []string{"0"}},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "OverwriteIfExists_Absent",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS},
},
inputMD: metadata.MD{},
wantMD: metadata.MD{},
},
{
name: "OverwriteIfExists_Present",
hmr: &HeaderMutationRules{},
hvos: []*v3corepb.HeaderValueOption{
{Header: &v3corepb.HeaderValue{Key: "a", Value: "1"}, AppendAction: v3corepb.HeaderValueOption_OVERWRITE_IF_EXISTS},
},
inputMD: metadata.MD{"a": []string{"0"}},
wantMD: metadata.MD{"a": []string{"1"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.hmr.ApplyAdditions(tt.hvos, tt.inputMD); (err != nil) != tt.wantErr {
t.Fatalf("ApplyAdditions() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if diff := cmp.Diff(tt.wantMD, tt.inputMD); diff != "" {
t.Fatalf("ApplyAdditions() returned diff in metadata (-want +got):\n%s", diff)
}
})
}
}
func (s) TestHeaderMutationRules_ApplyRemovals(t *testing.T) {
tests := []struct {
name string
hmr *HeaderMutationRules
headersToRemove []string
inputMD metadata.MD
wantMD metadata.MD
wantErr bool
}{
{
name: "NilReceiver",
hmr: nil,
headersToRemove: []string{"a"},
inputMD: metadata.MD{"a": []string{"1"}},
wantMD: metadata.MD{},
},
{
name: "DisallowAll",
hmr: &HeaderMutationRules{DisallowAll: true},
headersToRemove: []string{"a"},
inputMD: metadata.MD{"a": []string{"1"}},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "DisallowExprMatchAndDisallowIsErrorIsFalse",
hmr: &HeaderMutationRules{DisallowExpr: regexp.MustCompile("^a$")},
headersToRemove: []string{"a"},
inputMD: metadata.MD{"a": []string{"1"}},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "DisallowExprMatchAndDisallowIsErrorIsTrue",
hmr: &HeaderMutationRules{DisallowExpr: regexp.MustCompile("^a$"), DisallowIsError: true},
headersToRemove: []string{"a"},
inputMD: metadata.MD{"a": []string{"1"}},
wantErr: true,
},
{
name: "AllowExprMatch",
hmr: &HeaderMutationRules{AllowExpr: regexp.MustCompile("^a$")},
headersToRemove: []string{"a", "b"},
inputMD: metadata.MD{"a": []string{"1"}, "b": []string{"2"}},
wantMD: metadata.MD{"b": []string{"2"}},
},
{
name: "AllowExprNoMatch",
hmr: &HeaderMutationRules{AllowExpr: regexp.MustCompile("^a$")},
headersToRemove: []string{"b"},
inputMD: metadata.MD{"a": []string{"1"}, "b": []string{"2"}},
wantMD: metadata.MD{"a": []string{"1"}, "b": []string{"2"}},
},
{
name: "DisallowOverridesAllow",
hmr: &HeaderMutationRules{AllowExpr: regexp.MustCompile("."), DisallowExpr: regexp.MustCompile("^a$")},
headersToRemove: []string{"a", "b"},
inputMD: metadata.MD{"a": []string{"1"}, "b": []string{"2"}},
wantMD: metadata.MD{"a": []string{"1"}},
},
{
name: "InvalidHeaderKeyEmpty",
hmr: &HeaderMutationRules{},
headersToRemove: []string{""},
inputMD: metadata.MD{"a": []string{"1"}},
wantErr: true,
},
{
name: "InvalidHeaderKeyPseudoHeader",
hmr: &HeaderMutationRules{},
headersToRemove: []string{":path"},
inputMD: metadata.MD{":path": []string{"/"}},
wantErr: true,
},
{
name: "InvalidHeaderKeyHost",
hmr: &HeaderMutationRules{},
headersToRemove: []string{"host"},
inputMD: metadata.MD{"host": []string{"example.com"}},
wantErr: true,
},
{
name: "InvalidHeaderKeyNotLowercase",
hmr: &HeaderMutationRules{},
headersToRemove: []string{"A"},
inputMD: metadata.MD{"A": []string{"1"}},
wantErr: true,
},
{
name: "InvalidHeaderKeyTooLong",
hmr: &HeaderMutationRules{},
headersToRemove: []string{strings.Repeat("a", 16385)},
inputMD: metadata.MD{strings.Repeat("a", 16385): []string{"1"}},
wantErr: true,
},
{
name: "InvalidHeaderKeyGRPCPrefix",
hmr: &HeaderMutationRules{},
headersToRemove: []string{"grpc-accept-encoding"},
inputMD: metadata.MD{"grpc-accept-encoding": []string{"gzip"}},
wantErr: true,
},
{
name: "InvalidHeaderKeyCRLF",
hmr: &HeaderMutationRules{},
headersToRemove: []string{"x-user\r\nauthorization"},
inputMD: metadata.MD{"x-user": []string{"1"}},
wantErr: true,
},
{
name: "InvalidHeaderKeyWithSpace",
hmr: &HeaderMutationRules{},
headersToRemove: []string{"x user"},
inputMD: metadata.MD{"x user": []string{"1"}},
wantErr: true,
},
{
name: "HeaderExists",
hmr: &HeaderMutationRules{},
headersToRemove: []string{"a"},
inputMD: metadata.MD{"a": []string{"1"}},
wantMD: metadata.MD{},
},
{
name: "HeaderDoesNotExist",
hmr: &HeaderMutationRules{},
headersToRemove: []string{"a"},
inputMD: metadata.MD{"b": []string{"2"}},
wantMD: metadata.MD{"b": []string{"2"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.hmr.ApplyRemovals(tt.headersToRemove, tt.inputMD); (err != nil) != tt.wantErr {
t.Fatalf("ApplyRemovals() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if diff := cmp.Diff(tt.wantMD, tt.inputMD); diff != "" {
t.Fatalf("ApplyRemovals() returned diff in metadata (-want +got):\n%s", diff)
}
})
}
}
func (s) TestConstructHeaderMap(t *testing.T) {
tests := []struct {
name string
md metadata.MD
added [][]string
allowedHeaders []matcher.StringMatcher
disallowedHeaders []matcher.StringMatcher
wantHeaderMap *v3corepb.HeaderMap
}{
{
name: "NoHeaders",
md: metadata.MD{},
wantHeaderMap: nil,
},
{
name: "NoAllowedHeaders",
md: metadata.MD{
"a": {"1"},
"b": {"2"},
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
{Key: "b", RawValue: []byte("2")},
},
},
},
{
name: "WithMultipleValues_ForSingleHeader",
md: metadata.MD{
"a": {"1", "11"},
"b": {"2"},
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
{Key: "a", RawValue: []byte("11")},
{Key: "b", RawValue: []byte("2")},
},
},
},
{
name: "WithAllowedHeaders",
md: metadata.MD{
"a": {"1"},
"b": {"2"},
"c": {"3"},
},
allowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("a", false),
matcher.NewPrefixStringMatcher("c", false),
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
{Key: "c", RawValue: []byte("3")},
},
},
},
{
name: "WithDisallowedHeaders",
md: metadata.MD{
"a": {"1"},
"b": {"2"},
"c": {"3"},
},
disallowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("b", false),
matcher.NewPrefixStringMatcher("c", false),
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
},
},
},
{
name: "WithAllowedAndDisallowedHeaders",
md: metadata.MD{
"a": {"1"},
"b": {"2"},
"c": {"3"},
"d": {"4"},
},
allowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("a", false),
matcher.NewPrefixStringMatcher("b", false),
matcher.NewPrefixStringMatcher("c", false),
},
disallowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("b", false),
matcher.NewPrefixStringMatcher("c", false),
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
},
},
},
{
name: "WithAddedHeaders",
added: [][]string{
{"a", "1", "b", "2"},
{"c", "3"},
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
{Key: "b", RawValue: []byte("2")},
{Key: "c", RawValue: []byte("3")},
},
},
},
{
name: "WithAddedAndBaseHeaders",
md: metadata.MD{
"a": {"1"},
},
added: [][]string{
{"B", "2"}, // Lowercase check
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
{Key: "b", RawValue: []byte("2")},
},
},
},
{
name: "WithAddedHeadersAndFiltering",
added: [][]string{
{"a", "1", "b", "2", "c", "3"},
},
allowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("a", false),
matcher.NewExactStringMatcher("b", false),
},
disallowedHeaders: []matcher.StringMatcher{
matcher.NewExactStringMatcher("b", false),
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a", RawValue: []byte("1")},
},
},
},
{
name: "WithBinaryHeaders",
md: metadata.MD{
"a-bin": {"\x00\x01\x02"},
"b": {"1"},
},
added: [][]string{
{"c-bin", "\x03\x04\x05"},
},
wantHeaderMap: &v3corepb.HeaderMap{
Headers: []*v3corepb.HeaderValue{
{Key: "a-bin", RawValue: []byte("AAEC")},
{Key: "b", RawValue: []byte("1")},
{Key: "c-bin", RawValue: []byte("AwQF")},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotHeaderMap := ConstructHeaderMap(tt.md, tt.added, tt.allowedHeaders, tt.disallowedHeaders)
if gotHeaderMap != nil {
sort.Slice(gotHeaderMap.Headers, func(i, j int) bool {
return gotHeaderMap.Headers[i].Key < gotHeaderMap.Headers[j].Key
})
}
if diff := cmp.Diff(tt.wantHeaderMap, gotHeaderMap, protocmp.Transform()); diff != "" {
t.Fatalf("constructHeaderMap() mismatch (-want +got):\n%s", diff)
}
})
}
}

View File

@@ -0,0 +1,184 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package extproc
import (
"time"
"google.golang.org/grpc/internal/optional"
"google.golang.org/grpc/internal/xds/grpcservice"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/internal/xds/matcher"
v3procfilterpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_proc/v3"
)
// baseConfig contains the configuration for the external processing
// interceptor.
type baseConfig struct {
httpfilter.FilterConfig
// The following fields can be set either in the filter config or the override
// config. If both are set, the override config will be used.
// server is the configuration for the external processing server.
server *grpcservice.Config
// processingModes specifies the processing mode for each dataplane event.
processingModes processingModes
// failureModeAllow specifies the behavior when the RPC to the external
// processing server fails. If true, the dataplane RPC will be allowed to
// continue. If false, the data plane RPC will be failed with a grpc status
// code of INTERNAL.
failureModeAllow bool
// Attributes to be sent to the external processing server along with the
// request and response dataplane events.
requestAttributes []string
responseAttributes []string
// The following fields can only be set in the base config.
// mutationRules specifies the rules for what modifications an external
// processing server may make to headers/trailers sent to it.
mutationRules httpfilter.HeaderMutationRules
// allowedHeaders specifies the headers that are allowed to be sent to the
// external processing server. If unset, all headers are allowed.
allowedHeaders []matcher.StringMatcher
// disallowedHeaders specifies the headers that will not be sent to the
// external processing server. This overrides the above allowedHeaders if a
// header matches both.
disallowedHeaders []matcher.StringMatcher
// disableImmediateResponse specifies whether to disable immediate responses
// from the external processing server. When true, any immediate response
// received from the server is treated as an error (causing the stream to fail
// with an Internal error, or to be bypassed if failureModeAllow is true).
// When false, the immediate response is honored, and the stream is aborted
// with the specified status code and details provided by the external
// processing server.
disableImmediateResponse bool
// observabilityMode determines if the filter waits for the external
// processing server. If true, events are sent to the server in
// "observation-only" mode; the filter does not wait for a response. If false,
// the filter waits for a response, allowing the server to modify events
// before they reach the dataplane.
observabilityMode bool
// deferredCloseTimeout is the duration the filter waits before closing the
// external processing stream after the dataplane RPC completes. This is only
// applicable when observabilityMode is true; otherwise, it is ignored. The
// default value is 5 seconds.
deferredCloseTimeout time.Duration
}
// overrideConfig contains the configuration for the external processing
// interceptor used for overriding the base config. If a particular field is
// set, that will be used instead of the base config. The fields are similar to
// base config.
type overrideConfig struct {
httpfilter.FilterConfig
server optional.Optional[*grpcservice.Config]
processingModes optional.Optional[processingModes]
failureModeAllow optional.Optional[bool]
requestAttributes []string
responseAttributes []string
}
// processingMode defines how headers, trailers, and bodies are handled in
// relation to the external processing server.
type processingMode int
const (
// modeSkip indicates that the header/trailer/body should not be sent.
modeSkip processingMode = iota
// modeSend indicates that the header/trailer/body should be sent.
modeSend
)
type processingModes struct {
requestHeaderMode processingMode
requestBodyMode processingMode
responseHeaderMode processingMode
responseTrailerMode processingMode
responseBodyMode processingMode
}
// resolveHeaderMode resolves the processing mode for headers based on the
// protobuf enum value. If the mode is not set or set to Default processing
// mode, it returns the provided defaultMode.
func resolveHeaderMode(mode v3procfilterpb.ProcessingMode_HeaderSendMode, defaultMode processingMode) processingMode {
switch mode {
case v3procfilterpb.ProcessingMode_SEND:
return modeSend
case v3procfilterpb.ProcessingMode_SKIP:
return modeSkip
case v3procfilterpb.ProcessingMode_DEFAULT:
return defaultMode
default:
return defaultMode
}
}
// resolveBodyMode resolves the processing mode for body based on the protobuf
// enum value. If the mode is not set (i.e., default), it returns modeSkip, as
// the default for body is to skip.
func resolveBodyMode(mode v3procfilterpb.ProcessingMode_BodySendMode) processingMode {
switch mode {
case v3procfilterpb.ProcessingMode_GRPC:
return modeSend
case v3procfilterpb.ProcessingMode_NONE:
return modeSkip
default:
return modeSkip
}
}
// processingModesFromProto converts a protobuf ProcessingMode message
// to a processingModes struct.
func processingModesFromProto(pm *v3procfilterpb.ProcessingMode) processingModes {
return processingModes{
requestHeaderMode: resolveHeaderMode(pm.GetRequestHeaderMode(), modeSend),
requestBodyMode: resolveBodyMode(pm.GetRequestBodyMode()),
responseHeaderMode: resolveHeaderMode(pm.GetResponseHeaderMode(), modeSend),
responseBodyMode: resolveBodyMode(pm.GetResponseBodyMode()),
responseTrailerMode: resolveHeaderMode(pm.GetResponseTrailerMode(), modeSkip),
}
}
// newInterceptorConfig creates the interceptor config from the base and
// override filter configs. If a field is set in both the base and override
// configs, the value from the override config will be used.
func newInterceptorConfig(base baseConfig, override overrideConfig) baseConfig {
ic := base
// Apply overrides if present.
if val, ok := override.server.Value(); ok {
ic.server = val
}
if val, ok := override.failureModeAllow.Value(); ok {
ic.failureModeAllow = val
}
if override.requestAttributes != nil {
ic.requestAttributes = override.requestAttributes
}
if override.responseAttributes != nil {
ic.responseAttributes = override.responseAttributes
}
if val, ok := override.processingModes.Value(); ok {
ic.processingModes = val
}
return ic
}

View File

@@ -0,0 +1,980 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package extproc
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/envconfig"
"google.golang.org/grpc/internal/grpctest"
"google.golang.org/grpc/internal/optional"
"google.golang.org/grpc/internal/testutils"
"google.golang.org/grpc/internal/xds/bootstrap"
xdscreds "google.golang.org/grpc/internal/xds/credentials"
"google.golang.org/grpc/internal/xds/grpcservice"
"google.golang.org/grpc/internal/xds/httpfilter"
iextproc "google.golang.org/grpc/internal/xds/httpfilter/extproc/internal"
"google.golang.org/grpc/internal/xds/matcher"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/wrapperspb"
mutationpb "github.com/envoyproxy/go-control-plane/envoy/config/common/mutation_rules/v3"
corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
fpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_proc/v3"
matcherpb "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
)
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
const testBaseURI = "base-uri"
// allowlistInsecureCreds carries the identity of the insecure channel
// credentials configured for allowlisted targets by testParseOptions; want
// configs compare against it by identity. It carries a real insecure bundle
// so that configs built from it can be dialed.
var allowlistInsecureCreds = xdscreds.NewChannelCreds(insecure.NewBundle(), xdscreds.Identity{Type: "insecure"}, nil)
// testPerRPCCreds is a no-op PerRPCCredentials for fixtures whose configs are
// dialed for real.
type testPerRPCCreds struct{}
func (testPerRPCCreds) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
return nil, nil
}
func (testPerRPCCreds) RequireTransportSecurity() bool { return false }
// testParseOptions returns ParseOptions whose bootstrap
// configuration allowlists the given side-channel targets with insecure
// channel credentials. The returned options carry a ServerConfig without the
// trusted_xds_server feature, so the delivering server is untrusted and
// GrpcService parsing takes the allowed_grpc_services path.
func testParseOptions(t *testing.T, targets ...string) httpfilter.ParseOptions {
t.Helper()
// The allowed_grpc_services bootstrap field is parsed only when a
// consuming feature is enabled.
testutils.SetEnvConfig(t, &envconfig.XDSClientExtProcEnabled, true)
allowed := make(map[string]json.RawMessage, len(targets))
for _, target := range targets {
allowed[target] = json.RawMessage(`{"channel_creds": [{"type": "insecure"}]}`)
}
allowedJSON, err := json.Marshal(allowed)
if err != nil {
t.Fatalf("Failed to marshal allowed_grpc_services: %v", err)
}
contents, err := bootstrap.NewContentsForTesting(bootstrap.ConfigOptionsForTesting{
Servers: []byte(`[{"server_uri": "passthrough:///unused", "channel_creds": [{"type": "insecure"}]}]`),
Node: []byte(`{"id": "test-node"}`),
AllowedGRPCServices: allowedJSON,
})
if err != nil {
t.Fatalf("Failed to create bootstrap contents: %v", err)
}
config, err := bootstrap.NewConfigFromContents(contents)
if err != nil {
t.Fatalf("Failed to parse bootstrap contents: %v", err)
}
untrustedServer, err := bootstrap.ServerConfigForTesting(bootstrap.ServerConfigTestingOptions{URI: "untrusted-server:1234"})
if err != nil {
t.Fatalf("ServerConfigForTesting() failed: %v", err)
}
return httpfilter.ParseOptions{BootstrapConfig: config, ServerConfig: untrustedServer}
}
// overrideCreateExtProcChannelWithError overrides the channel creation seam
// to fail channel creation. The override is restored when the test ends.
func overrideCreateExtProcChannelWithError(t *testing.T) {
t.Helper()
origCreateExtProcChannel := iextproc.CreateExtProcChannel
iextproc.CreateExtProcChannel = func(*grpcservice.Config) (grpc.ClientConnInterface, func(), error) {
return nil, nil, fmt.Errorf("dial error")
}
t.Cleanup(func() { iextproc.CreateExtProcChannel = origCreateExtProcChannel })
}
// Tests that channel sharing considers the target and the credential
// identities, and ignores the per-RPC timeout and initial metadata.
func (s) TestSharesChannel(t *testing.T) {
insecureCreds := func() *xdscreds.ChannelCreds {
return xdscreds.NewChannelCreds(nil, xdscreds.Identity{Type: "insecure"}, nil)
}
const target = "dns:///proc-server:443"
tests := []struct {
name string
a, b *grpcservice.Config
want bool
}{
{
name: "equal_identities_share_despite_timeout_and_metadata",
a: &grpcservice.Config{TargetURI: target, ChannelCredentials: insecureCreds(), Timeout: time.Second},
b: &grpcservice.Config{TargetURI: target, ChannelCredentials: insecureCreds(), InitialMetadata: metadata.Pairs("k", "v")},
want: true,
},
{
name: "different_targets",
a: &grpcservice.Config{TargetURI: target, ChannelCredentials: insecureCreds()},
b: &grpcservice.Config{TargetURI: "dns:///other:443", ChannelCredentials: insecureCreds()},
want: false,
},
{
name: "different_channel_creds",
a: &grpcservice.Config{TargetURI: target, ChannelCredentials: insecureCreds()},
b: &grpcservice.Config{TargetURI: target, ChannelCredentials: xdscreds.NewChannelCreds(nil, xdscreds.Identity{Type: "other"}, nil)},
want: false,
},
{
name: "different_call_creds",
a: &grpcservice.Config{TargetURI: target, ChannelCredentials: insecureCreds()},
b: &grpcservice.Config{TargetURI: target, ChannelCredentials: insecureCreds(), CallCredentials: []*xdscreds.CallCreds{
xdscreds.NewCallCreds(nil, xdscreds.Identity{Type: "access_token"}, nil),
}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sharesChannel(tt.a, tt.b); got != tt.want {
t.Errorf("sharesChannel() = %v, want %v", got, tt.want)
}
})
}
}
var cmpOpts = []cmp.Option{
cmp.AllowUnexported(
baseConfig{},
overrideConfig{},
processingModes{},
optional.Optional[*grpcservice.Config]{},
optional.Optional[processingModes]{},
optional.Optional[bool]{},
),
cmp.Transformer("RegexpToString", func(r *regexp.Regexp) string {
if r == nil {
return ""
}
return r.String()
}),
}
func (s) TestParseFilterConfig_Success(t *testing.T) {
opts := testParseOptions(t, "localhost:1234")
tests := []struct {
name string
cfg proto.Message
wantCfg httpfilter.FilterConfig
}{
{
name: "DefaultConfig",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{},
})
return m
}(),
wantCfg: baseConfig{
server: &grpcservice.Config{TargetURI: "localhost:1234", ChannelCredentials: allowlistInsecureCreds},
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSend,
responseTrailerMode: modeSkip,
requestBodyMode: modeSkip,
responseBodyMode: modeSkip,
},
failureModeAllow: false,
deferredCloseTimeout: defaultDeferredCloseTimeout,
},
},
{
name: "ConfigWithGrpcMode",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{
RequestBodyMode: fpb.ProcessingMode_GRPC,
ResponseBodyMode: fpb.ProcessingMode_GRPC,
ResponseTrailerMode: fpb.ProcessingMode_SEND,
},
})
return m
}(),
wantCfg: baseConfig{
server: &grpcservice.Config{TargetURI: "localhost:1234", ChannelCredentials: allowlistInsecureCreds},
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSend,
responseTrailerMode: modeSend,
requestBodyMode: modeSend,
responseBodyMode: modeSend,
},
failureModeAllow: false,
deferredCloseTimeout: defaultDeferredCloseTimeout,
},
},
{
name: "ConfigWithMutationRules",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{},
MutationRules: &mutationpb.HeaderMutationRules{
AllowExpression: &matcherpb.RegexMatcher{Regex: ".*"},
DisallowExpression: &matcherpb.RegexMatcher{Regex: "a"},
},
})
return m
}(),
wantCfg: baseConfig{
server: &grpcservice.Config{TargetURI: "localhost:1234", ChannelCredentials: allowlistInsecureCreds},
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSend,
responseTrailerMode: modeSkip,
requestBodyMode: modeSkip,
responseBodyMode: modeSkip,
},
failureModeAllow: false,
mutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:.*)$"),
DisallowExpr: regexp.MustCompile("^(?:a)$"),
},
deferredCloseTimeout: defaultDeferredCloseTimeout,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := builder{}
got, err := b.ParseFilterConfig(tt.cfg, opts)
if err != nil {
t.Fatalf("ParseFilterConfig() returned unexpected error: %v", err)
}
if diff := cmp.Diff(got, tt.wantCfg, cmpOpts...); diff != "" {
t.Fatalf("ParseFilterConfig() returned unexpected config (-got +want):\n%s", diff)
}
})
}
}
func (s) TestParseFilterConfig_Errors(t *testing.T) {
opts := testParseOptions(t, "localhost:1234")
tests := []struct {
name string
cfg proto.Message
wantErr string
}{
{
name: "MissingGrpcService",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{ProcessingMode: &fpb.ProcessingMode{}})
return m
}(),
wantErr: "extproc: empty grpc_service provided",
},
{
name: "UnsupportedGrpcService_EnvoyGrpc",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_EnvoyGrpc_{
EnvoyGrpc: &corepb.GrpcService_EnvoyGrpc{
ClusterName: "cluster",
},
},
},
ProcessingMode: &fpb.ProcessingMode{},
})
return m
}(),
wantErr: "only google_grpc GrpcService config is supported",
},
{
name: "MissingProcessingMode",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
})
return m
}(),
wantErr: "extproc: missing processing_mode",
},
{
name: "InvalidProcessingMode_RequestBodyStreamed",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{RequestBodyMode: fpb.ProcessingMode_STREAMED},
})
return m
}(),
wantErr: "extproc: invalid request body mode STREAMED",
},
{
name: "InvalidProcessingMode_ResponseBodyStreamed",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{ResponseBodyMode: fpb.ProcessingMode_STREAMED},
})
return m
}(),
wantErr: "extproc: invalid response body mode STREAMED",
},
{
name: "InvalidProcessingMode_ResponseBodySendTrailerDefault",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{
ResponseBodyMode: fpb.ProcessingMode_GRPC,
ResponseTrailerMode: fpb.ProcessingMode_DEFAULT,
},
})
return m
}(),
wantErr: fmt.Sprintf("extproc: invalid response trailer mode DEFAULT: must be %q when response body mode is %q", "SEND", "GRPC"),
},
{
name: "InvalidProcessingMode_ResponseBodySendTrailerSkip",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{
ResponseBodyMode: fpb.ProcessingMode_GRPC,
ResponseTrailerMode: fpb.ProcessingMode_SKIP,
},
})
return m
}(),
wantErr: fmt.Sprintf("extproc: invalid response trailer mode SKIP: must be %q when response body mode is %q", "SEND", "GRPC"),
},
{
name: "InvalidMutationRules",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{},
MutationRules: &mutationpb.HeaderMutationRules{
AllowExpression: &matcherpb.RegexMatcher{Regex: "["},
},
})
return m
}(),
wantErr: "httpfilter: error parsing regexp",
},
{
name: "InvalidAllowedHeaders_EmptyPrefix",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "localhost:1234",
},
},
},
ProcessingMode: &fpb.ProcessingMode{},
ForwardRules: &fpb.HeaderForwardingRules{
AllowedHeaders: &matcherpb.ListStringMatcher{
Patterns: []*matcherpb.StringMatcher{
{
MatchPattern: &matcherpb.StringMatcher_Prefix{Prefix: ""},
},
},
},
},
})
return m
}(),
wantErr: "empty prefix is not allowed",
},
{
name: "InvalidServerConfig_EmptyTargetURI",
cfg: func() proto.Message {
m, _ := anypb.New(&fpb.ExternalProcessor{
GrpcService: &corepb.GrpcService{
TargetSpecifier: &corepb.GrpcService_GoogleGrpc_{
GoogleGrpc: &corepb.GrpcService_GoogleGrpc{
TargetUri: "",
},
},
},
ProcessingMode: &fpb.ProcessingMode{},
})
return m
}(),
wantErr: "target_uri must be non-empty",
},
{
name: "InvalidConfigType",
cfg: &fpb.ExternalProcessor{}, // Not Any
wantErr: "extproc: error parsing config",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := builder{}
_, err := builder.ParseFilterConfig(tt.cfg, opts)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ParseFilterConfig() returned error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func (s) TestParseFilterConfigOverride_Success(t *testing.T) {
tests := []struct {
name string
override proto.Message
wantOverrideCfg httpfilter.FilterConfig
}{
{
name: "EmptyOverride",
override: func() proto.Message {
m, _ := anypb.New(&fpb.ExtProcPerRoute{})
return m
}(),
wantOverrideCfg: overrideConfig{},
},
{
name: "GrpcProcessingMode",
override: func() proto.Message {
m, _ := anypb.New(
&fpb.ExtProcPerRoute{
Override: &fpb.ExtProcPerRoute_Overrides{
Overrides: &fpb.ExtProcOverrides{
ProcessingMode: &fpb.ProcessingMode{
RequestBodyMode: fpb.ProcessingMode_GRPC,
ResponseBodyMode: fpb.ProcessingMode_GRPC,
ResponseTrailerMode: fpb.ProcessingMode_SEND,
},
},
},
})
return m
}(),
wantOverrideCfg: overrideConfig{
processingModes: optional.New(processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSend,
responseTrailerMode: modeSend,
requestBodyMode: modeSend,
responseBodyMode: modeSend,
}),
},
},
{
name: "FailureModeAllow",
override: func() proto.Message {
m, _ := anypb.New(
&fpb.ExtProcPerRoute{
Override: &fpb.ExtProcPerRoute_Overrides{
Overrides: &fpb.ExtProcOverrides{
FailureModeAllow: wrapperspb.Bool(true),
},
},
})
return m
}(),
wantOverrideCfg: overrideConfig{
failureModeAllow: optional.New(true),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := builder{}
got, err := builder.ParseFilterConfigOverride(tt.override, httpfilter.ParseOptions{})
if err != nil {
t.Fatalf("ParseFilterConfigOverride() returned unexpected error: %v", err)
}
if diff := cmp.Diff(got, tt.wantOverrideCfg, cmpOpts...); diff != "" {
t.Fatalf("ParseFilterConfigOverride() returned unexpected config (-got +want):\n%s", diff)
}
})
}
}
func (s) TestParseFilterConfigOverride_Errors(t *testing.T) {
tests := []struct {
name string
override proto.Message
wantErr string
}{
{
name: "ProcessingMode_RequestBodyStreamed",
override: func() proto.Message {
m, _ := anypb.New(&fpb.ExtProcPerRoute{
Override: &fpb.ExtProcPerRoute_Overrides{
Overrides: &fpb.ExtProcOverrides{
ProcessingMode: &fpb.ProcessingMode{
RequestBodyMode: fpb.ProcessingMode_STREAMED,
},
},
},
})
return m
}(),
wantErr: "extproc: invalid request body mode STREAMED",
},
{
name: "ProcessingMode_ResponseBodyStreamed",
override: func() proto.Message {
m, _ := anypb.New(&fpb.ExtProcPerRoute{
Override: &fpb.ExtProcPerRoute_Overrides{
Overrides: &fpb.ExtProcOverrides{
ProcessingMode: &fpb.ProcessingMode{
ResponseBodyMode: fpb.ProcessingMode_STREAMED,
},
},
},
})
return m
}(),
wantErr: "extproc: invalid response body mode STREAMED",
},
{
name: "ProcessingMode_ResponseBodySendTrailerDefault",
override: func() proto.Message {
m, _ := anypb.New(&fpb.ExtProcPerRoute{
Override: &fpb.ExtProcPerRoute_Overrides{
Overrides: &fpb.ExtProcOverrides{
ProcessingMode: &fpb.ProcessingMode{
ResponseBodyMode: fpb.ProcessingMode_GRPC,
ResponseTrailerMode: fpb.ProcessingMode_DEFAULT,
},
},
},
})
return m
}(),
wantErr: fmt.Sprintf("extproc: invalid response trailer mode DEFAULT: must be %q when response body mode is %q", "SEND", "GRPC"),
},
{
name: "ProcessingMode_ResponseBodySendTrailerSkip",
override: func() proto.Message {
m, _ := anypb.New(&fpb.ExtProcPerRoute{
Override: &fpb.ExtProcPerRoute_Overrides{
Overrides: &fpb.ExtProcOverrides{
ProcessingMode: &fpb.ProcessingMode{
ResponseBodyMode: fpb.ProcessingMode_GRPC,
ResponseTrailerMode: fpb.ProcessingMode_SKIP,
},
},
},
})
return m
}(),
wantErr: fmt.Sprintf("extproc: invalid response trailer mode SKIP: must be %q when response body mode is %q", "SEND", "GRPC"),
},
{
name: "InvalidOverrideType",
override: &fpb.ExtProcOverrides{}, // Not Any
wantErr: "extproc: error parsing override",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := builder{}
_, err := builder.ParseFilterConfigOverride(tt.override, httpfilter.ParseOptions{})
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ParseFilterConfigOverride() returned error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func (s) TestBuildClientInterceptor_Success(t *testing.T) {
tests := []struct {
name string
cfg httpfilter.FilterConfig
override httpfilter.FilterConfig
wantConfig baseConfig
}{
{
name: "ConfigUsingOnlyBase",
cfg: baseConfig{
failureModeAllow: true,
requestAttributes: []string{"attr1"},
responseAttributes: []string{"attr2"},
observabilityMode: true,
disableImmediateResponse: true,
deferredCloseTimeout: 10 * time.Second,
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSkip,
responseTrailerMode: modeSend,
requestBodyMode: modeSend,
responseBodyMode: modeSkip,
},
server: &grpcservice.Config{
TargetURI: testBaseURI,
ChannelCredentials: xdscreds.NewChannelCreds(insecure.NewBundle(), xdscreds.Identity{Type: "test-channel-creds"}, nil),
CallCredentials: []*xdscreds.CallCreds{xdscreds.NewCallCreds(testPerRPCCreds{}, xdscreds.Identity{Type: "test-call-creds"}, nil)},
InitialMetadata: metadata.MD(metadata.Pairs("key1", "value1")),
Timeout: 5 * time.Second,
},
mutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:allow-.*)$"),
DisallowExpr: regexp.MustCompile("^(?:disallow-.*)$"),
DisallowAll: true,
DisallowIsError: true,
},
allowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("allow-header", false)},
},
wantConfig: baseConfig{
failureModeAllow: true,
requestAttributes: []string{"attr1"},
responseAttributes: []string{"attr2"},
mutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:allow-.*)$"),
DisallowExpr: regexp.MustCompile("^(?:disallow-.*)$"),
DisallowAll: true,
DisallowIsError: true,
},
observabilityMode: true,
disableImmediateResponse: true,
deferredCloseTimeout: 10 * time.Second,
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSkip,
responseTrailerMode: modeSend,
requestBodyMode: modeSend,
responseBodyMode: modeSkip,
},
server: &grpcservice.Config{
TargetURI: testBaseURI,
ChannelCredentials: xdscreds.NewChannelCreds(insecure.NewBundle(), xdscreds.Identity{Type: "test-channel-creds"}, nil),
CallCredentials: []*xdscreds.CallCreds{xdscreds.NewCallCreds(testPerRPCCreds{}, xdscreds.Identity{Type: "test-call-creds"}, nil)},
InitialMetadata: metadata.MD(metadata.Pairs("key1", "value1")),
Timeout: 5 * time.Second,
},
allowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("allow-header", false)},
},
},
{
name: "ConfigUsingBaseAndOverride",
cfg: baseConfig{
failureModeAllow: false,
requestAttributes: []string{"base-attr1"},
responseAttributes: []string{"base-attr2"},
observabilityMode: true,
disableImmediateResponse: true,
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSkip,
responseTrailerMode: modeSend,
requestBodyMode: modeSend,
responseBodyMode: modeSkip,
},
server: &grpcservice.Config{
TargetURI: testBaseURI,
ChannelCredentials: allowlistInsecureCreds,
Timeout: time.Second,
InitialMetadata: metadata.MD(metadata.Pairs("key1", "value1")),
},
mutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:allow-.*)$"),
DisallowExpr: regexp.MustCompile("^(?:disallow-.*)$"),
DisallowAll: true,
DisallowIsError: true,
},
allowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("allow-header", false)},
disallowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("disallow-header", false)},
deferredCloseTimeout: 10 * time.Second,
},
override: overrideConfig{
failureModeAllow: optional.New(true),
requestAttributes: []string{"override-attr1"},
responseAttributes: []string{"override-attr2"},
processingModes: optional.New(processingModes{
requestHeaderMode: modeSkip,
responseHeaderMode: modeSend,
responseTrailerMode: modeSkip,
requestBodyMode: modeSkip,
responseBodyMode: modeSend,
}),
server: optional.New(&grpcservice.Config{
TargetURI: "override-uri",
ChannelCredentials: allowlistInsecureCreds,
}),
},
wantConfig: baseConfig{
failureModeAllow: true,
requestAttributes: []string{"override-attr1"},
responseAttributes: []string{"override-attr2"},
mutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:allow-.*)$"),
DisallowExpr: regexp.MustCompile("^(?:disallow-.*)$"),
DisallowAll: true,
DisallowIsError: true,
},
observabilityMode: true,
disableImmediateResponse: true,
deferredCloseTimeout: 10 * time.Second,
processingModes: processingModes{
requestHeaderMode: modeSkip,
responseHeaderMode: modeSend,
responseTrailerMode: modeSkip,
requestBodyMode: modeSkip,
responseBodyMode: modeSend,
},
server: &grpcservice.Config{
TargetURI: "override-uri",
ChannelCredentials: allowlistInsecureCreds,
},
allowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("allow-header", false)},
disallowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("disallow-header", false)},
},
},
{
name: "ConfigUsingBaseAndPartialOverride",
cfg: baseConfig{
failureModeAllow: false,
requestAttributes: []string{"base-attr1"},
responseAttributes: []string{"base-attr2"},
observabilityMode: true,
disableImmediateResponse: true,
deferredCloseTimeout: 10 * time.Second,
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSkip,
responseTrailerMode: modeSend,
requestBodyMode: modeSend,
responseBodyMode: modeSkip,
},
server: &grpcservice.Config{
TargetURI: testBaseURI,
ChannelCredentials: allowlistInsecureCreds,
Timeout: time.Second,
InitialMetadata: metadata.MD(metadata.Pairs("key1", "value1")),
},
mutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:allow-.*)$"),
DisallowExpr: regexp.MustCompile("^(?:disallow-.*)$"),
DisallowAll: true,
DisallowIsError: true,
},
allowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("allow-header", false)},
disallowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("disallow-header", false)},
},
override: overrideConfig{
failureModeAllow: optional.New(true),
},
wantConfig: baseConfig{
failureModeAllow: true,
requestAttributes: []string{"base-attr1"},
responseAttributes: []string{"base-attr2"},
mutationRules: httpfilter.HeaderMutationRules{
AllowExpr: regexp.MustCompile("^(?:allow-.*)$"),
DisallowExpr: regexp.MustCompile("^(?:disallow-.*)$"),
DisallowAll: true,
DisallowIsError: true,
},
observabilityMode: true,
disableImmediateResponse: true,
deferredCloseTimeout: 10 * time.Second,
processingModes: processingModes{
requestHeaderMode: modeSend,
responseHeaderMode: modeSkip,
responseTrailerMode: modeSend,
requestBodyMode: modeSend,
responseBodyMode: modeSkip,
},
server: &grpcservice.Config{
TargetURI: testBaseURI,
ChannelCredentials: allowlistInsecureCreds,
Timeout: time.Second,
InitialMetadata: metadata.MD(metadata.Pairs("key1", "value1")),
},
allowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("allow-header", false)},
disallowedHeaders: []matcher.StringMatcher{matcher.NewExactStringMatcher("disallow-header", false)},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
builder := builder{}
filter := builder.BuildClientFilter(httpfilter.ClientFilterOptions{})
defer filter.Close()
intptr, err := filter.BuildClientInterceptor(tc.cfg, tc.override)
if err != nil {
t.Fatalf("BuildClientInterceptor() returned unexpected error: %v", err)
}
defer intptr.Close()
ic, _ := intptr.(*clientInterceptor)
if diff := cmp.Diff(ic.config, tc.wantConfig, cmpOpts...); diff != "" {
t.Fatalf("Interceptor config returned unexpected diff (-got +want):\n%s", diff)
}
})
}
}
func (s) TestBuildClientInterceptor_Failure(t *testing.T) {
// incorrectFilterConfig embeds httpfilter.FilterConfig but is not of type
// baseConfig/overrideConfig, and is used to test incorrect config types being
// passed to BuildClientInterceptor.
type incorrectFilterConfig struct {
httpfilter.FilterConfig
}
tests := []struct {
name string
cfg httpfilter.FilterConfig
override httpfilter.FilterConfig
wantErr string
}{
{
name: "NilConfig",
cfg: nil,
wantErr: "extproc: incorrect config type provided",
},
{
name: "IncorrectConfigType",
cfg: incorrectFilterConfig{},
wantErr: "extproc: incorrect config type provided",
},
{
name: "IncorrectOverrideType",
cfg: baseConfig{},
override: incorrectFilterConfig{},
wantErr: "extproc: incorrect override config type provided",
},
{
name: "ChannelCreationFailure",
cfg: baseConfig{
server: &grpcservice.Config{
TargetURI: "error-uri",
},
},
wantErr: fmt.Sprintf("extproc: failed to create channel to the external processor server %q: dial error", "error-uri"),
},
{
name: "ChannelCreationFailureInOverride",
cfg: baseConfig{
server: &grpcservice.Config{
TargetURI: testBaseURI,
},
},
override: overrideConfig{
server: optional.New(&grpcservice.Config{
TargetURI: "error-uri",
}),
},
wantErr: fmt.Sprintf("extproc: failed to create channel to the external processor server %q: dial error", "error-uri"),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
overrideCreateExtProcChannelWithError(t)
builder := builder{}
filter := builder.BuildClientFilter(httpfilter.ClientFilterOptions{})
defer filter.Close()
_, err := filter.BuildClientInterceptor(tc.cfg, tc.override)
if err == nil {
t.Fatalf("BuildClientInterceptor() returned nil error, want error %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("BuildClientInterceptor() returned error: %v, want %v", err, tc.wantErr)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package internal contains functionality internal to the extproc package.
package internal
import (
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/internal/xds/grpcservice"
)
var (
// CreateExtProcChannel creates the channel to the external processing
// server described by the given config. The returned function closes the
// channel. It is a variable so that tests can intercept channel creation
// and observe its release.
CreateExtProcChannel = func(server *grpcservice.Config) (grpc.ClientConnInterface, func(), error) {
conn, err := server.Dial()
if err != nil {
return nil, nil, err
}
return conn, func() { conn.Close() }, nil
}
// RegisterForTesting registers the external processor HTTP Filter for testing
// purposes.
RegisterForTesting func()
// UnregisterForTesting unregisters the external processor HTTP Filter for
// testing purposes.
UnregisterForTesting func()
// TimeNowFunc returns the current time.Time, and can be overridden for
// testing purposes.
TimeNowFunc func() time.Time
// TimeSinceFunc returns the time elapsed, and can be overridden for testing
// purposes.
TimeSinceFunc func(t time.Time) time.Duration
// DefaultFlowControlWindowSize is the initial flow control window size for
// the external processor filter and can be overridden for testing purposes.
DefaultFlowControlWindowSize int64 = 64 * 1024
)

View File

@@ -0,0 +1,54 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package extproc
import (
estats "google.golang.org/grpc/experimental/stats"
)
var (
clientHeadersDurationMetric = estats.RegisterFloat64Histo(estats.MetricDescriptor{
Name: "grpc.client_ext_proc.client_headers_duration",
Description: "Time between when the ext_proc filter sees the client's headers and when it allows those headers to continue on to the next filter.",
Unit: "s",
Labels: []string{"grpc.target"},
Default: false,
})
clientHalfCloseDurationMetric = estats.RegisterFloat64Histo(estats.MetricDescriptor{
Name: "grpc.client_ext_proc.client_half_close_duration",
Description: "Time between when the ext_proc filter sees the client's half-close and when it allows that half-close to continue on to the next filter.",
Unit: "s",
Labels: []string{"grpc.target"},
Default: false,
})
serverHeadersDurationMetric = estats.RegisterFloat64Histo(estats.MetricDescriptor{
Name: "grpc.client_ext_proc.server_headers_duration",
Description: "Time between when the ext_proc filter sees the server's headers and when it allows those headers to continue on to the next filter.",
Unit: "s",
Labels: []string{"grpc.target"},
Default: false,
})
serverTrailersDurationMetric = estats.RegisterFloat64Histo(estats.MetricDescriptor{
Name: "grpc.client_ext_proc.server_trailers_duration",
Description: "Time between when the ext_proc filter sees the server's trailers and when it allows those trailers to continue on to the next filter.",
Unit: "s",
Labels: []string{"grpc.target"},
Default: false,
})
)

View File

@@ -0,0 +1,311 @@
/*
*
* Copyright 2021 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package fault implements the Envoy Fault Injection HTTP filter.
package fault
import (
"context"
"errors"
"fmt"
"io"
rand "math/rand/v2"
"strconv"
"sync/atomic"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
iresolver "google.golang.org/grpc/internal/resolver"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
cpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/common/fault/v3"
fpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/fault/v3"
tpb "github.com/envoyproxy/go-control-plane/envoy/type/v3"
)
const headerAbortHTTPStatus = "x-envoy-fault-abort-request"
const headerAbortGRPCStatus = "x-envoy-fault-abort-grpc-request"
const headerAbortPercentage = "x-envoy-fault-abort-request-percentage"
const headerDelayPercentage = "x-envoy-fault-delay-request-percentage"
const headerDelayDuration = "x-envoy-fault-delay-request"
var statusMap = map[int]codes.Code{
400: codes.Internal,
401: codes.Unauthenticated,
403: codes.PermissionDenied,
404: codes.Unimplemented,
429: codes.Unavailable,
502: codes.Unavailable,
503: codes.Unavailable,
504: codes.Unavailable,
}
func init() {
httpfilter.Register(builder{})
}
type builder struct {
}
type config struct {
httpfilter.FilterConfig
config *fpb.HTTPFault
}
func (builder) TypeURLs() []string {
return []string{"type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault"}
}
// Parsing is the same for the base config and the override config.
func parseConfig(cfg proto.Message) (httpfilter.FilterConfig, error) {
if cfg == nil {
return nil, fmt.Errorf("fault: nil configuration message provided")
}
m, ok := cfg.(*anypb.Any)
if !ok {
return nil, fmt.Errorf("fault: error parsing config %v: unknown type %T", cfg, cfg)
}
msg := new(fpb.HTTPFault)
if err := m.UnmarshalTo(msg); err != nil {
return nil, fmt.Errorf("fault: error parsing config %v: %v", cfg, err)
}
return config{config: msg}, nil
}
func (builder) ParseFilterConfig(cfg proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
return parseConfig(cfg)
}
func (builder) ParseFilterConfigOverride(override proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
return parseConfig(override)
}
func (builder) IsTerminal() bool {
return false
}
func (builder) BuildClientFilter(httpfilter.ClientFilterOptions) httpfilter.ClientFilter {
return clientFilter{}
}
var _ httpfilter.ClientFilterBuilder = builder{}
type clientFilter struct{}
func (clientFilter) Close() {}
func (clientFilter) BuildClientInterceptor(cfg, override httpfilter.FilterConfig) (httpfilter.ClientInterceptor, error) {
if cfg == nil {
return nil, fmt.Errorf("fault: nil config provided")
}
c, ok := cfg.(config)
if !ok {
return nil, fmt.Errorf("fault: incorrect config type provided (%T): %v", cfg, cfg)
}
if override != nil {
// override completely replaces the listener configuration; but we
// still validate the listener config type.
c, ok = override.(config)
if !ok {
return nil, fmt.Errorf("fault: incorrect override config type provided (%T): %v", override, override)
}
}
icfg := c.config
if (icfg.GetMaxActiveFaults() != nil && icfg.GetMaxActiveFaults().GetValue() == 0) ||
(icfg.GetDelay() == nil && icfg.GetAbort() == nil) {
return nil, nil
}
return &interceptor{config: icfg}, nil
}
type interceptor struct {
config *fpb.HTTPFault
}
var activeFaults uint32 // global active faults; accessed atomically
func (i *interceptor) NewStream(ctx context.Context, _ iresolver.RPCInfo, newStream func(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStream, error), opts ...grpc.CallOption) (grpc.ClientStream, error) {
if maxAF := i.config.GetMaxActiveFaults(); maxAF != nil {
defer atomic.AddUint32(&activeFaults, ^uint32(0)) // decrement counter
if af := atomic.AddUint32(&activeFaults, 1); af > maxAF.GetValue() {
// Would exceed maximum active fault limit.
return newStream(ctx, opts...)
}
}
if err := injectDelay(ctx, i.config.GetDelay()); err != nil {
return nil, err
}
if err := injectAbort(ctx, i.config.GetAbort()); err != nil {
if err == errOKStream {
return &okStream{ctx: ctx}, nil
}
return nil, err
}
return newStream(ctx, opts...)
}
func (i *interceptor) Close() {}
// For overriding in tests
var randIntn = rand.IntN
var newTimer = time.NewTimer
func injectDelay(ctx context.Context, delayCfg *cpb.FaultDelay) error {
numerator, denominator := splitPct(delayCfg.GetPercentage())
var delay time.Duration
switch delayType := delayCfg.GetFaultDelaySecifier().(type) {
case *cpb.FaultDelay_FixedDelay:
delay = delayType.FixedDelay.AsDuration()
case *cpb.FaultDelay_HeaderDelay_:
md, _ := metadata.FromOutgoingContext(ctx)
v := md[headerDelayDuration]
if v == nil {
// No delay configured for this RPC.
return nil
}
ms, ok := parseIntFromMD(v)
if !ok {
// Malformed header; no delay.
return nil
}
delay = time.Duration(ms) * time.Millisecond
if v := md[headerDelayPercentage]; v != nil {
if num, ok := parseIntFromMD(v); ok && num < numerator {
numerator = num
}
}
}
if delay == 0 || randIntn(denominator) >= numerator {
return nil
}
t := newTimer(delay)
select {
case <-t.C:
case <-ctx.Done():
t.Stop()
return ctx.Err()
}
return nil
}
func injectAbort(ctx context.Context, abortCfg *fpb.FaultAbort) error {
numerator, denominator := splitPct(abortCfg.GetPercentage())
code := codes.OK
okCode := false
switch errType := abortCfg.GetErrorType().(type) {
case *fpb.FaultAbort_HttpStatus:
code, okCode = grpcFromHTTP(int(errType.HttpStatus))
case *fpb.FaultAbort_GrpcStatus:
code, okCode = sanitizeGRPCCode(codes.Code(errType.GrpcStatus)), true
case *fpb.FaultAbort_HeaderAbort_:
md, _ := metadata.FromOutgoingContext(ctx)
if v := md[headerAbortHTTPStatus]; v != nil {
// HTTP status has priority over gRPC status.
if httpStatus, ok := parseIntFromMD(v); ok {
code, okCode = grpcFromHTTP(httpStatus)
}
} else if v := md[headerAbortGRPCStatus]; v != nil {
if grpcStatus, ok := parseIntFromMD(v); ok {
code, okCode = sanitizeGRPCCode(codes.Code(grpcStatus)), true
}
}
if v := md[headerAbortPercentage]; v != nil {
if num, ok := parseIntFromMD(v); ok && num < numerator {
numerator = num
}
}
}
if !okCode || randIntn(denominator) >= numerator {
return nil
}
if code == codes.OK {
return errOKStream
}
return status.Errorf(code, "RPC terminated due to fault injection")
}
var errOKStream = errors.New("stream terminated early with OK status")
// parseIntFromMD returns the integer in the last header or nil if parsing
// failed.
func parseIntFromMD(header []string) (int, bool) {
if len(header) == 0 {
return 0, false
}
v, err := strconv.Atoi(header[len(header)-1])
return v, err == nil
}
func splitPct(fp *tpb.FractionalPercent) (num int, den int) {
if fp == nil {
return 0, 100
}
num = int(fp.GetNumerator())
switch fp.GetDenominator() {
case tpb.FractionalPercent_HUNDRED:
return num, 100
case tpb.FractionalPercent_TEN_THOUSAND:
return num, 10 * 1000
case tpb.FractionalPercent_MILLION:
return num, 1000 * 1000
}
return num, 100
}
func grpcFromHTTP(httpStatus int) (codes.Code, bool) {
if httpStatus < 200 || httpStatus >= 600 {
// Malformed; ignore this fault type.
return codes.OK, false
}
if c := statusMap[httpStatus]; c != codes.OK {
// OK = 0/the default for the map.
return c, true
}
// All undefined HTTP status codes convert to Unknown. HTTP status of 200
// is "success", but gRPC converts to Unknown due to missing grpc status.
return codes.Unknown, true
}
func sanitizeGRPCCode(c codes.Code) codes.Code {
if c > codes.Code(16) {
return codes.Unknown
}
return c
}
type okStream struct {
ctx context.Context
}
func (*okStream) Header() (metadata.MD, error) { return nil, nil }
func (*okStream) Trailer() metadata.MD { return nil }
func (*okStream) CloseSend() error { return nil }
func (o *okStream) Context() context.Context { return o.ctx }
func (*okStream) SendMsg(any) error { return io.EOF }
func (*okStream) RecvMsg(any) error { return io.EOF }

View File

@@ -0,0 +1,661 @@
//go:build !386
// +build !386
/*
*
* Copyright 2020 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package xds_test contains e2e tests for xDS use.
package fault
import (
"context"
"fmt"
"io"
rand "math/rand/v2"
"net"
"reflect"
"testing"
"time"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal"
"google.golang.org/grpc/internal/grpctest"
"google.golang.org/grpc/internal/stubserver"
"google.golang.org/grpc/internal/testutils"
"google.golang.org/grpc/internal/testutils/xds/e2e"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/resolver"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/wrapperspb"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
cpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/common/fault/v3"
fpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/fault/v3"
v3httppb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
tpb "github.com/envoyproxy/go-control-plane/envoy/type/v3"
testgrpc "google.golang.org/grpc/interop/grpc_testing"
testpb "google.golang.org/grpc/interop/grpc_testing"
_ "google.golang.org/grpc/internal/xds/balancer" // Register the balancers.
_ "google.golang.org/grpc/internal/xds/httpfilter/router" // Register the router filter.
_ "google.golang.org/grpc/internal/xds/resolver" // Register the xds_resolver.
)
const defaultTestTimeout = 10 * time.Second
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
// clientSetup performs a bunch of steps common to all xDS server tests here:
// - spin up an xDS management server on a local port
// - spin up a gRPC server and register the test service on it
// - create a local TCP listener and start serving on it
//
// Returns the following:
// - the management server: tests use this to configure resources
// - nodeID expected by the management server: this is set in the Node proto
// sent by the xdsClient for queries
// - the port the server is listening on
// - contents of the bootstrap configuration pointing to xDS management
// server
func clientSetup(t *testing.T) (*e2e.ManagementServer, string, uint32, []byte) {
// Spin up a xDS management server on a local port.
nodeID := uuid.New().String()
managementServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
// Create a bootstrap config pointing to the above management server with
// the nodeID.
bootstrapContents := e2e.DefaultBootstrapContents(t, nodeID, managementServer.Address)
// Create a local listener.
lis, err := testutils.LocalTCPListener()
if err != nil {
t.Fatalf("testutils.LocalTCPListener() failed: %v", err)
}
// Initialize a test gRPC server, assign it to the stub server, and start the test service.
stub := &stubserver.StubServer{
Listener: lis,
EmptyCallF: func(context.Context, *testpb.Empty) (*testpb.Empty, error) {
return &testpb.Empty{}, nil
},
FullDuplexCallF: func(stream testgrpc.TestService_FullDuplexCallServer) error {
// End RPC after client does a CloseSend.
for {
if _, err := stream.Recv(); err == io.EOF {
return nil
} else if err != nil {
return err
}
}
},
}
stubserver.StartTestService(t, stub)
t.Cleanup(stub.S.Stop)
return managementServer, nodeID, uint32(lis.Addr().(*net.TCPAddr).Port), bootstrapContents
}
func (s) TestFaultInjection_Unary(t *testing.T) {
type subcase struct {
name string
code codes.Code
repeat int
randIn []int // Intn calls per-repeat (not per-subcase)
delays []time.Duration // NewTimer calls per-repeat (not per-subcase)
md metadata.MD
}
testCases := []struct {
name string
cfgs []*fpb.HTTPFault
randOutInc int
want []subcase
}{{
name: "max faults zero",
cfgs: []*fpb.HTTPFault{{
MaxActiveFaults: wrapperspb.UInt32(0),
Abort: &fpb.FaultAbort{
Percentage: &tpb.FractionalPercent{Numerator: 100, Denominator: tpb.FractionalPercent_HUNDRED},
ErrorType: &fpb.FaultAbort_GrpcStatus{GrpcStatus: uint32(codes.Aborted)},
},
}},
randOutInc: 5,
want: []subcase{{
code: codes.OK,
repeat: 25,
}},
}, {
name: "no abort or delay",
cfgs: []*fpb.HTTPFault{{}},
randOutInc: 5,
want: []subcase{{
code: codes.OK,
repeat: 25,
}},
}, {
name: "abort always",
cfgs: []*fpb.HTTPFault{{
Abort: &fpb.FaultAbort{
Percentage: &tpb.FractionalPercent{Numerator: 100, Denominator: tpb.FractionalPercent_HUNDRED},
ErrorType: &fpb.FaultAbort_GrpcStatus{GrpcStatus: uint32(codes.Aborted)},
},
}},
randOutInc: 5,
want: []subcase{{
code: codes.Aborted,
randIn: []int{100},
repeat: 25,
}},
}, {
name: "abort 10%",
cfgs: []*fpb.HTTPFault{{
Abort: &fpb.FaultAbort{
Percentage: &tpb.FractionalPercent{Numerator: 100000, Denominator: tpb.FractionalPercent_MILLION},
ErrorType: &fpb.FaultAbort_GrpcStatus{GrpcStatus: uint32(codes.Aborted)},
},
}},
randOutInc: 50000,
want: []subcase{{
name: "[0,10]%",
code: codes.Aborted,
randIn: []int{1000000},
repeat: 2,
}, {
name: "(10,100]%",
code: codes.OK,
randIn: []int{1000000},
repeat: 18,
}, {
name: "[0,10]% again",
code: codes.Aborted,
randIn: []int{1000000},
repeat: 2,
}},
}, {
name: "delay always",
cfgs: []*fpb.HTTPFault{{
Delay: &cpb.FaultDelay{
Percentage: &tpb.FractionalPercent{Numerator: 100, Denominator: tpb.FractionalPercent_HUNDRED},
FaultDelaySecifier: &cpb.FaultDelay_FixedDelay{FixedDelay: durationpb.New(time.Second)},
},
}},
randOutInc: 5,
want: []subcase{{
randIn: []int{100},
repeat: 25,
delays: []time.Duration{time.Second},
}},
}, {
name: "delay 10%",
cfgs: []*fpb.HTTPFault{{
Delay: &cpb.FaultDelay{
Percentage: &tpb.FractionalPercent{Numerator: 1000, Denominator: tpb.FractionalPercent_TEN_THOUSAND},
FaultDelaySecifier: &cpb.FaultDelay_FixedDelay{FixedDelay: durationpb.New(time.Second)},
},
}},
randOutInc: 500,
want: []subcase{{
name: "[0,10]%",
randIn: []int{10000},
repeat: 2,
delays: []time.Duration{time.Second},
}, {
name: "(10,100]%",
randIn: []int{10000},
repeat: 18,
}, {
name: "[0,10]% again",
randIn: []int{10000},
repeat: 2,
delays: []time.Duration{time.Second},
}},
}, {
name: "delay 80%, abort 50%",
cfgs: []*fpb.HTTPFault{{
Delay: &cpb.FaultDelay{
Percentage: &tpb.FractionalPercent{Numerator: 80, Denominator: tpb.FractionalPercent_HUNDRED},
FaultDelaySecifier: &cpb.FaultDelay_FixedDelay{FixedDelay: durationpb.New(3 * time.Second)},
},
Abort: &fpb.FaultAbort{
Percentage: &tpb.FractionalPercent{Numerator: 50, Denominator: tpb.FractionalPercent_HUNDRED},
ErrorType: &fpb.FaultAbort_GrpcStatus{GrpcStatus: uint32(codes.Unimplemented)},
},
}},
randOutInc: 5,
want: []subcase{{
name: "50% delay and abort",
code: codes.Unimplemented,
randIn: []int{100, 100},
repeat: 10,
delays: []time.Duration{3 * time.Second},
}, {
name: "30% delay, no abort",
randIn: []int{100, 100},
repeat: 6,
delays: []time.Duration{3 * time.Second},
}, {
name: "20% success",
randIn: []int{100, 100},
repeat: 4,
}, {
name: "50% delay and abort again",
code: codes.Unimplemented,
randIn: []int{100, 100},
repeat: 10,
delays: []time.Duration{3 * time.Second},
}},
}, {
name: "header abort",
cfgs: []*fpb.HTTPFault{{
Abort: &fpb.FaultAbort{
Percentage: &tpb.FractionalPercent{Numerator: 80, Denominator: tpb.FractionalPercent_HUNDRED},
ErrorType: &fpb.FaultAbort_HeaderAbort_{},
},
}},
randOutInc: 10,
want: []subcase{{
name: "30% abort; [0,30]%",
md: metadata.MD{
headerAbortGRPCStatus: []string{fmt.Sprintf("%d", codes.DataLoss)},
headerAbortPercentage: []string{"30"},
},
code: codes.DataLoss,
randIn: []int{100},
repeat: 3,
}, {
name: "30% abort; (30,60]%",
md: metadata.MD{
headerAbortGRPCStatus: []string{fmt.Sprintf("%d", codes.DataLoss)},
headerAbortPercentage: []string{"30"},
},
randIn: []int{100},
repeat: 3,
}, {
name: "80% abort; (60,80]%",
md: metadata.MD{
headerAbortGRPCStatus: []string{fmt.Sprintf("%d", codes.DataLoss)},
headerAbortPercentage: []string{"80"},
},
code: codes.DataLoss,
randIn: []int{100},
repeat: 2,
}, {
name: "cannot exceed percentage in filter",
md: metadata.MD{
headerAbortGRPCStatus: []string{fmt.Sprintf("%d", codes.DataLoss)},
headerAbortPercentage: []string{"100"},
},
randIn: []int{100},
repeat: 2,
}, {
name: "HTTP Status 404",
md: metadata.MD{
headerAbortHTTPStatus: []string{"404"},
headerAbortPercentage: []string{"100"},
},
code: codes.Unimplemented,
randIn: []int{100},
repeat: 1,
}, {
name: "HTTP Status 429",
md: metadata.MD{
headerAbortHTTPStatus: []string{"429"},
headerAbortPercentage: []string{"100"},
},
code: codes.Unavailable,
randIn: []int{100},
repeat: 1,
}, {
name: "HTTP Status 200",
md: metadata.MD{
headerAbortHTTPStatus: []string{"200"},
headerAbortPercentage: []string{"100"},
},
// No GRPC status, but HTTP Status of 200 translates to Unknown,
// per spec in statuscodes.md.
code: codes.Unknown,
randIn: []int{100},
repeat: 1,
}, {
name: "gRPC Status OK",
md: metadata.MD{
headerAbortGRPCStatus: []string{fmt.Sprintf("%d", codes.OK)},
headerAbortPercentage: []string{"100"},
},
// This should be Unimplemented (mismatched request/response
// count), per spec in statuscodes.md, but grpc-go currently
// returns io.EOF which status.Code() converts to Unknown
code: codes.Unknown,
randIn: []int{100},
repeat: 1,
}, {
name: "invalid header results in no abort",
md: metadata.MD{
headerAbortGRPCStatus: []string{"error"},
headerAbortPercentage: []string{"100"},
},
repeat: 1,
}, {
name: "invalid header results in default percentage",
md: metadata.MD{
headerAbortGRPCStatus: []string{fmt.Sprintf("%d", codes.DataLoss)},
headerAbortPercentage: []string{"error"},
},
code: codes.DataLoss,
randIn: []int{100},
repeat: 1,
}},
}, {
name: "header delay",
cfgs: []*fpb.HTTPFault{{
Delay: &cpb.FaultDelay{
Percentage: &tpb.FractionalPercent{Numerator: 80, Denominator: tpb.FractionalPercent_HUNDRED},
FaultDelaySecifier: &cpb.FaultDelay_HeaderDelay_{},
},
}},
randOutInc: 10,
want: []subcase{{
name: "30% delay; [0,30]%",
md: metadata.MD{
headerDelayDuration: []string{"2"},
headerDelayPercentage: []string{"30"},
},
randIn: []int{100},
delays: []time.Duration{2 * time.Millisecond},
repeat: 3,
}, {
name: "30% delay; (30, 60]%",
md: metadata.MD{
headerDelayDuration: []string{"2"},
headerDelayPercentage: []string{"30"},
},
randIn: []int{100},
repeat: 3,
}, {
name: "invalid header results in no delay",
md: metadata.MD{
headerDelayDuration: []string{"error"},
headerDelayPercentage: []string{"80"},
},
repeat: 1,
}, {
name: "invalid header results in default percentage",
md: metadata.MD{
headerDelayDuration: []string{"2"},
headerDelayPercentage: []string{"error"},
},
randIn: []int{100},
delays: []time.Duration{2 * time.Millisecond},
repeat: 1,
}, {
name: "invalid header results in default percentage",
md: metadata.MD{
headerDelayDuration: []string{"2"},
headerDelayPercentage: []string{"error"},
},
randIn: []int{100},
repeat: 1,
}, {
name: "cannot exceed percentage in filter",
md: metadata.MD{
headerDelayDuration: []string{"2"},
headerDelayPercentage: []string{"100"},
},
randIn: []int{100},
repeat: 1,
}},
}, {
name: "abort then delay filters",
cfgs: []*fpb.HTTPFault{{
Abort: &fpb.FaultAbort{
Percentage: &tpb.FractionalPercent{Numerator: 50, Denominator: tpb.FractionalPercent_HUNDRED},
ErrorType: &fpb.FaultAbort_GrpcStatus{GrpcStatus: uint32(codes.Unimplemented)},
},
}, {
Delay: &cpb.FaultDelay{
Percentage: &tpb.FractionalPercent{Numerator: 80, Denominator: tpb.FractionalPercent_HUNDRED},
FaultDelaySecifier: &cpb.FaultDelay_FixedDelay{FixedDelay: durationpb.New(time.Second)},
},
}},
randOutInc: 10,
want: []subcase{{
name: "50% delay and abort (abort skips delay)",
code: codes.Unimplemented,
randIn: []int{100},
repeat: 5,
}, {
name: "30% delay, no abort",
randIn: []int{100, 100},
repeat: 3,
delays: []time.Duration{time.Second},
}, {
name: "20% success",
randIn: []int{100, 100},
repeat: 2,
}},
}}
fs, nodeID, port, bc := clientSetup(t)
// Create an xDS resolver with the above bootstrap configuration.
xdsResolver, err := internal.NewXDSResolverWithConfigForTesting.(func([]byte) (resolver.Builder, error))(bc)
if err != nil {
t.Fatalf("Failed to create xDS resolver for testing: %v", err)
}
for tcNum, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
defer func() { randIntn = rand.IntN; newTimer = time.NewTimer }()
var intnCalls []int
var newTimerCalls []time.Duration
randOut := 0
randIntn = func(n int) int {
intnCalls = append(intnCalls, n)
return randOut % n
}
newTimer = func(d time.Duration) *time.Timer {
newTimerCalls = append(newTimerCalls, d)
return time.NewTimer(0)
}
serviceName := fmt.Sprintf("myservice%d", tcNum)
resources := e2e.DefaultClientResources(e2e.ResourceParams{
DialTarget: serviceName,
NodeID: nodeID,
Host: "localhost",
Port: port,
SecLevel: e2e.SecurityLevelNone,
})
hcm := new(v3httppb.HttpConnectionManager)
lis := resources.Listeners[0].GetApiListener().GetApiListener()
err := lis.UnmarshalTo(hcm)
if err != nil {
t.Fatal(err)
}
routerFilter := hcm.HttpFilters[len(hcm.HttpFilters)-1]
hcm.HttpFilters = nil
for i, cfg := range tc.cfgs {
hcm.HttpFilters = append(hcm.HttpFilters, e2e.HTTPFilter(fmt.Sprintf("fault%d", i), cfg))
}
hcm.HttpFilters = append(hcm.HttpFilters, routerFilter)
hcmAny := testutils.MarshalAny(t, hcm)
resources.Listeners[0].ApiListener.ApiListener = hcmAny
resources.Listeners[0].FilterChains[0].Filters[0].ConfigType = &v3listenerpb.Filter_TypedConfig{TypedConfig: hcmAny}
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
if err := fs.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a ClientConn and run the test case.
cc, err := grpc.NewClient("xds:///"+serviceName, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithResolvers(xdsResolver))
if err != nil {
t.Fatalf("failed to dial local test server: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
count := 0
for _, want := range tc.want {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if want.repeat == 0 {
t.Fatalf("invalid repeat count")
}
for n := 0; n < want.repeat; n++ {
intnCalls = nil
newTimerCalls = nil
ctx = metadata.NewOutgoingContext(ctx, want.md)
_, err := client.EmptyCall(ctx, &testpb.Empty{}, grpc.WaitForReady(true))
t.Logf("%v: RPC %d: err: %v, intnCalls: %v, newTimerCalls: %v", want.name, count, err, intnCalls, newTimerCalls)
if status.Code(err) != want.code || !reflect.DeepEqual(intnCalls, want.randIn) || !reflect.DeepEqual(newTimerCalls, want.delays) {
t.Fatalf("WANTED code: %v, intnCalls: %v, newTimerCalls: %v", want.code, want.randIn, want.delays)
}
randOut += tc.randOutInc
count++
}
}
})
}
}
func (s) TestFaultInjection_MaxActiveFaults(t *testing.T) {
fs, nodeID, port, bc := clientSetup(t)
// Create an xDS resolver with the above bootstrap configuration.
xdsResolver, err := internal.NewXDSResolverWithConfigForTesting.(func([]byte) (resolver.Builder, error))(bc)
if err != nil {
t.Fatalf("Failed to create xDS resolver for testing: %v", err)
}
resources := e2e.DefaultClientResources(e2e.ResourceParams{
DialTarget: "myservice",
NodeID: nodeID,
Host: "localhost",
Port: port,
SecLevel: e2e.SecurityLevelNone,
})
hcm := new(v3httppb.HttpConnectionManager)
lis := resources.Listeners[0].GetApiListener().GetApiListener()
if err = lis.UnmarshalTo(hcm); err != nil {
t.Fatal(err)
}
defer func() { newTimer = time.NewTimer }()
timers := make(chan *time.Timer, 2)
newTimer = func(time.Duration) *time.Timer {
t := time.NewTimer(24 * time.Hour) // Will reset to fire.
timers <- t
return t
}
hcm.HttpFilters = append([]*v3httppb.HttpFilter{
e2e.HTTPFilter("fault", &fpb.HTTPFault{
MaxActiveFaults: wrapperspb.UInt32(2),
Delay: &cpb.FaultDelay{
Percentage: &tpb.FractionalPercent{Numerator: 100, Denominator: tpb.FractionalPercent_HUNDRED},
FaultDelaySecifier: &cpb.FaultDelay_FixedDelay{FixedDelay: durationpb.New(time.Second)},
},
})},
hcm.HttpFilters...)
hcmAny := testutils.MarshalAny(t, hcm)
resources.Listeners[0].ApiListener.ApiListener = hcmAny
resources.Listeners[0].FilterChains[0].Filters[0].ConfigType = &v3listenerpb.Filter_TypedConfig{TypedConfig: hcmAny}
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
if err := fs.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a ClientConn
cc, err := grpc.NewClient("xds:///myservice", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithResolvers(xdsResolver))
if err != nil {
t.Fatalf("failed to dial local test server: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
streams := make(chan testgrpc.TestService_FullDuplexCallClient, 5) // startStream() is called 5 times
startStream := func() {
str, err := client.FullDuplexCall(ctx)
if err != nil {
t.Error("RPC error:", err)
}
streams <- str
}
endStream := func() {
str := <-streams
str.CloseSend()
if _, err := str.Recv(); err != io.EOF {
t.Error("stream error:", err)
}
}
releaseStream := func() {
timer := <-timers
timer.Reset(0)
}
// Start three streams; two should delay.
go startStream()
go startStream()
go startStream()
// End one of the streams. Ensure the others are blocked on creation.
endStream()
select {
case <-streams:
t.Errorf("unexpected second stream created before delay expires")
case <-time.After(50 * time.Millisecond):
// Wait a short time to ensure no other streams were started yet.
}
// Start one more; it should not be blocked.
go startStream()
endStream()
// Expire one stream's delay; it should be created.
releaseStream()
endStream()
// Another new stream should delay.
go startStream()
select {
case <-streams:
t.Errorf("unexpected second stream created before delay expires")
case <-time.After(50 * time.Millisecond):
// Wait a short time to ensure no other streams were started yet.
}
// Expire both pending timers and end the two streams.
releaseStream()
releaseStream()
endStream()
endStream()
}

View File

@@ -0,0 +1,333 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package gcpauthn implements the GCP Authentication HTTP filter.
package gcpauthn
import (
"container/list"
"context"
"fmt"
"strings"
"sync"
"golang.org/x/sync/singleflight"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/google"
"google.golang.org/grpc/internal/resolver"
"google.golang.org/grpc/internal/xds/balancer/clustermanager"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
v3gcpauthnpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/gcp_authn/v3"
)
const defaultCacheSize = 10 // default capacity of the LRU credentials cache.
func init() {
httpfilter.Register(builder{})
}
type builder struct{}
type config struct {
httpfilter.FilterConfig
cacheSize uint64
}
func (builder) TypeURLs() []string {
return []string{"type.googleapis.com/envoy.extensions.filters.http.gcp_authn.v3.GcpAuthnFilterConfig"}
}
func (builder) ParseFilterConfig(cfg proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
m, ok := cfg.(*anypb.Any)
if !ok {
return nil, fmt.Errorf("gcpauthn: invalid filter config type %T", cfg)
}
msg := &v3gcpauthnpb.GcpAuthnFilterConfig{}
if err := m.UnmarshalTo(msg); err != nil {
return nil, fmt.Errorf("gcpauthn: failed to unmarshal filter config: %v", err)
}
cacheSize := uint64(defaultCacheSize)
if cacheSizeConfig := msg.GetCacheConfig().GetCacheSize(); cacheSizeConfig != nil {
if cacheSize = cacheSizeConfig.GetValue(); cacheSize == 0 {
return nil, fmt.Errorf("gcpauthn: cache_config.cache_size must be greater than zero")
}
}
return config{cacheSize: cacheSize}, nil
}
// ParseFilterConfigOverride parses the provided override configuration.
//
// Note that we don't support overrides for this filter configuration,
// but still validate it as part of the normal resource validation.
func (b builder) ParseFilterConfigOverride(cfg proto.Message, opts httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
return b.ParseFilterConfig(cfg, opts)
}
func (builder) IsTerminal() bool {
return false
}
func (builder) BuildClientFilter(opts httpfilter.ClientFilterOptions) httpfilter.ClientFilter {
ctx, cancel := context.WithCancel(context.Background())
return &clientFilter{
ctx: ctx,
cancel: cancel,
filterName: opts.FilterName,
}
}
var _ httpfilter.ClientFilterBuilder = builder{}
// clientFilter implements the httpfilter.ClientFilter interface.
type clientFilter struct {
// ctx is initialized using context.Background() and is scoped to the
// lifetime of the filter. It is used as the parent context for fetching
// service account identity credentials, ensuring that token fetch requests
// are not terminated by individual RPC's context.
ctx context.Context
// cancel is the cancellation function for ctx, called when the filter
// is closed.
cancel context.CancelFunc
// filterName is the name of the HTTP filter instance in the xDS
// configuration. It is used as the key in the cluster metadata to look up
// the audience value for the cluster to which the RPC is destined.
filterName string
// cache is the LRU cache of PerRPCCredentials instances, keyed by audience
// and is initialized or resized when BuildClientInterceptor is called.
cache *lruCache
}
// BuildClientInterceptor builds a client interceptor for the GCP
// Authentication filter.
func (cf *clientFilter) BuildClientInterceptor(cfg, _ httpfilter.FilterConfig) (httpfilter.ClientInterceptor, error) {
c, ok := cfg.(config)
if !ok {
return nil, fmt.Errorf("gcpauthn: invalid filter config type %T", cfg)
}
if cf.cache == nil {
cf.cache = newLRUCache(c.cacheSize)
} else {
cf.cache.resizeCache(c.cacheSize)
}
return &interceptor{
ctx: cf.ctx,
filterName: cf.filterName,
cache: cf.cache,
}, nil
}
// Close closes the client filter.
func (cf *clientFilter) Close() {
cf.cancel()
}
type interceptor struct {
ctx context.Context
filterName string
cache *lruCache
}
func (i *interceptor) NewStream(ctx context.Context, _ resolver.RPCInfo, newStream func(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStream, error), opts ...grpc.CallOption) (grpc.ClientStream, error) {
clusterName := clustermanager.PickedCluster(ctx)
// The picked cluster name in the context is formatted by the xDS
// resolver with a prefix to distinguish between standard CDS clusters and
// cluster_specifier_plugins:
// - cluster_specifier_plugin: Since the target cluster is dynamically
// resolved later at the load balancing layer, the final cluster name is
// not yet known at this stage, hence we bypass this filter.
// - cluster: Standard CDS routing where the destination cluster is
// known at routing time. We strip the prefix to get the raw cluster name,
// which is used to look up the cluster configuration in the CDS response.
if strings.HasPrefix(clusterName, "cluster_specifier_plugin:") {
return newStream(ctx, opts...)
}
clusterName = strings.TrimPrefix(clusterName, "cluster:")
cfg := xdsresource.XDSConfigFromContext(ctx)
if cfg == nil {
return nil, status.Errorf(codes.Unavailable, "gcpauthn: xDS config not found in context")
}
clusterResult, ok := cfg.Clusters[clusterName]
if !ok {
return nil, status.Errorf(codes.Unavailable, "gcpauthn: cluster %q not found in xDS config", clusterName)
}
if clusterResult.Err != nil {
return nil, status.Errorf(codes.Unavailable, "gcpauthn: cluster config for %q is invalid or missing: %v", clusterName, clusterResult.Err)
}
val, ok := clusterResult.Config.Cluster.Metadata[i.filterName]
if !ok {
return newStream(ctx, opts...)
}
audienceMetadata, ok := val.(xdsresource.AudienceMetadataValue)
if !ok {
return nil, status.Errorf(codes.Unavailable, "gcpauthn: cluster metadata for key %q is not of type AudienceMetadataValue, got %T", i.filterName, val)
}
creds, err := i.cache.getOrCreate(i.ctx, audienceMetadata.Audience)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "gcpauthn: failed to create credentials: %v", err)
}
// We pass the credentials via a PerRPCCredentials call option rather than
// directly attaching the token here. Since this filter runs before load
// balancer has selected a connection, it cannot check if the connection is
// secure. Passing it as a call option defers token retrieval and injection
// to the credentials package, which can verify transport security after
// the connection has been established.
opts = append(opts, grpc.PerRPCCredentials(creds))
return newStream(ctx, opts...)
}
func (i *interceptor) Close() {}
// cacheEntry represents a cached PerRPCCredentials instance and its position
// in the LRU list.
type cacheEntry struct {
creds credentials.PerRPCCredentials
elem *list.Element
}
// lruCache is a thread-safe LRU cache that stores PerRPCCredentials instances
// by their target audience string.
type lruCache struct {
// The following fields are protected by mu.
mu sync.Mutex
// cacheSize is the maximum capacity of the lruList.
cacheSize uint64
// lruList is a doubly linked list tracking access order. The front of the
// list holds the most recently used entry, and the back holds the least
// recently used entry. Elements in the list store values of type
// string (representing the target audience).
lruList *list.List
// cache maps audience keys to their corresponding cacheEntry pointers,
// allowing O(1) lookups and updates in the LRU list.
cache map[string]*cacheEntry
// sf is used to deduplicate credential creation for the same audience.
sf singleflight.Group
}
// newLRUCache instantiates a new lruCache with the specified capacity.
func newLRUCache(size uint64) *lruCache {
return &lruCache{
cacheSize: size,
lruList: list.New(),
cache: make(map[string]*cacheEntry),
}
}
// resizeCache dynamically updates the capacity of the LRU cache,
// immediately evicting Least Recently Used entries if the new size is
// smaller than the current cache size.
func (c *lruCache) resizeCache(newCacheSize uint64) {
c.mu.Lock()
defer c.mu.Unlock()
if c.cacheSize == newCacheSize {
return
}
c.cacheSize = newCacheSize
for uint64(len(c.cache)) > c.cacheSize {
c.removeOldestLocked()
}
}
// getOrCreate retrieves or constructs the PerRPCCredentials for a specified
// audience. If the audience is not found in the cache, it creates new
// credentials using the configured creator, adds it to the cache, and evicts
// the least recently used entry if the cache size is at capacity.
func (c *lruCache) getOrCreate(ctx context.Context, audience string) (credentials.PerRPCCredentials, error) {
if creds, ok := c.getExisting(audience); ok {
return creds, nil
}
val, err, _ := c.sf.Do(audience, func() (any, error) {
// Double-check cache inside singleflight callback in case another call
// completed the initial check before the current call updates the cache.
if creds, ok := c.getExisting(audience); ok {
return creds, nil
}
creds, err := google.NewServiceAccountIdentityCredentials(ctx, audience)
if err != nil {
return nil, err
}
c.mu.Lock()
defer c.mu.Unlock()
if uint64(len(c.cache)) >= c.cacheSize {
c.removeOldestLocked()
}
c.cache[audience] = &cacheEntry{
creds: creds,
elem: c.lruList.PushFront(audience),
}
return creds, nil
})
if err != nil {
return nil, err
}
return val.(credentials.PerRPCCredentials), nil
}
// getExisting retrieves the PerRPCCredentials for a specified audience if it
// already exists in the cache and updates the access order in the LRU list.
//
// The boolean return value indicates if an entry was found in the cache.
func (c *lruCache) getExisting(audience string) (credentials.PerRPCCredentials, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if entry, ok := c.cache[audience]; ok {
c.lruList.MoveToFront(entry.elem)
return entry.creds, ok
}
return nil, false
}
// removeOldestLocked evicts the least recently used entry from the cache.
// It must be called with mu locked.
func (c *lruCache) removeOldestLocked() {
oldest := c.lruList.Back()
delete(c.cache, oldest.Value.(string))
c.lruList.Remove(oldest)
}

View File

@@ -0,0 +1,952 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package gcpauthn_test
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/credentials/xds"
"google.golang.org/grpc/internal/envconfig"
"google.golang.org/grpc/internal/grpctest"
"google.golang.org/grpc/internal/stubserver"
"google.golang.org/grpc/internal/testutils"
"google.golang.org/grpc/internal/testutils/xds/e2e"
"google.golang.org/grpc/internal/testutils/xds/e2e/setup"
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource"
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource/version"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/wrapperspb"
v3clusterpb "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3endpointpb "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3routepb "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
v3gcpauthnpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/gcp_authn/v3"
v3httppb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
testgrpc "google.golang.org/grpc/interop/grpc_testing"
testpb "google.golang.org/grpc/interop/grpc_testing"
)
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
const (
gceMetadataHostEnvVar = "GCE_METADATA_HOST"
defaultTestTimeout = 10 * time.Second
defaultTestShortTimeout = 100 * time.Millisecond
)
func setupGCPAuthnTest(t *testing.T) {
testutils.SetEnvConfig(t, &envconfig.GCPAuthenticationFilterEnabled, true)
cleanup, err := xdsresource.RegisterMetadataConverterForTesting(version.V3AudienceURL)
if err != nil {
t.Fatalf("RegisterMetadataConverterForTesting(%q) failed: %v", version.V3AudienceURL, err)
}
t.Cleanup(cleanup)
}
// Test verifies the basic end-to-end flow. It ensures that the gcp_authn
// filter successfully fetches a token from the stub metadata server and
// attaches it to the outgoing gRPC request metadata. Than verify that
// subsequent RPC calls with same audience reuse the same token.
func (s) TestGCPAuthnFilter_SuccessCase(t *testing.T) {
setupGCPAuthnTest(t)
const tokenValue = "token"
// Starts a local HTTP server and sets GCE_METADATA_HOST to spoof the
// GCE metadata server and redirect token fetch requests to it.
var requestCount atomic.Int32
metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount.Add(1)
w.Write([]byte(tokenValue))
}))
defer metadataServer.Close()
t.Setenv(gceMetadataHostEnvVar, strings.TrimPrefix(metadataServer.URL, "http://"))
// Setup management server and resolver.
mgmtServer, nodeID, _, resolverBuilder := setup.ManagementServerAndResolver(t)
// Start a test backend.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
backend := &stubserver.StubServer{
EmptyCallF: func(ctx context.Context, _ *testpb.Empty) (*testpb.Empty, error) {
md, _ := metadata.FromIncomingContext(ctx)
// Verify that the token is attached to the RPC
if !strings.Contains(md.Get("authorization")[0], "Bearer "+tokenValue) {
return nil, fmt.Errorf("Expected token not found in metadata: %v", md)
}
return &testpb.Empty{}, nil
},
}
stubserver.StartTestService(t, backend, grpc.Creds(testutils.CreateServerTLSCredentials(t, tls.NoClientCert)))
defer backend.Stop()
const (
testServiceName = "service-name"
clusterName = "cluster_A"
endpointName = "endpoint_A"
routeConfigName = "route-service-name"
filterName = "com.google.grpc.gcp_authn"
)
// Configure resources on the management server.
listener := e2e.DefaultClientListener(testServiceName, routeConfigName)
hcm := new(v3httppb.HttpConnectionManager)
lis := listener.GetApiListener().GetApiListener()
if err := lis.UnmarshalTo(hcm); err != nil {
t.Fatal(err)
}
hcm.HttpFilters = append([]*v3httppb.HttpFilter{
{
Name: filterName,
ConfigType: &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 10},
},
}),
},
},
}, hcm.HttpFilters...)
listener.ApiListener.ApiListener = testutils.MarshalAny(t, hcm)
cluster := e2e.DefaultCluster(clusterName, endpointName, e2e.SecurityLevelTLS)
cluster.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{
Url: "https://example.com",
}),
},
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listener},
Routes: []*v3routepb.RouteConfiguration{e2e.DefaultRouteConfig(routeConfigName, testServiceName, clusterName)},
Clusters: []*v3clusterpb.Cluster{cluster},
Endpoints: []*v3endpointpb.ClusterLoadAssignment{e2e.DefaultEndpoint(endpointName, "localhost", []uint32{testutils.ParsePort(t, backend.Address)})},
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a gRPC client using the xDS resolver.
clientCreds, err := xds.NewClientCredentials(xds.ClientOptions{FallbackCreds: insecure.NewCredentials()})
if err != nil {
t.Fatalf("Failed to create client credentials: %v", err)
}
cc, err := grpc.NewClient("xds:///"+testServiceName, grpc.WithTransportCredentials(clientCreds), grpc.WithResolvers(resolverBuilder))
if err != nil {
t.Fatalf("Failed to create a gRPC client: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
if _, err = client.EmptyCall(ctx, &testpb.Empty{}); err != nil {
t.Fatalf("EmptyCall() failed: %v", err)
}
// Verify request count is 1.
if count := requestCount.Load(); count != 1 {
t.Fatalf("Unexpected request to metadata server, got %d want 1", count)
}
const numCalls = 3
for i := 0; i < numCalls; i++ {
if _, err = client.EmptyCall(ctx, &testpb.Empty{}); err != nil {
t.Fatalf("EmptyCall() failed: %v", err)
}
}
// Verify request count is 1. This ensures that the token fetched for
// the first RPC was reused by the subsequent RPCs.
if count := requestCount.Load(); count != 1 {
t.Fatalf("Unexpected request to metadata server, got %d want 1", count)
}
}
// Test verifies that the filter correctly handles concurrent RPC calls
// (thundering herd problem) by fetching the token only once and reusing
// it across all subsequent RPCs with the same audience.
func (s) TestGCPAuthnFilter_ConcurrentRequests(t *testing.T) {
setupGCPAuthnTest(t)
const tokenValue = "token"
// Starts a local HTTP server and sets GCE_METADATA_HOST to spoof the
// GCE metadata server and redirect token fetch requests to it.
var requestCount atomic.Int32
metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount.Add(1)
w.Write([]byte(tokenValue))
}))
defer metadataServer.Close()
t.Setenv(gceMetadataHostEnvVar, strings.TrimPrefix(metadataServer.URL, "http://"))
// Setup management server and resolver.
mgmtServer, nodeID, _, resolverBuilder := setup.ManagementServerAndResolver(t)
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start a test backend.
backend := &stubserver.StubServer{
EmptyCallF: func(ctx context.Context, _ *testpb.Empty) (*testpb.Empty, error) {
md, _ := metadata.FromIncomingContext(ctx)
// Verify that the token is attached to the RPC
if !strings.Contains(md.Get("authorization")[0], "Bearer "+tokenValue) {
return nil, fmt.Errorf("Expected token not found in metadata: %v", md)
}
return &testpb.Empty{}, nil
},
}
stubserver.StartTestService(t, backend, grpc.Creds(testutils.CreateServerTLSCredentials(t, tls.NoClientCert)))
defer backend.Stop()
const (
testServiceName = "service-name"
clusterName = "cluster_A"
endpointName = "endpoint_A"
routeConfigName = "route-service-name"
filterName = "com.google.grpc.gcp_authn"
)
// Configure resources on the management server.
listener := e2e.DefaultClientListener(testServiceName, routeConfigName)
hcm := new(v3httppb.HttpConnectionManager)
lis := listener.GetApiListener().GetApiListener()
if err := lis.UnmarshalTo(hcm); err != nil {
t.Fatal(err)
}
hcm.HttpFilters = append([]*v3httppb.HttpFilter{
{
Name: filterName,
ConfigType: &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 10},
},
}),
},
},
}, hcm.HttpFilters...)
listener.ApiListener.ApiListener = testutils.MarshalAny(t, hcm)
cluster := e2e.DefaultCluster(clusterName, endpointName, e2e.SecurityLevelTLS)
cluster.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{
Url: "https://example.com",
}),
},
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listener},
Routes: []*v3routepb.RouteConfiguration{e2e.DefaultRouteConfig(routeConfigName, testServiceName, clusterName)},
Clusters: []*v3clusterpb.Cluster{cluster},
Endpoints: []*v3endpointpb.ClusterLoadAssignment{e2e.DefaultEndpoint(endpointName, "localhost", []uint32{testutils.ParsePort(t, backend.Address)})},
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a gRPC client using the xDS resolver.
clientCreds, err := xds.NewClientCredentials(xds.ClientOptions{FallbackCreds: insecure.NewCredentials()})
if err != nil {
t.Fatalf("Failed to create client credentials: %v", err)
}
cc, err := grpc.NewClient("xds:///"+testServiceName, grpc.WithTransportCredentials(clientCreds), grpc.WithResolvers(resolverBuilder))
if err != nil {
t.Fatalf("Failed to create a gRPC client: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
const numCalls = 100
errs := make([]error, numCalls)
var wg sync.WaitGroup
for i := 0; i < numCalls; i++ {
wg.Go(func() {
_, errs[i] = client.EmptyCall(ctx, &testpb.Empty{})
})
}
wg.Wait()
for _, err := range errs {
if err != nil {
t.Errorf("EmptyCall() failed: %v", err)
}
}
// Verify request count is 1. This ensures that only one token fetch call was
// made to the metadata server and all 100 concurrent RPC calls reused the
// same token.
if count := requestCount.Load(); count != 1 {
t.Fatalf("Unexpected request to metadata server, got %d want 1", count)
}
}
// Test verifies that the filter refuses to attach credentials when the target
// cluster is configured without transport security.
func (s) TestGCPAuthnFilter_InsecureTransport(t *testing.T) {
setupGCPAuthnTest(t)
// Starts a local HTTP server and sets GCE_METADATA_HOST to spoof the
// GCE metadata server and redirect token fetch requests to it.
metadataServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer metadataServer.Close()
t.Setenv(gceMetadataHostEnvVar, strings.TrimPrefix(metadataServer.URL, "http://"))
// Setup management server and resolver.
mgmtServer, nodeID, _, resolverBuilder := setup.ManagementServerAndResolver(t)
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start a test backend.
backend := &stubserver.StubServer{}
stubserver.StartTestService(t, backend)
defer backend.Stop()
const (
testServiceName = "service-name"
clusterName = "cluster_A"
endpointName = "endpoint_A"
filterName = "com.google.grpc.gcp_authn"
routeConfigName = "route-service-name"
)
// Configure resources on the management server.
listener := e2e.DefaultClientListener(testServiceName, routeConfigName)
hcm := new(v3httppb.HttpConnectionManager)
lis := listener.GetApiListener().GetApiListener()
if err := lis.UnmarshalTo(hcm); err != nil {
t.Fatal(err)
}
hcm.HttpFilters = append([]*v3httppb.HttpFilter{
{
Name: filterName,
ConfigType: &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 10},
},
}),
},
},
}, hcm.HttpFilters...)
listener.ApiListener.ApiListener = testutils.MarshalAny(t, hcm)
// SecurityLevelNone causes SecurityCfg to be nil
cluster := e2e.DefaultCluster(clusterName, endpointName, e2e.SecurityLevelNone)
cluster.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{
Url: "https://example.com",
}),
},
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listener},
Routes: []*v3routepb.RouteConfiguration{e2e.DefaultRouteConfig(routeConfigName, testServiceName, clusterName)},
Clusters: []*v3clusterpb.Cluster{cluster},
Endpoints: []*v3endpointpb.ClusterLoadAssignment{e2e.DefaultEndpoint(endpointName, "localhost", []uint32{testutils.ParsePort(t, backend.Address)})},
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a gRPC client using the xDS resolver.
cc, err := grpc.NewClient("xds:///"+testServiceName, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithResolvers(resolverBuilder))
if err != nil {
t.Fatalf("Failed to create a gRPC client: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
const wantErr = "cannot send secure credentials on an insecure connection"
if _, err = client.EmptyCall(ctx, &testpb.Empty{}); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Fatalf("EmptyCall() failed with error: %v; want error containing: %v", err, wantErr)
}
}
// Test verifies that the credential cache of the gcp_authn filter correctly
// handles cache resizing across xDS updates. It performs RPC calls to 3
// different clusters to fill a cache of size 3. Then it updates the xDS
// configuration to reduce the cache size to 1. Finally, it verifies that only
// the most recently used element survives in the cache, and making calls to
// the other two results in cache misses (forcing a new token fetch).
func (s) TestGCPAuthnFilter_CacheSharingConfigUpdate(t *testing.T) {
setupGCPAuthnTest(t)
// Starts a local HTTP server and sets GCE_METADATA_HOST to spoof the
// GCE metadata server and redirect token fetch requests to it.
var requestCount atomic.Int32
metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount.Add(1)
w.Write([]byte("token"))
}))
defer metadataServer.Close()
t.Setenv(gceMetadataHostEnvVar, strings.TrimPrefix(metadataServer.URL, "http://"))
// Setup management server and resolver.
mgmtServer, nodeID, _, resolverBuilder := setup.ManagementServerAndResolver(t)
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start a test backend.
backend := &stubserver.StubServer{
EmptyCallF: func(context.Context, *testpb.Empty) (*testpb.Empty, error) {
return &testpb.Empty{}, nil
},
}
stubserver.StartTestService(t, backend, grpc.Creds(testutils.CreateServerTLSCredentials(t, tls.NoClientCert)))
defer backend.Stop()
const (
testServiceName = "service-name"
filterName = "com.google.grpc.gcp_authn"
)
// Configure resources on the management server.
listener := &v3listenerpb.Listener{
Name: testServiceName,
ApiListener: &v3listenerpb.ApiListener{
ApiListener: testutils.MarshalAny(t, &v3httppb.HttpConnectionManager{
RouteSpecifier: &v3httppb.HttpConnectionManager_RouteConfig{
RouteConfig: &v3routepb.RouteConfiguration{
VirtualHosts: []*v3routepb.VirtualHost{{
Domains: []string{testServiceName},
Routes: []*v3routepb.Route{
{
Match: &v3routepb.RouteMatch{
PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: ""},
Headers: []*v3routepb.HeaderMatcher{{
Name: "match-id",
HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "a"},
}},
},
Action: &v3routepb.Route_Route{Route: &v3routepb.RouteAction{
ClusterSpecifier: &v3routepb.RouteAction_Cluster{Cluster: "A"},
}},
},
{
Match: &v3routepb.RouteMatch{
PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: ""},
Headers: []*v3routepb.HeaderMatcher{{
Name: "match-id",
HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "b"},
}},
},
Action: &v3routepb.Route_Route{Route: &v3routepb.RouteAction{
ClusterSpecifier: &v3routepb.RouteAction_Cluster{Cluster: "B"},
}},
},
{
Match: &v3routepb.RouteMatch{
PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: ""},
Headers: []*v3routepb.HeaderMatcher{{
Name: "match-id",
HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "c"},
}},
},
Action: &v3routepb.Route_Route{Route: &v3routepb.RouteAction{
ClusterSpecifier: &v3routepb.RouteAction_Cluster{Cluster: "C"},
}},
},
},
}},
},
},
HttpFilters: []*v3httppb.HttpFilter{
{
Name: filterName,
ConfigType: &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 10},
},
}),
},
},
e2e.RouterHTTPFilter,
},
}),
},
}
clusterA := e2e.DefaultCluster("A", "endpoint_A", e2e.SecurityLevelTLS)
clusterA.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{Url: "url-1"}),
},
}
clusterB := e2e.DefaultCluster("B", "endpoint_B", e2e.SecurityLevelTLS)
clusterB.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{Url: "url-2"}),
},
}
clusterC := e2e.DefaultCluster("C", "endpoint_C", e2e.SecurityLevelTLS)
clusterC.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{Url: "url-3"}),
},
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listener},
Clusters: []*v3clusterpb.Cluster{clusterA, clusterB, clusterC},
Endpoints: []*v3endpointpb.ClusterLoadAssignment{
e2e.DefaultEndpoint("endpoint_A", "localhost", []uint32{testutils.ParsePort(t, backend.Address)}),
e2e.DefaultEndpoint("endpoint_B", "localhost", []uint32{testutils.ParsePort(t, backend.Address)}),
e2e.DefaultEndpoint("endpoint_C", "localhost", []uint32{testutils.ParsePort(t, backend.Address)}),
},
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a gRPC client using the xDS resolver.
clientCreds, err := xds.NewClientCredentials(xds.ClientOptions{FallbackCreds: insecure.NewCredentials()})
if err != nil {
t.Fatalf("Failed to create client credentials: %v", err)
}
cc, err := grpc.NewClient("xds:///"+testServiceName, grpc.WithTransportCredentials(clientCreds), grpc.WithResolvers(resolverBuilder))
if err != nil {
t.Fatalf("Failed to create a gRPC client: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
makeCall := func(ctx context.Context, rpcName string) {
if _, err := client.EmptyCall(ctx, &testpb.Empty{}); err != nil {
t.Fatalf("EmptyCall(%s) failed: %v", rpcName, err)
}
}
// Make initial calls to all 3 clusters to populate the cache with 3 entries.
// The access order is A, then B, then C (leaving C as the most
// recently used).
ctxA := metadata.AppendToOutgoingContext(ctx, "match-id", "a")
ctxB := metadata.AppendToOutgoingContext(ctx, "match-id", "b")
ctxC := metadata.AppendToOutgoingContext(ctx, "match-id", "c")
makeCall(ctxA, "A")
makeCall(ctxB, "B")
makeCall(ctxC, "C")
// Verify request count is 3!
if count := requestCount.Load(); count != 3 {
t.Fatalf("Unexpected requests to metadata server, got %d want 3", count)
}
// Make another call to cluster C and verify that it did not increase the
// request count.
makeCall(ctxC, "C")
if count := requestCount.Load(); count != 3 {
t.Fatalf("Unexpected requests to metadata server, got %d want 3", count)
}
// Update cache config to size 1!
hcm := &v3httppb.HttpConnectionManager{}
if err := anypb.UnmarshalTo(listener.ApiListener.ApiListener, hcm, proto.UnmarshalOptions{}); err != nil {
t.Fatalf("Failed to unmarshal HCM: %v", err)
}
hcm.HttpFilters[0].ConfigType = &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 1},
},
}),
}
// Add a new route matching "match-id": "d" pointing to Cluster D as canary.
routeD := &v3routepb.Route{
Match: &v3routepb.RouteMatch{
PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: ""},
Headers: []*v3routepb.HeaderMatcher{{
Name: "match-id",
HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "d"},
}},
},
Action: &v3routepb.Route_Route{Route: &v3routepb.RouteAction{
ClusterSpecifier: &v3routepb.RouteAction_Cluster{Cluster: "D"},
}},
}
hcm.GetRouteConfig().GetVirtualHosts()[0].Routes = append(hcm.GetRouteConfig().GetVirtualHosts()[0].Routes, routeD)
resources.Listeners[0].ApiListener.ApiListener = testutils.MarshalAny(t, hcm)
resources.Clusters = append(resources.Clusters, e2e.DefaultCluster("D", "endpoint_D", e2e.SecurityLevelTLS))
resources.Endpoints = append(resources.Endpoints, e2e.DefaultEndpoint("endpoint_D", "localhost", []uint32{testutils.ParsePort(t, backend.Address)}))
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Active Probing: Wait for the new route "d" to become live. Because
// it points to Cluster D with no metadata, a successful call here
// won't trigger a new fetch. This guarantees that the resource update
// is propagated.
ctxD := metadata.AppendToOutgoingContext(ctx, "match-id", "d")
for ; ctx.Err() == nil; <-time.After(10 * time.Millisecond) {
if _, err = client.EmptyCall(ctxD, &testpb.Empty{}); err == nil {
break
}
}
if ctx.Err() != nil {
t.Fatal("Timeout waiting for xDS update to propagate")
}
// Make calls for all 3 clusters again. Resizing the cache from 3 to 1
// in the previous step leaves only the token for Cluster C in the
// cache (as it was the most recently used). Therefore, calls to
// clusters A and B will miss the cache and trigger new metadata
// server fetches, while C will hit.
makeCall(ctxC, "C on second pass")
if count := requestCount.Load(); count != 3 {
t.Fatalf("Unexpected requests to metadata server, got %d want 3", count)
}
makeCall(ctxB, "B on second pass")
makeCall(ctxA, "A on second pass")
// Verify that the total requests to the metadata server is exactly 5
// This proves that token for cluster C was successfully cached in the
// second pass, while A and B were not.
if count := requestCount.Load(); count != 5 {
t.Fatalf("Unexpected requests to metadata server, got %d want 5", count)
}
}
// Test verifies the scenario where two RPCs are made: the first with a
// short context which triggers a token fetch but times out while waiting
// for the metadata server to respond and the second RPC with a long context,
// which blocks waiting for the first token fetch to finish, and then succeeds
// by using the token fetched by the first.
func (s) TestGCPAuthnFilter_ConcurrentRPCWithShortAndLongContext(t *testing.T) {
setupGCPAuthnTest(t)
var once sync.Once
const tokenValue = "token"
requestStarted := make(chan struct{})
proceedCh := make(chan struct{})
// Starts a local HTTP server and sets GCE_METADATA_HOST to spoof the
// GCE metadata server and redirect token fetch requests to it.
var requestCount atomic.Int32
metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Close requestStarted channel to signal that a request has started.
once.Do(func() {
close(requestStarted)
})
requestCount.Add(1)
// Block until signaled to proceed.
<-proceedCh
w.Write([]byte(tokenValue))
}))
defer metadataServer.Close()
t.Setenv(gceMetadataHostEnvVar, strings.TrimPrefix(metadataServer.URL, "http://"))
// Setup management server and resolver.
mgmtServer, nodeID, _, resolverBuilder := setup.ManagementServerAndResolver(t)
// Start a test backend.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
backend := &stubserver.StubServer{
EmptyCallF: func(ctx context.Context, _ *testpb.Empty) (*testpb.Empty, error) {
md, _ := metadata.FromIncomingContext(ctx)
// Verify that the token is attached to the RPC
if !strings.Contains(md.Get("authorization")[0], "Bearer "+tokenValue) {
return nil, fmt.Errorf("Expected token not found in metadata: %v", md)
}
return &testpb.Empty{}, nil
},
}
stubserver.StartTestService(t, backend, grpc.Creds(testutils.CreateServerTLSCredentials(t, tls.NoClientCert)))
defer backend.Stop()
const (
testServiceName = "service-name"
clusterName = "cluster_A"
endpointName = "endpoint_A"
routeConfigName = "route-service-name"
filterName = "com.google.grpc.gcp_authn"
)
// Configure resources on the management server.
listener := e2e.DefaultClientListener(testServiceName, routeConfigName)
hcm := new(v3httppb.HttpConnectionManager)
lis := listener.GetApiListener().GetApiListener()
if err := lis.UnmarshalTo(hcm); err != nil {
t.Fatal(err)
}
hcm.HttpFilters = append([]*v3httppb.HttpFilter{
{
Name: filterName,
ConfigType: &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 10},
},
}),
},
},
}, hcm.HttpFilters...)
listener.ApiListener.ApiListener = testutils.MarshalAny(t, hcm)
cluster := e2e.DefaultCluster(clusterName, endpointName, e2e.SecurityLevelTLS)
cluster.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{
Url: "https://example.com",
}),
},
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listener},
Routes: []*v3routepb.RouteConfiguration{e2e.DefaultRouteConfig(routeConfigName, testServiceName, clusterName)},
Clusters: []*v3clusterpb.Cluster{cluster},
Endpoints: []*v3endpointpb.ClusterLoadAssignment{e2e.DefaultEndpoint(endpointName, "localhost", []uint32{testutils.ParsePort(t, backend.Address)})},
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a gRPC client using the xDS resolver.
clientCreds, err := xds.NewClientCredentials(xds.ClientOptions{FallbackCreds: insecure.NewCredentials()})
if err != nil {
t.Fatalf("Failed to create client credentials: %v", err)
}
cc, err := grpc.NewClient("xds:///"+testServiceName, grpc.WithTransportCredentials(clientCreds), grpc.WithResolvers(resolverBuilder))
if err != nil {
t.Fatalf("Failed to create a gRPC client: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
cc.Connect()
testutils.AwaitState(ctx, t, cc, connectivity.Ready)
// Create a short context for the first RPC call.
shortCtx, shortBufCancel := context.WithTimeout(context.Background(), defaultTestShortTimeout)
defer shortBufCancel()
errCh1, errCh2 := make(chan error, 1), make(chan error, 1)
// First RPC call with short context (triggers token fetch request
// to metadata server).
go func() {
_, err := client.EmptyCall(shortCtx, &testpb.Empty{})
errCh1 <- err
}()
// Wait for the first RPC to hit the local metadata server.
select {
case <-requestStarted:
case <-ctx.Done():
t.Fatal("Timeout while waiting for token fetch to start in metadata server")
}
// Second RPC call with long context.
go func() {
_, err := client.EmptyCall(ctx, &testpb.Empty{})
errCh2 <- err
}()
// The first RPC should fail with context deadline exceeded because its
// context timeout is short and the metadata server is blocked.
select {
case err := <-errCh1:
if err == nil || status.Code(err) != codes.Internal {
t.Fatalf("First RPC failed unexpectedly with error code %v, want %v", status.Code(err), codes.Internal)
}
case <-ctx.Done():
t.Fatal("Timeout waiting for first RPC to fail")
}
// Now allow the metadata server to complete and return token.
close(proceedCh)
// Verify that the second RPC successfully completes.
select {
case err := <-errCh2:
if err != nil {
t.Fatalf("Second RPC failed unexpectedly with: %v, want success", err)
}
case <-ctx.Done():
t.Fatal("Timeout while waiting for second RPC to succeed")
}
// Verify request count is 1. This ensures that the token fetched for
// the first RPC was reused for the second RPC.
if count := requestCount.Load(); count != 1 {
t.Fatalf("Unexpected request to metadata server, got %d want 1", count)
}
}
// Test verifies that when an RPC is made with user-specified call options
// (e.g., grpc.Header), the filter successfully appends its PerRPCCredentials
// option without overriding, corrupting or interfering with the existing ones.
func (s) TestGCPAuthnFilter_PreservesUserCallOptions(t *testing.T) {
setupGCPAuthnTest(t)
const tokenValue = "token"
// Starts a local HTTP server and sets GCE_METADATA_HOST to spoof the
// GCE metadata server and redirect token fetch requests to it.
metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte(tokenValue))
}))
defer metadataServer.Close()
t.Setenv(gceMetadataHostEnvVar, strings.TrimPrefix(metadataServer.URL, "http://"))
// Setup management server and resolver.
mgmtServer, nodeID, _, resolverBuilder := setup.ManagementServerAndResolver(t)
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start a test backend.
backend := &stubserver.StubServer{
EmptyCallF: func(ctx context.Context, _ *testpb.Empty) (*testpb.Empty, error) {
// Set a custom header back to the client to verify the original
// CallOption works.
if err := grpc.SetHeader(ctx, metadata.Pairs("custom-response-header", "custom-val")); err != nil {
t.Errorf("Failed to set response header: %v", err)
}
md, _ := metadata.FromIncomingContext(ctx)
// Verify that the token is attached to the RPC
if !strings.Contains(md.Get("authorization")[0], "Bearer "+tokenValue) {
return nil, fmt.Errorf("Expected token not found in metadata: %v", md)
}
return &testpb.Empty{}, nil
},
}
stubserver.StartTestService(t, backend, grpc.Creds(testutils.CreateServerTLSCredentials(t, tls.NoClientCert)))
defer backend.Stop()
const (
testServiceName = "service-name"
clusterName = "cluster_A"
endpointName = "endpoint_A"
routeConfigName = "route-service-name"
filterName = "com.google.grpc.gcp_authn"
)
// Configure resources on the management server.
listener := e2e.DefaultClientListener(testServiceName, routeConfigName)
hcm := new(v3httppb.HttpConnectionManager)
lis := listener.GetApiListener().GetApiListener()
if err := lis.UnmarshalTo(hcm); err != nil {
t.Fatal(err)
}
hcm.HttpFilters = append([]*v3httppb.HttpFilter{
{
Name: filterName,
ConfigType: &v3httppb.HttpFilter_TypedConfig{
TypedConfig: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 10},
},
}),
},
},
}, hcm.HttpFilters...)
listener.ApiListener.ApiListener = testutils.MarshalAny(t, hcm)
cluster := e2e.DefaultCluster(clusterName, endpointName, e2e.SecurityLevelTLS)
cluster.Metadata = &v3corepb.Metadata{
TypedFilterMetadata: map[string]*anypb.Any{
filterName: testutils.MarshalAny(t, &v3gcpauthnpb.Audience{
Url: "https://example.com",
}),
},
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listener},
Routes: []*v3routepb.RouteConfiguration{e2e.DefaultRouteConfig(routeConfigName, testServiceName, clusterName)},
Clusters: []*v3clusterpb.Cluster{cluster},
Endpoints: []*v3endpointpb.ClusterLoadAssignment{e2e.DefaultEndpoint(endpointName, "localhost", []uint32{testutils.ParsePort(t, backend.Address)})},
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create a gRPC client using the xDS resolver.
clientCreds, err := xds.NewClientCredentials(xds.ClientOptions{FallbackCreds: insecure.NewCredentials()})
if err != nil {
t.Fatalf("Failed to create client credentials: %v", err)
}
cc, err := grpc.NewClient("xds:///"+testServiceName, grpc.WithTransportCredentials(clientCreds), grpc.WithResolvers(resolverBuilder))
if err != nil {
t.Fatalf("Failed to create a gRPC client: %v", err)
}
defer cc.Close()
client := testgrpc.NewTestServiceClient(cc)
var header metadata.MD
// RPC call with a custom CallOption.
if _, err = client.EmptyCall(ctx, &testpb.Empty{}, grpc.Header(&header)); err != nil {
t.Fatalf("EmptyCall() failed: %v", err)
}
// Verify that the response header option was executed successfully,
// proving the first entry was preserved.
val := header.Get("custom-response-header")
if len(val) == 0 || val[0] != "custom-val" {
t.Fatalf("Unexpected response header, got: %v want custom-val", val)
}
}

View File

@@ -0,0 +1,295 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package gcpauthn
import (
"context"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"google.golang.org/grpc"
"google.golang.org/grpc/internal/grpctest"
"google.golang.org/grpc/internal/resolver"
"google.golang.org/grpc/internal/testutils"
"google.golang.org/grpc/internal/xds/balancer/clustermanager"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/wrapperspb"
v3gcpauthnpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/gcp_authn/v3"
)
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
// Test verifies that the filter successfully decodes the raw protobuf
// configuration into the internal config struct.
func (s) TestParseFilterConfig(t *testing.T) {
testCases := []struct {
name string
config proto.Message
wantCfg config
wantErr string
}{
{
name: "valid_config_with_cache_size",
config: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 50},
},
}),
wantCfg: config{cacheSize: 50},
},
{
name: "config_with_no_cache_size",
config: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{},
}),
wantCfg: config{cacheSize: defaultCacheSize},
},
{
name: "config_with_zero_cache_size",
config: testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 0},
},
}),
wantErr: "cache_config.cache_size must be greater than zero",
},
{
name: "invalid_message_type",
config: &v3gcpauthnpb.GcpAuthnFilterConfig{},
wantErr: "gcpauthn: invalid filter config type",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gotCfg, err := builder{}.ParseFilterConfig(tc.config, httpfilter.ParseOptions{})
if err != nil {
if tc.wantErr == "" || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("ParseFilterConfig() failed with error = %v; want error %q", err, tc.wantErr)
}
return
}
if diff := cmp.Diff(gotCfg, tc.wantCfg, cmp.AllowUnexported(config{})); diff != "" {
t.Fatalf("ParseFilterConfig() returned unexpected config (-got +want):\n%s", diff)
}
})
}
}
// Test verifies that the filter correctly creates the stream interceptor
// from the parsed config, and correctly handles cache initialization.
func (s) TestBuildClientInterceptor(t *testing.T) {
type dummyConfig struct {
httpfilter.FilterConfig
}
tests := []struct {
name string
cfg httpfilter.FilterConfig
wantErr string
wantCacheSize uint64
}{
{
name: "invalid_config_type",
cfg: dummyConfig{},
wantErr: "invalid filter config type",
},
{
name: "cache_initialization",
cfg: config{cacheSize: 5},
wantCacheSize: 5,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
const filterName = "com.google.grpc.gcp_authn"
clientFilter := builder{}.BuildClientFilter(httpfilter.ClientFilterOptions{FilterName: filterName})
defer clientFilter.Close()
i, err := clientFilter.BuildClientInterceptor(tc.cfg, nil)
if err != nil {
if tc.wantErr == "" || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("BuildClientInterceptor() failed with error = %v, want error %q", err, tc.wantErr)
}
return
}
interceptor := i.(*interceptor)
if interceptor.filterName != filterName {
t.Fatalf("BuildClientInterceptor() returned interceptor with filtername = %q, want %q", interceptor.filterName, filterName)
}
if interceptor.cache == nil {
t.Fatalf("BuildClientInterceptor() returned interceptor with nil cache")
}
if interceptor.cache.cacheSize != tc.wantCacheSize {
t.Fatalf("BuildClientInterceptor() returned interceptor with cacheSize = %d, want %d", interceptor.cache.cacheSize, tc.wantCacheSize)
}
})
}
}
// Test verifies that consecutive calls to BuildClientInterceptor with
// different cache sizes dynamically resize the shared credentials cache.
func (s) TestBuildClientInterceptor_CacheResizing(t *testing.T) {
tests := []struct {
name string
cfgs []httpfilter.FilterConfig
wantCacheSizes []uint64
}{
{
name: "cache_resize_smaller",
cfgs: []httpfilter.FilterConfig{config{cacheSize: 5}, config{cacheSize: 3}},
wantCacheSizes: []uint64{5, 3},
},
{
name: "cache_resize_larger",
cfgs: []httpfilter.FilterConfig{config{cacheSize: 5}, config{cacheSize: 15}},
wantCacheSizes: []uint64{5, 15},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
const filterName = "com.google.grpc.gcp_authn"
clientFilter := builder{}.BuildClientFilter(httpfilter.ClientFilterOptions{FilterName: filterName})
defer clientFilter.Close()
for i, cfg := range tc.cfgs {
intptr, err := clientFilter.BuildClientInterceptor(cfg, nil)
if err != nil {
t.Fatalf("BuildClientInterceptor(%v) returned unexpected error: %v", cfg, err)
}
interceptor := intptr.(*interceptor)
if interceptor.filterName != filterName {
t.Fatalf("BuildClientInterceptor() returned interceptor with filtername = %q, want %q", interceptor.filterName, filterName)
}
if interceptor.cache == nil {
t.Fatalf("BuildClientInterceptor() returned interceptor with nil cache")
}
if interceptor.cache.cacheSize != tc.wantCacheSizes[i] {
t.Fatalf("BuildClientInterceptor() returned interceptor with cacheSize = %d, want %d", interceptor.cache.cacheSize, tc.wantCacheSizes[i])
}
}
})
}
}
// Test verifies that interceptor.NewStream returns the expected errors under
// various invalid states, such as a missing XDS config, a cluster not defined
// in the CDS, or a malformed metadata type.
func (s) TestInterceptor_NewStream_Errors(t *testing.T) {
builder := httpfilter.Get("type.googleapis.com/envoy.extensions.filters.http.gcp_authn.v3.GcpAuthnFilterConfig")
cfg := testutils.MarshalAny(t, &v3gcpauthnpb.GcpAuthnFilterConfig{
CacheConfig: &v3gcpauthnpb.TokenCacheConfig{
CacheSize: &wrapperspb.UInt64Value{Value: 10},
},
})
filterConfig, err := builder.ParseFilterConfig(cfg, httpfilter.ParseOptions{})
if err != nil {
t.Fatalf("Failed to parse filter config: %v", err)
}
clientFilterBuilder, ok := builder.(httpfilter.ClientFilterBuilder)
if !ok {
t.Fatalf("Filter Builder does not implement ClientFilterBuilder")
}
clientFilter := clientFilterBuilder.BuildClientFilter(httpfilter.ClientFilterOptions{FilterName: "com.google.grpc.gcp_authn"}).(*clientFilter)
interceptor, err := clientFilter.BuildClientInterceptor(filterConfig, nil)
if err != nil {
t.Fatalf("Failed to build client interceptor: %v", err)
}
validConfig := &xdsresource.XDSConfig{
Clusters: map[string]*xdsresource.ClusterResult{
"cluster1": {
Config: xdsresource.ClusterConfig{
Cluster: &xdsresource.ClusterUpdate{
Metadata: map[string]any{
"com.google.grpc.gcp_authn": xdsresource.AudienceMetadataValue{Audience: "https://example.com"},
},
},
},
},
"cluster_wrong_type": {
Config: xdsresource.ClusterConfig{
Cluster: &xdsresource.ClusterUpdate{
Metadata: map[string]any{
"com.google.grpc.gcp_authn": "not-audience-metadata",
},
},
},
},
"cluster_empty_audience": {
Config: xdsresource.ClusterConfig{
Cluster: &xdsresource.ClusterUpdate{
Metadata: map[string]any{
"com.google.grpc.gcp_authn": xdsresource.AudienceMetadataValue{Audience: ""},
},
},
},
},
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tests := []struct {
name string
ctx context.Context
wantErr string
}{
{
name: "missing_xds_config",
ctx: clustermanager.SetPickedCluster(ctx, "cluster1"),
wantErr: "xDS config not found in context",
},
{
name: "cluster_not_found_in_CDS",
ctx: clustermanager.SetPickedCluster(xdsresource.NewContextWithXDSConfig(ctx, validConfig), "cluster_not_found"),
wantErr: "not found in xDS config",
},
{
name: "wrong_metadata_type",
ctx: clustermanager.SetPickedCluster(xdsresource.NewContextWithXDSConfig(ctx, validConfig), "cluster_wrong_type"),
wantErr: "not of type AudienceMetadataValue",
},
{
name: "empty_audience",
ctx: clustermanager.SetPickedCluster(xdsresource.NewContextWithXDSConfig(ctx, validConfig), "cluster_empty_audience"),
wantErr: "failed to create credentials",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
newStream := func(context.Context, ...grpc.CallOption) (grpc.ClientStream, error) {
return nil, nil
}
if _, err := interceptor.NewStream(tc.ctx, resolver.RPCInfo{}, newStream); err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("NewStream() returned unexpected results, got %q , want error containing %q", err, tc.wantErr)
}
})
}
}

View File

@@ -0,0 +1,204 @@
/*
*
* Copyright 2021 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package httpfilter contains interface definitions for xDS-based HTTP filters
// and a registry for filter builders.
package httpfilter
import (
"context"
"google.golang.org/grpc"
estats "google.golang.org/grpc/experimental/stats"
iresolver "google.golang.org/grpc/internal/resolver"
"google.golang.org/grpc/internal/xds/bootstrap"
"google.golang.org/protobuf/proto"
)
// FilterConfig represents an opaque data structure holding configuration for a
// filter. Embed this interface to implement it.
type FilterConfig interface {
isFilterConfig()
}
// ParseOptions contains additional information passed to the HTTP filter's
// config parsing methods.
type ParseOptions struct {
// BootstrapConfig contains the complete bootstrap configuration used by the
// xDS client that received the resource being parsed.
BootstrapConfig *bootstrap.Config
// ServerConfig contains the configuration of the xDS management server that
// sent the resource being parsed.
ServerConfig *bootstrap.ServerConfig
}
// DisabledFilterConfig represents a disabled filter override. It implements the
// FilterConfig interface and can be returned by ParseFilterConfigOverride to
// indicate that the filter should be disabled. It is not used as a config for
// any filter, and is only used as a marker in the override configuration. For
// more information, see
// envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_filters#route-based-filter-chain
type DisabledFilterConfig struct{}
func (DisabledFilterConfig) isFilterConfig() {}
// Builder defines the parsing functionality of an HTTP filter. A Builder may
// optionally implement either ClientFilterBuilder or ServerFilterBuilder or
// both, indicating it is capable of working on the client side or server side
// or both, respectively.
type Builder interface {
// TypeURLs are the proto message types supported by this filter. A filter
// will be registered by each of its supported message types.
TypeURLs() []string
// ParseFilterConfig parses the provided configuration proto.Message from
// the LDS configuration of this filter. This may be an anypb.Any, a
// udpa.type.v1.TypedStruct, or an xds.type.v3.TypedStruct for filters that
// do not accept a custom type. The resulting FilterConfig will later be
// passed to Build.
ParseFilterConfig(proto.Message, ParseOptions) (FilterConfig, error)
// ParseFilterConfigOverride parses the provided override configuration
// proto.Message from the RDS override configuration of this filter. This
// may be an anypb.Any, a udpa.type.v1.TypedStruct, or an
// xds.type.v3.TypedStruct for filters that do not accept a custom type.
// The resulting FilterConfig will later be passed to Build.
ParseFilterConfigOverride(proto.Message, ParseOptions) (FilterConfig, error)
// IsTerminal returns whether this Filter is terminal or not (i.e. it must
// be last filter in the filter chain).
IsTerminal() bool
}
// ClientInterceptor is an interceptor for gRPC client streams.
type ClientInterceptor interface {
// NewStream creates a ClientStream for an RPC.
//
// Implementations may delegate stream creation to the provided newStream
// function, passing the provided CallOption slice along with any new
// CallOption instances they wish to add. To intercept or override stream
// behavior, implementations may wrap the ClientStream returned by the
// delegate.
//
// Note: RPCInfo.Context is currently unused and will be nil.
NewStream(ctx context.Context, ri iresolver.RPCInfo, newStream func(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStream, error), opts ...grpc.CallOption) (grpc.ClientStream, error)
// Close closes the interceptor. No new RPCs will be dispatched to this
// interceptor, but ongoing calls to NewStream are allowed to complete.
Close()
}
// ClientFilterOptions contains options for building a client filter.
type ClientFilterOptions struct {
FilterName string // FilterName is the filter name from the xDS configuration.
MetricsRecorder estats.MetricsRecorder // MetricsRecorder is the metrics recorder to capture metrics for the filter.
Target string // Target is the target string of the channel.
}
// ClientFilterBuilder is an optional interface that a Builder can implement to
// indicate its capability to build client-side filters.
type ClientFilterBuilder interface {
// BuildClientFilter constructs a ClientFilter.
BuildClientFilter(opts ClientFilterOptions) ClientFilter
}
// ClientFilter represents the actual filter implementation on the client side.
// Implementations are free to maintain internal state when required, and share
// it across interceptors. Filter instances are retained by the resolver as long
// as they are present in the LDS configuration.
type ClientFilter interface {
// BuildClientInterceptor uses the given FilterConfigs to produce an HTTP
// filter interceptor for clients. config will always be non-nil, but
// override may be nil if no override config exists for the filter.
//
// It is valid for this method to return a nil Interceptor and a nil error.
// In this case, the RPC will not be intercepted by this filter.
BuildClientInterceptor(config, override FilterConfig) (ClientInterceptor, error)
// Close is called when the filter is no longer needed.
Close()
}
// ServerInterceptor is an interceptor for incoming RPC's on gRPC server side.
type ServerInterceptor interface {
// InterceptRPC intercepts an incoming RPC on the server side.
//
// On success, implementations must return either the original ServerStream
// or a wrapped ServerStream, with a nil error.
//
// Returning a non-nil error will terminate the RPC with that error.
// Implementations are expected to return an error created using the status
// package; otherwise, the RPC will fail with an UNKNOWN status code.
//
// Implementations should never return (nil, nil).
InterceptRPC(ss grpc.ServerStream) (grpc.ServerStream, error)
// Close closes the interceptor. No new RPCs will be dispatched to this
// interceptor, but ongoing calls to InterceptRPC are allowed to complete.
Close()
}
// ServerFilterBuilder is an optional interface that a Builder can implement to
// indicate its capability to build server-side filters.
type ServerFilterBuilder interface {
// BuildServerFilter constructs a ServerFilter.
BuildServerFilter() ServerFilter
}
// ServerFilter represents the actual filter implementation on the server side.
// Implementations are free to maintain internal state when required, and share
// it across interceptors. Filter instances are retained by the server as long
// as they are present in any of the filter chains in the LDS configuration.
type ServerFilter interface {
// BuildServerInterceptor uses the given FilterConfigs to produce
// an HTTP filter interceptor for servers. config will always be non-nil,
// but override may be nil if no override config exists for the filter.
//
// It is valid for this method to return a nil Interceptor and a nil error.
// In this case, the RPC will not be intercepted by this filter.
BuildServerInterceptor(config, override FilterConfig) (ServerInterceptor, error)
// Close is called when the filter is no longer needed.
Close()
}
var (
// registeredBuilders is a map from scheme to filter builder.
registeredBuilders = make(map[string]Builder)
)
// Register registers the HTTP Filter Builder with the registry. b.TypeURLs()
// will be used as the types for this filter.
//
// NOTE: this function must only be called during initialization time (i.e. in
// an init() function), and is not thread-safe. If multiple filters are
// registered with the same type URL, the one registered last will take effect.
func Register(b Builder) {
for _, u := range b.TypeURLs() {
registeredBuilders[u] = b
}
}
// UnregisterForTesting unregisters the HTTP Filter Builder for testing purposes.
func UnregisterForTesting(typeURL string) {
delete(registeredBuilders, typeURL)
}
// Get returns the HTTP Filter Builder registered with typeURL.
//
// If no filter builder is register with typeURL, nil will be returned.
func Get(typeURL string) Builder {
return registeredBuilders[typeURL]
}

View File

@@ -0,0 +1,279 @@
/*
*
* Copyright 2021 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package rbac implements the Envoy RBAC HTTP filter.
package rbac
import (
"errors"
"fmt"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/grpc/internal/xds/rbac"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
v3rbacpb "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3"
v3routepb "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
rpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3"
)
func init() {
httpfilter.Register(builder{})
}
type builder struct {
}
type config struct {
httpfilter.FilterConfig
chainEngine *rbac.ChainEngine
}
func (builder) TypeURLs() []string {
return []string{
"type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC",
"type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute",
}
}
// Parsing is the same for the base config and the override config.
func parseConfig(rbacCfg *rpb.RBAC) (httpfilter.FilterConfig, error) {
// All the validation logic described in A41.
for _, policy := range rbacCfg.GetRules().GetPolicies() {
// "Policy.condition and Policy.checked_condition must cause a
// validation failure if present." - A41
if policy.Condition != nil {
return nil, errors.New("rbac: Policy.condition is present")
}
if policy.CheckedCondition != nil {
return nil, errors.New("rbac: policy.CheckedCondition is present")
}
// "It is also a validation failure if Permission or Principal has a
// header matcher for a grpc- prefixed header name or :scheme." - A41.
//
// "Envoy aliases :authority and Host in its header map implementation,
// so they should be treated equivalent for the RBAC matchers; there must
// be no behavior change depending on which of the two header names is
// used in the RBAC policy." - A41. Any header matcher with value "host"
// is rewritten to ":authority", as that is what grpc-go shifts both
// headers to in the transport layer.
//
// Both rules apply to header matchers nested inside and/or/not rules, so
// the whole permission and principal trees are walked.
for _, principal := range policy.GetPrincipals() {
if err := normalizePrincipalHeaders(principal); err != nil {
return nil, err
}
}
for _, permission := range policy.GetPermissions() {
if err := normalizePermissionHeaders(permission); err != nil {
return nil, err
}
}
}
// Two cases where this HTTP Filter is a no op:
// "If absent, no enforcing RBAC policy will be applied" - RBAC
// Documentation for Rules field.
// "At this time, if the RBAC.action is Action.LOG then the policy will be
// completely ignored, as if RBAC was not configured." - A41
if rbacCfg.Rules == nil || rbacCfg.GetRules().GetAction() == v3rbacpb.RBAC_LOG {
return config{}, nil
}
// TODO(gregorycooke) - change the call chain to here so we have the filter
// name to input here instead of an empty string. It will come from here:
// https://github.com/grpc/grpc-go/blob/eff0942e95d93112921414aee758e619ec86f26f/xds/internal/xdsclient/xdsresource/unmarshal_lds.go#L199
ce, err := rbac.NewChainEngine([]*v3rbacpb.RBAC{rbacCfg.GetRules()}, "")
if err != nil {
// "At this time, if the RBAC.action is Action.LOG then the policy will be
// completely ignored, as if RBAC was not configured." - A41
if rbacCfg.GetRules().GetAction() != v3rbacpb.RBAC_LOG {
return nil, fmt.Errorf("rbac: error constructing matching engine: %v", err)
}
}
return config{chainEngine: ce}, nil
}
// normalizePermissionHeaders applies the A41 header-name rules to every header
// matcher reachable from permission, including those nested inside and/or/not
// rules.
func normalizePermissionHeaders(permission *v3rbacpb.Permission) error {
switch p := permission.GetRule().(type) {
case *v3rbacpb.Permission_Header:
return normalizeHeaderMatcher(p.Header)
case *v3rbacpb.Permission_AndRules:
for _, rule := range p.AndRules.GetRules() {
if err := normalizePermissionHeaders(rule); err != nil {
return err
}
}
case *v3rbacpb.Permission_OrRules:
for _, rule := range p.OrRules.GetRules() {
if err := normalizePermissionHeaders(rule); err != nil {
return err
}
}
case *v3rbacpb.Permission_NotRule:
return normalizePermissionHeaders(p.NotRule)
}
return nil
}
// normalizePrincipalHeaders applies the A41 header-name rules to every header
// matcher reachable from principal, including those nested inside and/or/not
// ids.
func normalizePrincipalHeaders(principal *v3rbacpb.Principal) error {
switch p := principal.GetIdentifier().(type) {
case *v3rbacpb.Principal_Header:
return normalizeHeaderMatcher(p.Header)
case *v3rbacpb.Principal_AndIds:
for _, id := range p.AndIds.GetIds() {
if err := normalizePrincipalHeaders(id); err != nil {
return err
}
}
case *v3rbacpb.Principal_OrIds:
for _, id := range p.OrIds.GetIds() {
if err := normalizePrincipalHeaders(id); err != nil {
return err
}
}
case *v3rbacpb.Principal_NotId:
return normalizePrincipalHeaders(p.NotId)
}
return nil
}
// normalizeHeaderMatcher lowercases the name of a header matcher, rejects the
// names that A41 forbids (:scheme or a grpc- prefixed name) and rewrites a
// "host" matcher to ":authority".
func normalizeHeaderMatcher(header *v3routepb.HeaderMatcher) error {
// The keys of the metadata the matchers run against are always lowercase,
// so a name that contains an uppercase character matches no header at all
// and the rule using it never fires. Lowercase the name, as Envoy and
// grpc-java do, both to make it match and to keep the checks below from
// being evaded by the case of the name.
name := header.GetName()
lowerName := strings.ToLower(name)
if lowerName != name {
header.Name = lowerName
}
if lowerName == ":scheme" {
return fmt.Errorf("rbac: header matcher for %q is %q", name, ":scheme")
}
if strings.HasPrefix(lowerName, "grpc-") {
return fmt.Errorf("rbac: header matcher for %q starts with %q", name, "grpc-")
}
if lowerName == "host" {
header.Name = ":authority"
}
return nil
}
func (builder) ParseFilterConfig(cfg proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
if cfg == nil {
return nil, fmt.Errorf("rbac: nil configuration message provided")
}
m, ok := cfg.(*anypb.Any)
if !ok {
return nil, fmt.Errorf("rbac: error parsing config %v: unknown type %T", cfg, cfg)
}
msg := new(rpb.RBAC)
if err := m.UnmarshalTo(msg); err != nil {
return nil, fmt.Errorf("rbac: error parsing config %v: %v", cfg, err)
}
return parseConfig(msg)
}
func (builder) ParseFilterConfigOverride(override proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
if override == nil {
return nil, fmt.Errorf("rbac: nil configuration message provided")
}
m, ok := override.(*anypb.Any)
if !ok {
return nil, fmt.Errorf("rbac: error parsing override config %v: unknown type %T", override, override)
}
msg := new(rpb.RBACPerRoute)
if err := m.UnmarshalTo(msg); err != nil {
return nil, fmt.Errorf("rbac: error parsing override config %v: %v", override, err)
}
return parseConfig(msg.Rbac)
}
func (builder) IsTerminal() bool {
return false
}
func (builder) BuildServerFilter() httpfilter.ServerFilter {
return serverFilter{}
}
var _ httpfilter.ServerFilterBuilder = builder{}
type serverFilter struct{}
func (serverFilter) Close() {}
func (serverFilter) BuildServerInterceptor(cfg httpfilter.FilterConfig, override httpfilter.FilterConfig) (httpfilter.ServerInterceptor, error) {
if cfg == nil {
return nil, fmt.Errorf("rbac: nil config provided")
}
c, ok := cfg.(config)
if !ok {
return nil, fmt.Errorf("rbac: incorrect config type provided (%T): %v", cfg, cfg)
}
if override != nil {
// override completely replaces the listener configuration; but we
// still validate the listener config type.
c, ok = override.(config)
if !ok {
return nil, fmt.Errorf("rbac: incorrect override config type provided (%T): %v", override, override)
}
}
// RBAC HTTP Filter is a no op from one of these two cases:
// "If absent, no enforcing RBAC policy will be applied" - RBAC
// Documentation for Rules field.
// "At this time, if the RBAC.action is Action.LOG then the policy will be
// completely ignored, as if RBAC was not configured." - A41
if c.chainEngine == nil {
return nil, nil
}
return &interceptor{chainEngine: c.chainEngine}, nil
}
type interceptor struct {
chainEngine *rbac.ChainEngine
}
func (i *interceptor) InterceptRPC(ss grpc.ServerStream) (grpc.ServerStream, error) {
if err := i.chainEngine.IsAuthorized(ss.Context()); err != nil {
return nil, err
}
return ss, nil
}
func (i *interceptor) Close() {}

View File

@@ -0,0 +1,185 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package rbac
import (
"testing"
"google.golang.org/grpc/internal/grpctest"
v3rbacpb "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3"
v3routepb "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
rpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3"
)
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
func headerMatcher(name string) *v3routepb.HeaderMatcher {
return &v3routepb.HeaderMatcher{
Name: name,
HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PresentMatch{PresentMatch: true},
}
}
func headerPermission(name string) *v3rbacpb.Permission {
return &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Header{Header: headerMatcher(name)}}
}
func headerPrincipal(name string) *v3rbacpb.Principal {
return &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Header{Header: headerMatcher(name)}}
}
func rbacConfig(perm *v3rbacpb.Permission, principal *v3rbacpb.Principal) *rpb.RBAC {
return &rpb.RBAC{Rules: &v3rbacpb.RBAC{
Action: v3rbacpb.RBAC_ALLOW,
Policies: map[string]*v3rbacpb.Policy{
"test-policy": {
Permissions: []*v3rbacpb.Permission{perm},
Principals: []*v3rbacpb.Principal{principal},
},
},
}}
}
// TestNestedHeaderMatcherValidation checks that a header matcher for :scheme or
// a grpc- prefixed name is rejected even when it is nested inside an and/or/not
// rule, as A41 requires.
func (s) TestNestedHeaderMatcherValidation(t *testing.T) {
anyPermission := &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Any{Any: true}}
anyPrincipal := &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Any{Any: true}}
tests := []struct {
name string
cfg *rpb.RBAC
}{
{
name: "permission and_rules :scheme",
cfg: rbacConfig(&v3rbacpb.Permission{Rule: &v3rbacpb.Permission_AndRules{AndRules: &v3rbacpb.Permission_Set{
Rules: []*v3rbacpb.Permission{headerPermission(":scheme")},
}}}, anyPrincipal),
},
{
name: "permission not_rule grpc- prefix",
cfg: rbacConfig(&v3rbacpb.Permission{Rule: &v3rbacpb.Permission_NotRule{NotRule: headerPermission("grpc-timeout")}}, anyPrincipal),
},
{
name: "principal or_ids :scheme",
cfg: rbacConfig(anyPermission, &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_OrIds{OrIds: &v3rbacpb.Principal_Set{
Ids: []*v3rbacpb.Principal{headerPrincipal(":scheme")},
}}}),
},
{
name: "principal not_id grpc- prefix",
cfg: rbacConfig(anyPermission, &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_NotId{NotId: headerPrincipal("grpc-encoding")}}),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := parseConfig(test.cfg); err == nil {
t.Fatalf("parseConfig() succeeded; want error rejecting a nested :scheme/grpc- header matcher")
}
})
}
}
// TestNestedHostHeaderAliasing checks that a "host" header matcher nested inside
// an and/or/not rule is rewritten to ":authority", so it behaves the same as a
// top-level host matcher (A41 host/:authority equivalence).
func (s) TestNestedHostHeaderAliasing(t *testing.T) {
perm := &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_NotRule{NotRule: headerPermission("host")}}
principal := &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_AndIds{AndIds: &v3rbacpb.Principal_Set{
Ids: []*v3rbacpb.Principal{headerPrincipal("host")},
}}}
if _, err := parseConfig(rbacConfig(perm, principal)); err != nil {
t.Fatalf("parseConfig() failed: %v", err)
}
gotPerm := perm.GetRule().(*v3rbacpb.Permission_NotRule).NotRule.GetRule().(*v3rbacpb.Permission_Header).Header.GetName()
if gotPerm != ":authority" {
t.Errorf("Nested permission host matcher name = %q, want %q", gotPerm, ":authority")
}
gotPrincipal := principal.GetIdentifier().(*v3rbacpb.Principal_AndIds).AndIds.GetIds()[0].GetIdentifier().(*v3rbacpb.Principal_Header).Header.GetName()
if gotPrincipal != ":authority" {
t.Errorf("Nested principal host matcher name = %q, want %q", gotPrincipal, ":authority")
}
}
// TestHeaderMatcherNameIsLowercased checks that a header matcher name is
// lowercased before it reaches the matching engine. gRPC lowercases every
// metadata key, so a name that carries an uppercase character matches no
// header at all, and a DENY policy using one fails open.
func (s) TestHeaderMatcherNameIsLowercased(t *testing.T) {
tests := []struct {
name string
wantName string
}{
{name: "X-Role", wantName: "x-role"},
{name: "USER-AGENT", wantName: "user-agent"},
// The host to :authority alias must not depend on the case either.
{name: "Host", wantName: ":authority"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// The permission matcher is nested and the principal matcher is at
// the top level, so both the recursive walk and the top level are
// covered.
permHeader, principalHeader := headerMatcher(test.name), headerMatcher(test.name)
perm := &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_NotRule{
NotRule: &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Header{Header: permHeader}},
}}
principal := &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Header{Header: principalHeader}}
if _, err := parseConfig(rbacConfig(perm, principal)); err != nil {
t.Fatalf("parseConfig() failed: %v", err)
}
if got := permHeader.GetName(); got != test.wantName {
t.Errorf("Permission header matcher name = %q, want %q", got, test.wantName)
}
if got := principalHeader.GetName(); got != test.wantName {
t.Errorf("Principal header matcher name = %q, want %q", got, test.wantName)
}
})
}
}
// TestHeaderMatcherValidationIsCaseInsensitive checks that the A41 rejection of
// :scheme and grpc- prefixed header matchers cannot be evaded by spelling the
// name in another case.
func (s) TestHeaderMatcherValidationIsCaseInsensitive(t *testing.T) {
anyPermission := &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_Any{Any: true}}
anyPrincipal := &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_Any{Any: true}}
for _, name := range []string{":Scheme", "Grpc-Timeout", "GRPC-STATUS"} {
t.Run(name, func(t *testing.T) {
if _, err := parseConfig(rbacConfig(headerPermission(name), anyPrincipal)); err == nil {
t.Errorf("parseConfig() succeeded for permission header matcher %q; want error rejecting a :scheme/grpc- header matcher", name)
}
if _, err := parseConfig(rbacConfig(anyPermission, headerPrincipal(name))); err == nil {
t.Errorf("parseConfig() succeeded for principal header matcher %q; want error rejecting a :scheme/grpc- header matcher", name)
}
})
}
}

View File

@@ -0,0 +1,122 @@
/*
*
* Copyright 2021 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package router implements the Envoy Router HTTP filter.
package router
import (
"fmt"
"google.golang.org/grpc/internal/xds/httpfilter"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
pb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3"
)
// TypeURL is the message type for the Router configuration.
const TypeURL = "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router"
func init() {
httpfilter.Register(builder{})
}
// IsRouterFilter returns true iff b is a Router filter builder.
func IsRouterFilter(b httpfilter.Builder) bool {
_, ok := b.(builder)
return ok
}
type builder struct {
}
func (builder) TypeURLs() []string { return []string{TypeURL} }
func (builder) ParseFilterConfig(cfg proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
// The gRPC router filter does not currently use any fields from the
// config. Verify type only.
if cfg == nil {
return nil, fmt.Errorf("router: nil configuration message provided")
}
m, ok := cfg.(*anypb.Any)
if !ok {
return nil, fmt.Errorf("router: error parsing config %v: unknown type %T", cfg, cfg)
}
msg := new(pb.Router)
if err := m.UnmarshalTo(msg); err != nil {
return nil, fmt.Errorf("router: error parsing config %v: %v", cfg, err)
}
return config{}, nil
}
func (builder) ParseFilterConfigOverride(override proto.Message, _ httpfilter.ParseOptions) (httpfilter.FilterConfig, error) {
if override != nil {
return nil, fmt.Errorf("router: unexpected config override specified: %v", override)
}
return config{}, nil
}
func (builder) IsTerminal() bool {
return true
}
func (builder) BuildClientFilter(httpfilter.ClientFilterOptions) httpfilter.ClientFilter {
return filter{}
}
func (builder) BuildServerFilter() httpfilter.ServerFilter {
return filter{}
}
var _ httpfilter.ClientFilterBuilder = builder{}
var _ httpfilter.ServerFilterBuilder = builder{}
type filter struct{}
func (filter) Close() {}
func (filter) BuildClientInterceptor(cfg, override httpfilter.FilterConfig) (httpfilter.ClientInterceptor, error) {
if _, ok := cfg.(config); !ok {
return nil, fmt.Errorf("router: incorrect config type provided (%T): %v", cfg, cfg)
}
if override != nil {
return nil, fmt.Errorf("router: unexpected override configuration specified: %v", override)
}
// The gRPC router is implemented within the xds resolver's config
// selector, not as a separate plugin. So we return a nil HTTPFilter,
// which will not be invoked.
return nil, nil
}
func (filter) BuildServerInterceptor(cfg, override httpfilter.FilterConfig) (httpfilter.ServerInterceptor, error) {
if _, ok := cfg.(config); !ok {
return nil, fmt.Errorf("router: incorrect config type provided (%T): %v", cfg, cfg)
}
if override != nil {
return nil, fmt.Errorf("router: unexpected override configuration specified: %v", override)
}
// The gRPC router is currently unimplemented on the server side. So we
// return a nil HTTPFilter, which will not be invoked.
return nil, nil
}
// The gRPC router filter does not currently support any configuration. Verify
// type only.
type config struct {
httpfilter.FilterConfig
}