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
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:
79
internal/xds/httpfilter/ext_authz/config.go
Normal file
79
internal/xds/httpfilter/ext_authz/config.go
Normal 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
|
||||
}
|
||||
184
internal/xds/httpfilter/ext_authz/ext_authz.go
Normal file
184
internal/xds/httpfilter/ext_authz/ext_authz.go
Normal 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
|
||||
}
|
||||
439
internal/xds/httpfilter/ext_authz/ext_authz_test.go
Normal file
439
internal/xds/httpfilter/ext_authz/ext_authz_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
326
internal/xds/httpfilter/extconfig.go
Normal file
326
internal/xds/httpfilter/extconfig.go
Normal 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
|
||||
}
|
||||
712
internal/xds/httpfilter/extconfig_test.go
Normal file
712
internal/xds/httpfilter/extconfig_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
184
internal/xds/httpfilter/extproc/config.go
Normal file
184
internal/xds/httpfilter/extproc/config.go
Normal 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
|
||||
}
|
||||
980
internal/xds/httpfilter/extproc/config_test.go
Normal file
980
internal/xds/httpfilter/extproc/config_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2240
internal/xds/httpfilter/extproc/ext_proc.go
Normal file
2240
internal/xds/httpfilter/extproc/ext_proc.go
Normal file
File diff suppressed because it is too large
Load Diff
61
internal/xds/httpfilter/extproc/internal/internal.go
Normal file
61
internal/xds/httpfilter/extproc/internal/internal.go
Normal 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
|
||||
)
|
||||
54
internal/xds/httpfilter/extproc/metrics.go
Normal file
54
internal/xds/httpfilter/extproc/metrics.go
Normal 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,
|
||||
})
|
||||
)
|
||||
311
internal/xds/httpfilter/fault/fault.go
Normal file
311
internal/xds/httpfilter/fault/fault.go
Normal 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 }
|
||||
661
internal/xds/httpfilter/fault/fault_test.go
Normal file
661
internal/xds/httpfilter/fault/fault_test.go
Normal 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()
|
||||
}
|
||||
333
internal/xds/httpfilter/gcp_authn/gcp_authn_filter.go
Normal file
333
internal/xds/httpfilter/gcp_authn/gcp_authn_filter.go
Normal 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)
|
||||
}
|
||||
952
internal/xds/httpfilter/gcp_authn/gcp_authn_filter_e2e_test.go
Normal file
952
internal/xds/httpfilter/gcp_authn/gcp_authn_filter_e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
295
internal/xds/httpfilter/gcp_authn/gcp_authn_filter_test.go
Normal file
295
internal/xds/httpfilter/gcp_authn/gcp_authn_filter_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
204
internal/xds/httpfilter/httpfilter.go
Normal file
204
internal/xds/httpfilter/httpfilter.go
Normal 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]
|
||||
}
|
||||
279
internal/xds/httpfilter/rbac/rbac.go
Normal file
279
internal/xds/httpfilter/rbac/rbac.go
Normal 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() {}
|
||||
185
internal/xds/httpfilter/rbac/rbac_test.go
Normal file
185
internal/xds/httpfilter/rbac/rbac_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
122
internal/xds/httpfilter/router/router.go
Normal file
122
internal/xds/httpfilter/router/router.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user