11#include " envoy/extensions/filters/http/proto_api_scrubber/v3/config.pb.h"
2+ #include " envoy/extensions/matching/common_inputs/network/v3/network_inputs.pb.h"
23#include " envoy/grpc/status.h"
4+ #include " envoy/type/matcher/v3/http_inputs.pb.h"
35
6+ #include " source/common/router/string_accessor_impl.h"
47#include " source/extensions/filters/http/proto_api_scrubber/scrubbing_util_lib/field_checker.h"
58
69#include " test/extensions/filters/http/grpc_field_extraction/message_converter/message_converter_test_lib.h"
710#include " test/integration/http_protocol_integration.h"
811#include " test/proto/apikeys.pb.h"
12+ #include " test/test_common/registry.h"
913
1014#include " cel/expr/syntax.pb.h"
1115#include " fmt/format.h"
@@ -29,17 +33,63 @@ std::string apikeysDescriptorPath() {
2933}
3034
3135const std::string kCreateApiKeyMethod = " /apikeys.ApiKeys/CreateApiKey" ;
36+ const std::string kFilterStateLabelKey = " filter_state_label_key" ;
37+ const std::string kFilterStateLabelValue = " LABEL1,LABEL2,LABEL3" ;
38+
39+ // This filter injects data into filter_state.
40+ class MetadataInjectorFilter : public ::Envoy::Http::PassThroughDecoderFilter {
41+ public:
42+ ::Envoy::Http::FilterHeadersStatus decodeHeaders (::Envoy::Http::RequestHeaderMap&,
43+ bool ) override {
44+ const std::string key = kFilterStateLabelKey ;
45+ const std::string value = kFilterStateLabelValue ;
46+ decoder_callbacks_->streamInfo ().filterState ()->setData (
47+ key, std::make_shared<::Envoy::Router::StringAccessorImpl>(value),
48+ ::Envoy::StreamInfo::FilterState::StateType::ReadOnly);
49+ return ::Envoy::Http::FilterHeadersStatus::Continue;
50+ }
51+ };
52+
53+ class MetadataInjectorConfigFactory
54+ : public ::Envoy::Server::Configuration::NamedHttpFilterConfigFactory {
55+ public:
56+ absl::StatusOr<::Envoy::Http::FilterFactoryCb>
57+ createFilterFactoryFromProto (const ::Envoy::Protobuf::Message&, const std::string&,
58+ ::Envoy::Server::Configuration::FactoryContext&) override {
59+ return [](::Envoy::Http::FilterChainFactoryCallbacks& callbacks) {
60+ callbacks.addStreamDecoderFilter (std::make_shared<MetadataInjectorFilter>());
61+ };
62+ }
63+
64+ ::Envoy::ProtobufTypes::MessagePtr createEmptyConfigProto () override {
65+ return std::make_unique<Protobuf::Struct>();
66+ }
67+
68+ std::string name () const override { return " test_injector" ; }
69+ };
70+
71+ // RegisterFactory handles the singleton lifetime automatically and avoids the destruction crash.
72+ static ::Envoy::Registry::RegisterFactory<
73+ MetadataInjectorConfigFactory, ::Envoy::Server::Configuration::NamedHttpFilterConfigFactory>
74+ register_test_injector;
3275
3376class ProtoApiScrubberIntegrationTest : public HttpProtocolIntegrationTest {
3477public:
3578 void SetUp () override { HttpProtocolIntegrationTest::SetUp (); }
3679
3780 void TearDown () override {
81+ if (codec_client_) {
82+ // Close the client FIRST to prevent any "connection reset" callbacks.
83+ codec_client_->close ();
84+ codec_client_.reset ();
85+ }
86+
3887 cleanupUpstreamAndDownstream ();
3988 HttpProtocolIntegrationTest::TearDown ();
4089 }
4190
4291 enum class RestrictionType { Request, Response };
92+ enum class StringMatchType { Exact, Regex };
4393
4494 static xds::type::matcher::v3::Matcher::MatcherList::Predicate
4595 buildCelPredicate (absl::string_view cel_expression) {
@@ -64,6 +114,30 @@ class ProtoApiScrubberIntegrationTest : public HttpProtocolIntegrationTest {
64114 return predicate;
65115 }
66116
117+ static xds::type::matcher::v3::Matcher::MatcherList::Predicate
118+ buildStringMatcherPredicate (const std::string& input_extension_name,
119+ const Protobuf::Message& input_config,
120+ const std::string& match_pattern, StringMatchType match_type) {
121+ xds::type::matcher::v3::Matcher::MatcherList::Predicate predicate;
122+ auto * single = predicate.mutable_single_predicate ();
123+
124+ // Configure the Data Input (The source of the string to be matched)
125+ single->mutable_input ()->set_name (input_extension_name);
126+ single->mutable_input ()->mutable_typed_config ()->PackFrom (input_config);
127+
128+ // Configure the String Matcher (The logic to apply)
129+ auto * string_matcher = single->mutable_value_match ();
130+ if (match_type == StringMatchType::Regex) {
131+ auto * regex = string_matcher->mutable_safe_regex ();
132+ regex->mutable_google_re2 ();
133+ regex->set_regex (match_pattern);
134+ } else {
135+ string_matcher->set_exact (match_pattern);
136+ }
137+
138+ return predicate;
139+ }
140+
67141 // Helper to build the configuration with a generic predicate.
68142 std::string
69143 getFilterConfig (const std::string& descriptor_path, const std::string& method_name = " " ,
@@ -133,15 +207,33 @@ class ProtoApiScrubberIntegrationTest : public HttpProtocolIntegrationTest {
133207 }
134208
135209 template <typename T>
136- IntegrationStreamDecoderPtr sendGrpcRequest (const T& request_msg,
137- const std::string& method_path) {
210+ IntegrationStreamDecoderPtr
211+ sendGrpcRequest (const T& request_msg, const std::string& method_path,
212+ const Http::TestRequestHeaderMapImpl& custom_headers = {}) {
213+ // Close the existing connection in case it exists.
214+ // This can happen if this method is called more than once from a single test.
215+ if (codec_client_ != nullptr ) {
216+ codec_client_->close ();
217+ }
218+
138219 codec_client_ = makeHttpConnection (lookupPort (" http" ));
139220 auto request_buf = Grpc::Common::serializeToGrpcFrame (request_msg);
221+
222+ // Default headers
140223 auto request_headers = Http::TestRequestHeaderMapImpl{{" :method" , " POST" },
141224 {" :path" , method_path},
142225 {" content-type" , " application/grpc" },
143226 {" :authority" , " host" },
144227 {" :scheme" , " http" }};
228+
229+ // Merge custom headers (overwriting defaults if keys match)
230+ custom_headers.iterate(
231+ [&request_headers](const Http::HeaderEntry& header) -> Http::HeaderMap::Iterate {
232+ request_headers.setCopy (Http::LowerCaseString (header.key ().getStringView ()),
233+ header.value ().getStringView ());
234+ return Http::HeaderMap::Iterate::Continue;
235+ });
236+
145237 return codec_client_->makeRequestWithBody (request_headers, request_buf->toString ());
146238 }
147239};
@@ -304,6 +396,137 @@ TEST_P(ProtoApiScrubberIntegrationTest, ScrubNestedField_MatcherFalse) {
304396 ASSERT_TRUE (response->waitForEndStream ());
305397}
306398
399+ // Tests scrubbing of nested fields in the request when a CEL matcher is configured to use request
400+ // headers.
401+ TEST_P (ProtoApiScrubberIntegrationTest, ScrubNestedField_CustomCelMatcher_RequestHeader) {
402+ config_helper_.prependFilter (getFilterConfig (
403+ apikeysDescriptorPath (), kCreateApiKeyMethod , " key.display_name" , RestrictionType::Request,
404+ buildCelPredicate (" request.headers['api-version'] == '2025-v1'" )));
405+ initialize ();
406+
407+ {
408+ // Tests that the field `key.display_name` is removed from the request as the CEL expression
409+ // ("request.headers['api-version'] == '2025_v1'") evaluates to true.
410+ auto original_proto = makeCreateApiKeyRequest (R"pb(
411+ parent: "public"
412+ key { display_name: "should-be-removed" }
413+ )pb" );
414+ auto custom_headers = Http::TestRequestHeaderMapImpl{{" api-version" , " 2025-v1" }};
415+
416+ auto response = sendGrpcRequest (original_proto, kCreateApiKeyMethod , custom_headers);
417+ waitForNextUpstreamRequest ();
418+
419+ apikeys::CreateApiKeyRequest expected = original_proto;
420+ expected.mutable_key ()->clear_display_name ();
421+
422+ Buffer::OwnedImpl data;
423+ data.add (upstream_request_->body ());
424+ checkSerializedData<apikeys::CreateApiKeyRequest>(data, {expected});
425+
426+ upstream_request_->encodeHeaders (Http::TestResponseHeaderMapImpl{{" :status" , " 200" }}, true );
427+ ASSERT_TRUE (response->waitForEndStream ());
428+ }
429+
430+ {
431+ // Tests that the field `key.display_name` is preserved in the request as the CEL expression
432+ // ("request.headers['api-version'] == '2025_v1'") evaluates to false.
433+ auto original_proto = makeCreateApiKeyRequest (R"pb(
434+ parent: "public"
435+ key { display_name: "should-stay" }
436+ )pb" );
437+ auto custom_headers = Http::TestRequestHeaderMapImpl{{" api-version" , " 2025-v2" }};
438+
439+ auto response = sendGrpcRequest (original_proto, kCreateApiKeyMethod , custom_headers);
440+ waitForNextUpstreamRequest ();
441+
442+ Buffer::OwnedImpl data;
443+ data.add (upstream_request_->body ());
444+ checkSerializedData<apikeys::CreateApiKeyRequest>(data, {original_proto});
445+
446+ upstream_request_->encodeHeaders (Http::TestResponseHeaderMapImpl{{" :status" , " 200" }}, true );
447+ ASSERT_TRUE (response->waitForEndStream ());
448+ }
449+ }
450+
451+ // Tests scrubbing of nested fields in the request when a String matcher is configured to use
452+ // request headers via exact match.
453+ TEST_P (ProtoApiScrubberIntegrationTest, ScrubNestedField_StringMatcher_RequestHeader_ExactMatch) {
454+ envoy::type::matcher::v3::HttpRequestHeaderMatchInput header_input;
455+ header_input.set_header_name (" api-version" );
456+
457+ auto predicate = buildStringMatcherPredicate (" envoy.matching.inputs.request_headers" ,
458+ header_input, " 2025-v1" , StringMatchType::Exact);
459+
460+ config_helper_.prependFilter (getFilterConfig (apikeysDescriptorPath (), kCreateApiKeyMethod ,
461+ " key.display_name" , RestrictionType::Request,
462+ predicate));
463+
464+ initialize ();
465+
466+ auto original_proto = makeCreateApiKeyRequest (R"pb(
467+ parent: "public"
468+ key { display_name: "should-be-removed" }
469+ )pb" );
470+ auto custom_headers = Http::TestRequestHeaderMapImpl{{" api-version" , " 2025-v1" }};
471+
472+ auto response = sendGrpcRequest (original_proto, kCreateApiKeyMethod , custom_headers);
473+ waitForNextUpstreamRequest ();
474+
475+ apikeys::CreateApiKeyRequest expected = original_proto;
476+ expected.mutable_key ()->clear_display_name ();
477+
478+ Buffer::OwnedImpl data;
479+ data.add (upstream_request_->body ());
480+ checkSerializedData<apikeys::CreateApiKeyRequest>(data, {expected});
481+
482+ upstream_request_->encodeHeaders (Http::TestResponseHeaderMapImpl{{" :status" , " 200" }}, true );
483+ ASSERT_TRUE (response->waitForEndStream ());
484+ }
485+
486+ // Tests scrubbing of nested fields in the request when a String matcher is configured to use filter
487+ // state via regex match.
488+ TEST_P (ProtoApiScrubberIntegrationTest, ScrubNestedField_StringMatcher_FilterState_RegexMatch) {
489+ envoy::extensions::matching::common_inputs::network::v3::FilterStateInput filter_state_input;
490+ filter_state_input.set_key (kFilterStateLabelKey );
491+
492+ // The metadata injector filter (test_injector) sets the value to: "LABEL1,LABEL2,LABEL3"
493+ // We use a regex to verify that "LABEL2" is present in the list.
494+ auto predicate =
495+ buildStringMatcherPredicate (" envoy.matching.inputs.filter_state" , filter_state_input,
496+ " .*LABEL2.*" , StringMatchType::Regex);
497+
498+ config_helper_.prependFilter (getFilterConfig (apikeysDescriptorPath (), kCreateApiKeyMethod ,
499+ " key.display_name" , RestrictionType::Request,
500+ predicate));
501+
502+ config_helper_.prependFilter (R"yaml(
503+ name: test_injector
504+ typed_config:
505+ "@type": type.googleapis.com/google.protobuf.Struct
506+ )yaml" );
507+
508+ initialize ();
509+
510+ auto original_proto = makeCreateApiKeyRequest (R"pb(
511+ parent: "public"
512+ key { display_name: "should-be-removed" }
513+ )pb" );
514+
515+ auto response = sendGrpcRequest (original_proto, kCreateApiKeyMethod );
516+ waitForNextUpstreamRequest ();
517+
518+ // Since "LABEL1,LABEL2,LABEL3" matches ".*LABEL2.*", the field should be removed.
519+ apikeys::CreateApiKeyRequest expected = original_proto;
520+ expected.mutable_key ()->clear_display_name ();
521+
522+ Buffer::OwnedImpl data;
523+ data.add (upstream_request_->body ());
524+ checkSerializedData<apikeys::CreateApiKeyRequest>(data, {expected});
525+
526+ upstream_request_->encodeHeaders (Http::TestResponseHeaderMapImpl{{" :status" , " 200" }}, true );
527+ ASSERT_TRUE (response->waitForEndStream ());
528+ }
529+
307530// ============================================================================
308531// TEST GROUP 3: VALIDATION & REJECTION
309532// ============================================================================
0 commit comments