diff --git a/api/BUILD b/api/BUILD index 11eb3d4bdb3ce..ddd1f98b36cb7 100644 --- a/api/BUILD +++ b/api/BUILD @@ -193,6 +193,7 @@ proto_library( "//envoy/extensions/filters/network/sni_cluster/v3:pkg", "//envoy/extensions/filters/network/sni_dynamic_forward_proxy/v3:pkg", "//envoy/extensions/filters/network/tcp_proxy/v3:pkg", + "//envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/filters/ratelimit/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/router/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/v3:pkg", diff --git a/api/envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/BUILD b/api/envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/BUILD new file mode 100644 index 0000000000000..693f0b92ff34d --- /dev/null +++ b/api/envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/BUILD @@ -0,0 +1,12 @@ +# DO NOT EDIT. This file is generated by tools/proto_format/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/type/matcher/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.proto b/api/envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.proto new file mode 100644 index 0000000000000..f0b0ec963b4fb --- /dev/null +++ b/api/envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.proto @@ -0,0 +1,109 @@ +syntax = "proto3"; + +package envoy.extensions.filters.network.thrift_proxy.filters.header_to_metadata.v3; + +import "envoy/type/matcher/v3/regex.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.network.thrift_proxy.filters.header_to_metadata.v3"; +option java_outer_classname = "HeaderToMetadataProto"; +option java_multiple_files = true; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Header-To-Metadata Filter] +// +// The configuration for transforming headers into metadata. This is useful +// for matching load balancer subsets, logging, etc. +// +// Header to Metadata :ref:`configuration overview `. +// [#extension: envoy.filters.thrift.header_to_metadata] + +message HeaderToMetadata { + enum ValueType { + STRING = 0; + + NUMBER = 1; + + // The value is a serialized `protobuf.Value + // `_. + PROTOBUF_VALUE = 2; + } + + // ValueEncode defines the encoding algorithm. + enum ValueEncode { + // The value is not encoded. + NONE = 0; + + // The value is encoded in `Base64 `_. + // Note: this is mostly used for STRING and PROTOBUF_VALUE to escape the + // non-ASCII characters in the header. + BASE64 = 1; + } + + // [#next-free-field: 7] + message KeyValuePair { + // The namespace — if this is empty, the filter's namespace will be used. + string metadata_namespace = 1; + + // The key to use within the namespace. + string key = 2 [(validate.rules).string = {min_len: 1}]; + + oneof value_type { + // The value to pair with the given key. + // + // When used for on_present case, if value is non-empty it'll be used instead + // of the header value. If both are empty, no metadata is added. + // + // When used for on_missing case, a non-empty value must be provided otherwise + // no metadata is added. + string value = 3; + + // If present, the header's value will be matched and substituted with this. + // If there is no match or substitution, the header value + // is used as-is. + // + // This is only used for on_present. + // + // Note: if the `value` field is non-empty this field should be empty. + type.matcher.v3.RegexMatchAndSubstitute regex_value_rewrite = 4; + } + + // The value's type — defaults to string. + ValueType type = 5 [(validate.rules).enum = {defined_only: true}]; + + // How is the value encoded, default is NONE (not encoded). + // The value will be decoded accordingly before storing to metadata. + ValueEncode encode = 6; + } + + // A Rule defines what metadata to apply when a header is present or missing. + message Rule { + // Specifies that a match will be performed on the value of a header. + // + // The header to be extracted. + string header = 1 + [(validate.rules).string = {min_len: 1 well_known_regex: HTTP_HEADER_NAME strict: false}]; + + // If the header is present, apply this metadata KeyValuePair. + // + // If the value in the KeyValuePair is non-empty, it'll be used instead + // of the header value. + KeyValuePair on_present = 2; + + // If the header is not present, apply this metadata KeyValuePair. + // + // The value in the KeyValuePair must be set, since it'll be used in lieu + // of the missing header value. + KeyValuePair on_missing = 3; + + // Whether or not to remove the header after a rule is applied. + // + // This prevents headers from leaking. + bool remove = 4; + } + + // The list of rules to apply to requests. + repeated Rule request_rules = 1 [(validate.rules).repeated = {min_items: 1}]; +} diff --git a/api/versioning/BUILD b/api/versioning/BUILD index 8eeb536f66c84..24195d8d680dd 100644 --- a/api/versioning/BUILD +++ b/api/versioning/BUILD @@ -130,6 +130,7 @@ proto_library( "//envoy/extensions/filters/network/sni_cluster/v3:pkg", "//envoy/extensions/filters/network/sni_dynamic_forward_proxy/v3:pkg", "//envoy/extensions/filters/network/tcp_proxy/v3:pkg", + "//envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/filters/ratelimit/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/router/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/v3:pkg", diff --git a/docs/root/configuration/other_protocols/thrift_filters/header_to_metadata_filter.rst b/docs/root/configuration/other_protocols/thrift_filters/header_to_metadata_filter.rst new file mode 100644 index 0000000000000..611142166349d --- /dev/null +++ b/docs/root/configuration/other_protocols/thrift_filters/header_to_metadata_filter.rst @@ -0,0 +1,90 @@ +.. _config_thrift_filters_header_to_metadata: + +Envoy Header-To-Metadata Filter +=============================== +* :ref:`v3 API reference ` +* This filter should be configured with the name *envoy.filters.http.header_to_metadata*. + +This filter is configured with rules that will be matched against requests. +Each rule has either a header and can be triggered either when the header is present or missing. + +When a rule is triggered, dynamic metadata will be added based on the configuration of the rule. +If the header is present, it's value is extracted and used along with the specified +key as metadata. If the header is missing, on missing case is triggered and the value +specified is used for adding metadata. + +The metadata can then be used for load balancing decisions, consumed from logs, etc. + +A typical use case for this filter is to dynamically match requests with load balancer +subsets. For this, a given header's value would be extracted and attached to the request +as dynamic metadata which would then be used to match a subset of endpoints. + +Example +------- + +A sample filter configuration to route traffic to endpoints based on the presence or +absence of a version header could be: + +.. code-block:: yaml + + thrift_filters: + - name: envoy.filters.thrift.header_to_metadata + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.thrift.header_to_metadata.v3.HeaderToMetadata + request_rules: + - header: x-version + on_header_present: + metadata_namespace: envoy.lb + key: version + type: STRING + on_header_missing: + metadata_namespace: envoy.lb + key: default + value: 'true' + type: STRING + remove: false + +A corresponding upstream cluster configuration could be: + +.. code-block:: yaml + + clusters: + - name: versioned-cluster + type: EDS + lb_policy: ROUND_ROBIN + lb_subset_config: + fallback_policy: ANY_ENDPOINT + subset_selectors: + - keys: + - default + - keys: + - version + +This would then allow requests with the ``x-version`` header set to be matched against +endpoints with the corresponding version. Whereas requests with that header missing +would be matched with the default endpoints. + +If the header's value needs to be transformed before it's added to the request as +dynamic metadata, this filter supports regex matching and substitution: + +.. code-block:: yaml + + thrift_filters: + - name: envoy.filters.thrift.header_to_metadata + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.thrift.header_to_metadata.v3.HeaderToMetadata + request_rules: + - header: ":path" + on_header_present: + metadata_namespace: envoy.lb + key: cluster + regex_value_rewrite: + pattern: + google_re2: {} + regex: "^/(cluster[\\d\\w-]+)/?.*$" + substitution: "\\1" + +Statistics +---------- + +Currently, this filter generates no statistics. diff --git a/docs/root/configuration/other_protocols/thrift_filters/thrift_filters.rst b/docs/root/configuration/other_protocols/thrift_filters/thrift_filters.rst index 5dadb3c5f8d40..3dd28c6523324 100644 --- a/docs/root/configuration/other_protocols/thrift_filters/thrift_filters.rst +++ b/docs/root/configuration/other_protocols/thrift_filters/thrift_filters.rst @@ -8,5 +8,6 @@ Envoy has the following builtin Thrift filters. .. toctree:: :maxdepth: 2 + header_to_metadata_filter rate_limit_filter router_filter diff --git a/docs/root/version_history/current.rst b/docs/root/version_history/current.rst index 3648d52837cc4..4ccc134ded333 100644 --- a/docs/root/version_history/current.rst +++ b/docs/root/version_history/current.rst @@ -64,6 +64,7 @@ New Features * oauth filter: added :ref:`cookie_names ` to allow overriding (default) cookie names (``BearerToken``, ``OauthHMAC``, and ``OauthExpires``) set by the filter. * tcp: added a :ref:`FilterState ` :ref:`hash policy `, used by :ref:`TCP proxy ` to allow hashing load balancer algorithms to hash on objects in filter state. * tcp_proxy: added support to populate upstream http connect header values from stream info. +* thrift_proxy: add header to metadata filter for turning headers into dynamic metadata. * thrift_proxy: add upstream response zone metrics in the form ``cluster.cluster_name.zone.local_zone.upstream_zone.thrift.upstream_resp_success``. * thrift_proxy: add upstream metrics to show decoding errors and whether exception is from local or remote, e.g. ``cluster.cluster_name.thrift.upstream_resp_exception_remote``. * thrift_proxy: add host level success/error metrics where success is a reply of type success and error is any other response to a call. diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index f9acb724d9642..cd6a62d3d5c42 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -173,6 +173,7 @@ EXTENSIONS = { # "envoy.filters.thrift.router": "//source/extensions/filters/network/thrift_proxy/router:config", + "envoy.filters.thrift.header_to_metadata": "//source/extensions/filters/network/thrift_proxy/filters/header_to_metadata:config", "envoy.filters.thrift.ratelimit": "//source/extensions/filters/network/thrift_proxy/filters/ratelimit:config", # diff --git a/source/extensions/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index 4df99902fad6d..6a4a403220fdb 100644 --- a/source/extensions/extensions_metadata.yaml +++ b/source/extensions/extensions_metadata.yaml @@ -393,6 +393,11 @@ envoy.filters.network.zookeeper_proxy: - envoy.filters.network security_posture: requires_trusted_downstream_and_upstream status: alpha +envoy.filters.thrift.header_to_metadata: + categories: + - envoy.thrift_proxy.filters + security_posture: requires_trusted_downstream_and_upstream + status: alpha envoy.filters.thrift.ratelimit: categories: - envoy.thrift_proxy.filters diff --git a/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/BUILD b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/BUILD new file mode 100644 index 0000000000000..810944a4ac04d --- /dev/null +++ b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/BUILD @@ -0,0 +1,33 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_extension_package() + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + deps = [ + ":header_to_metadata_filter_lib", + "//envoy/registry", + "//source/extensions/filters/network/thrift_proxy/filters:factory_base_lib", + "@envoy_api//envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "header_to_metadata_filter_lib", + srcs = ["header_to_metadata_filter.cc"], + hdrs = ["header_to_metadata_filter.h"], + deps = [ + "//envoy/server:filter_config_interface", + "//source/extensions/filters/network/thrift_proxy/filters:pass_through_filter_lib", + "@envoy_api//envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.cc b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.cc new file mode 100644 index 0000000000000..f7fde92031706 --- /dev/null +++ b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.cc @@ -0,0 +1,35 @@ +#include "source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.h" + +#include + +#include "source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h" + +namespace Envoy { +namespace Extensions { +namespace ThriftFilters { +namespace HeaderToMetadataFilter { + +using namespace Envoy::Extensions::NetworkFilters; + +ThriftProxy::ThriftFilters::FilterFactoryCb +HeaderToMetadataFilterConfig::createFilterFactoryFromProtoTyped( + const envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata& proto_config, + const std::string&, Server::Configuration::FactoryContext&) { + ConfigSharedPtr filter_config(std::make_shared(proto_config)); + return + [filter_config](ThriftProxy::ThriftFilters::FilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addDecoderFilter(std::make_shared(filter_config)); + }; +} + +/** + * Static registration for the header to metadata filter. @see RegisterFactory. + */ +REGISTER_FACTORY(HeaderToMetadataFilterConfig, + ThriftProxy::ThriftFilters::NamedThriftFilterConfigFactory); + +} // namespace HeaderToMetadataFilter +} // namespace ThriftFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.h b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.h new file mode 100644 index 0000000000000..08777ae395a13 --- /dev/null +++ b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.h @@ -0,0 +1,33 @@ +#pragma once + +#include "envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.pb.h" +#include "envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.pb.validate.h" + +#include "source/extensions/filters/network/thrift_proxy/filters/factory_base.h" +#include "source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h" + +namespace Envoy { +namespace Extensions { +namespace ThriftFilters { +namespace HeaderToMetadataFilter { + +/** + * Config registration for the header to metadata filter. @see NamedThriftFilterConfigFactory. + */ +class HeaderToMetadataFilterConfig : public ThriftProxy::ThriftFilters::FactoryBase< + envoy::extensions::filters::network::thrift_proxy:: + filters::header_to_metadata::v3::HeaderToMetadata> { +public: + HeaderToMetadataFilterConfig() : FactoryBase("envoy.filters.thrift.header_to_metadata") {} + +private: + ThriftProxy::ThriftFilters::FilterFactoryCb createFilterFactoryFromProtoTyped( + const envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata& proto_config, + const std::string& stats_prefix, Server::Configuration::FactoryContext& context) override; +}; + +} // namespace HeaderToMetadataFilter +} // namespace ThriftFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.cc b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.cc new file mode 100644 index 0000000000000..bf2b3409a7c40 --- /dev/null +++ b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.cc @@ -0,0 +1,184 @@ +#include "source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h" + +#include "source/common/common/base64.h" +#include "source/common/common/regex.h" +#include "source/common/http/headers.h" +#include "source/common/network/utility.h" + +namespace Envoy { +namespace Extensions { +namespace ThriftFilters { +namespace HeaderToMetadataFilter { + +using namespace Envoy::Extensions::NetworkFilters; + +// Extract the value of the header. +absl::optional HeaderValueSelector::extract(Http::HeaderMap& map) const { + const auto header_entry = map.get(header_); + if (header_entry.empty()) { + return absl::nullopt; + } + // Catch the value in the header before removing. + absl::optional value = std::string(header_entry[0]->value().getStringView()); + if (remove_) { + map.remove(header_); + } + return value; +} + +Rule::Rule(const ProtoRule& rule) : rule_(rule) { + selector_ = + std::make_shared(Http::LowerCaseString(rule.header()), rule.remove()); + + // Rule must have at least one of the `on_*` fields set. + if (!rule.has_on_present() && !rule.has_on_missing()) { + const auto& error = fmt::format("header to metadata filter: rule for {} has neither " + "`on_present` nor `on_missing` set", + selector_->toString()); + throw EnvoyException(error); + } + + if (rule.has_on_missing() && rule.on_missing().value().empty()) { + throw EnvoyException("Cannot specify on_missing rule without non-empty value"); + } + + if (rule.has_on_present() && rule.on_present().has_regex_value_rewrite()) { + const auto& rewrite_spec = rule.on_present().regex_value_rewrite(); + regex_rewrite_ = Regex::Utility::parseRegex(rewrite_spec.pattern()); + regex_rewrite_substitution_ = rewrite_spec.substitution(); + } +} + +Config::Config(const envoy::extensions::filters::network::thrift_proxy::filters:: + header_to_metadata::v3::HeaderToMetadata& config) { + for (const auto& entry : config.request_rules()) { + request_rules_.emplace_back(entry); + } +} + +HeaderToMetadataFilter::HeaderToMetadataFilter(const ConfigSharedPtr config) : config_(config) {} + +ThriftProxy::FilterStatus +HeaderToMetadataFilter::transportBegin(ThriftProxy::MessageMetadataSharedPtr metadata) { + auto& headers = metadata->headers(); + + writeHeaderToMetadata(headers, config_->requestRules(), *decoder_callbacks_); + + return ThriftProxy::FilterStatus::Continue; +} + +const std::string& HeaderToMetadataFilter::decideNamespace(const std::string& nspace) const { + static const std::string& headerToMetadata = "envoy.filters.thrift.header_to_metadata"; + return nspace.empty() ? headerToMetadata : nspace; +} + +bool HeaderToMetadataFilter::addMetadata(StructMap& map, const std::string& meta_namespace, + const std::string& key, std::string value, ValueType type, + ValueEncode encode) const { + ProtobufWkt::Value val; + + ASSERT(!value.empty()); + + if (value.size() >= MAX_HEADER_VALUE_LEN) { + // Too long, go away. + ENVOY_LOG(debug, "metadata value is too long"); + return false; + } + + if (encode == envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata::BASE64) { + value = Base64::decodeWithoutPadding(value); + if (value.empty()) { + ENVOY_LOG(debug, "Base64 decode failed"); + return false; + } + } + + // Sane enough, add the key/value. + switch (type) { + case envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata::STRING: + val.set_string_value(std::move(value)); + break; + case envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata::NUMBER: { + double dval; + if (absl::SimpleAtod(StringUtil::trim(value), &dval)) { + val.set_number_value(dval); + } else { + ENVOY_LOG(debug, "value to number conversion failed"); + return false; + } + break; + } + case envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata::PROTOBUF_VALUE: { + if (!val.ParseFromString(value)) { + ENVOY_LOG(debug, "parse from decoded string failed"); + return false; + } + break; + } + default: + NOT_REACHED_GCOVR_EXCL_LINE; + } + + // Have we seen this namespace before? + auto namespace_iter = map.find(meta_namespace); + if (namespace_iter == map.end()) { + map[meta_namespace] = ProtobufWkt::Struct(); + namespace_iter = map.find(meta_namespace); + } + + auto& keyval = namespace_iter->second; + (*keyval.mutable_fields())[key] = val; + + return true; +} + +// add metadata['key']= value depending on header present or missing case +void HeaderToMetadataFilter::applyKeyValue(std::string&& value, const Rule& rule, + const KeyValuePair& keyval, StructMap& np) const { + if (keyval.has_regex_value_rewrite()) { + const auto& matcher = rule.regexRewrite(); + value = matcher->replaceAll(value, rule.regexSubstitution()); + } else if (!keyval.value().empty()) { + value = keyval.value(); + } + if (!value.empty()) { + const auto& nspace = decideNamespace(keyval.metadata_namespace()); + addMetadata(np, nspace, keyval.key(), value, keyval.type(), keyval.encode()); + } else { + ENVOY_LOG(debug, "value is empty, not adding metadata"); + } +} + +void HeaderToMetadataFilter::writeHeaderToMetadata( + Http::HeaderMap& headers, const HeaderToMetadataRules& rules, + ThriftProxy::ThriftFilters::DecoderFilterCallbacks& callbacks) const { + StructMap structs_by_namespace; + + for (const auto& rule : rules) { + const auto& proto_rule = rule.rule(); + absl::optional value = rule.selector_->extract(headers); + + if (value && proto_rule.has_on_present()) { + applyKeyValue(std::move(value).value_or(""), rule, proto_rule.on_present(), + structs_by_namespace); + } else if (!value && proto_rule.has_on_missing()) { + applyKeyValue(std::move(value).value_or(""), rule, proto_rule.on_missing(), + structs_by_namespace); + } + } + // Any matching rules? + if (!structs_by_namespace.empty()) { + for (auto const& entry : structs_by_namespace) { + callbacks.streamInfo().setDynamicMetadata(entry.first, entry.second); + } + } +} + +} // namespace HeaderToMetadataFilter +} // namespace ThriftFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h new file mode 100644 index 0000000000000..3578943d52fbd --- /dev/null +++ b/source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +#include "envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.pb.h" + +#include "source/common/common/logger.h" +#include "source/common/common/matchers.h" +#include "source/extensions/filters/network/thrift_proxy/filters/pass_through_filter.h" + +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace ThriftFilters { +namespace HeaderToMetadataFilter { + +using namespace Envoy::Extensions::NetworkFilters; +using ProtoRule = envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata:: + v3::HeaderToMetadata::Rule; +using KeyValuePair = envoy::extensions::filters::network::thrift_proxy::filters:: + header_to_metadata::v3::HeaderToMetadata::KeyValuePair; +using ValueType = envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata:: + v3::HeaderToMetadata::ValueType; +using ValueEncode = envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata:: + v3::HeaderToMetadata::ValueEncode; + +// Get value from a header. +class HeaderValueSelector { +public: + explicit HeaderValueSelector(Http::LowerCaseString header, bool remove) + : header_(std::move(header)), remove_(std::move(remove)) {} + absl::optional extract(Http::HeaderMap& map) const; + std::string toString() const { return fmt::format("header '{}'", header_.get()); } + +private: + const Http::LowerCaseString header_; + const bool remove_; +}; + +class Rule { +public: + Rule(const ProtoRule& rule); + const ProtoRule& rule() const { return rule_; } + const Regex::CompiledMatcherPtr& regexRewrite() const { return regex_rewrite_; } + const std::string& regexSubstitution() const { return regex_rewrite_substitution_; } + std::shared_ptr selector_; + +private: + const ProtoRule rule_; + Regex::CompiledMatcherPtr regex_rewrite_{}; + std::string regex_rewrite_substitution_{}; +}; + +using HeaderToMetadataRules = std::vector; + +const uint32_t MAX_HEADER_VALUE_LEN = 8 * 1024; + +class Config { +public: + Config(const envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata& config); + const HeaderToMetadataRules& requestRules() const { return request_rules_; } + +private: + HeaderToMetadataRules request_rules_; +}; + +using ConfigSharedPtr = std::shared_ptr; + +class HeaderToMetadataFilter : public ThriftProxy::ThriftFilters::PassThroughDecoderFilter, + protected Logger::Loggable { +public: + HeaderToMetadataFilter(const ConfigSharedPtr config); + + ThriftProxy::FilterStatus + transportBegin(Extensions::NetworkFilters::ThriftProxy::MessageMetadataSharedPtr) override; + +private: + using ProtobufRepeatedRule = Protobuf::RepeatedPtrField; + using StructMap = std::map; + + /** + * writeHeaderToMetadata encapsulates (1) searching for the header and (2) writing it to the + * request metadata. + * @param headers the map of key-value headers to look through. These are request headers. + * @param rules the header-to-metadata mapping set in configuration. + */ + void writeHeaderToMetadata(Http::HeaderMap& headers, const HeaderToMetadataRules& rules, + ThriftProxy::ThriftFilters::DecoderFilterCallbacks& callbacks) const; + bool addMetadata(StructMap&, const std::string&, const std::string&, std::string, ValueType, + ValueEncode) const; + void applyKeyValue(std::string&&, const Rule&, const KeyValuePair&, StructMap&) const; + const std::string& decideNamespace(const std::string& nspace) const; + + const ConfigSharedPtr config_; +}; + +} // namespace HeaderToMetadataFilter +} // namespace ThriftFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/BUILD b/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/BUILD new file mode 100644 index 0000000000000..45f0eec6b570a --- /dev/null +++ b/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/BUILD @@ -0,0 +1,37 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_package", +) +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_names = ["envoy.filters.thrift.header_to_metadata"], + deps = [ + "//source/extensions/filters/network/thrift_proxy/filters/header_to_metadata:config", + "//source/extensions/filters/network/thrift_proxy/filters/header_to_metadata:header_to_metadata_filter_lib", + "//test/extensions/filters/network/thrift_proxy:mocks", + "//test/mocks/server:server_mocks", + "@envoy_api//envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "header_to_metadata_filter_test", + srcs = ["header_to_metadata_filter_test.cc"], + extension_names = ["envoy.filters.thrift.header_to_metadata"], + deps = [ + "//source/extensions/filters/network/thrift_proxy/filters/header_to_metadata:header_to_metadata_filter_lib", + "//test/extensions/filters/network/thrift_proxy:mocks", + "//test/mocks/server:server_mocks", + "//test/mocks/ssl:ssl_mocks", + ], +) diff --git a/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config_test.cc b/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config_test.cc new file mode 100644 index 0000000000000..e3dadf9eedf25 --- /dev/null +++ b/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config_test.cc @@ -0,0 +1,127 @@ +#include + +#include "envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.pb.h" +#include "envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3/header_to_metadata.pb.validate.h" + +#include "source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/config.h" +#include "source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h" + +#include "test/extensions/filters/network/thrift_proxy/mocks.h" +#include "test/mocks/server/factory_context.h" +#include "test/mocks/server/instance.h" +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace ThriftFilters { +namespace HeaderToMetadataFilter { + +using HeaderToMetadataProtoConfig = envoy::extensions::filters::network::thrift_proxy::filters:: + header_to_metadata::v3::HeaderToMetadata; + +void testForbiddenConfig(const std::string& yaml, const std::string& message) { + HeaderToMetadataProtoConfig proto_config; + TestUtility::loadFromYamlAndValidate(yaml, proto_config); + + testing::NiceMock context; + HeaderToMetadataFilterConfig factory; + + EXPECT_THROW_WITH_MESSAGE(factory.createFilterFactoryFromProto(proto_config, "stats", context), + EnvoyException, message); +} + +// Tests that empty (metadata) keys are rejected. +TEST(HeaderToMetadataFilterConfigTest, InvalidEmptyKey) { + const std::string yaml = R"EOF( +request_rules: + - header: x-version + on_present: + metadata_namespace: envoy.lb + key: "" + type: STRING + )EOF"; + + HeaderToMetadataProtoConfig proto_config; + EXPECT_THROW(TestUtility::loadFromYamlAndValidate(yaml, proto_config), ProtoValidationException); +} + +// Tests that a valid config with header is properly consumed. +TEST(HeaderToMetadataFilterConfigTest, SimpleConfig) { + const std::string yaml = R"EOF( +request_rules: + - header: x-version + on_present: + metadata_namespace: envoy.lb + key: version + type: STRING + on_missing: + metadata_namespace: envoy.lb + key: default + value: 'true' + type: STRING + )EOF"; + + HeaderToMetadataProtoConfig proto_config; + TestUtility::loadFromYamlAndValidate(yaml, proto_config); + + testing::NiceMock context; + HeaderToMetadataFilterConfig factory; + + auto cb = factory.createFilterFactoryFromProto(proto_config, "stats", context); + NetworkFilters::ThriftProxy::ThriftFilters::MockFilterChainFactoryCallbacks filter_callbacks; + EXPECT_CALL(filter_callbacks, addDecoderFilter(_)); + cb(filter_callbacks); +} + +// Tests that configuration does not allow value and regex_value_rewrite in the same rule. +TEST(HeaderToMetadataFilterConfigTest, ValueAndRegex) { + const std::string yaml = R"EOF( +request_rules: + - header: x-version + on_present: + metadata_namespace: envoy.lb + key: cluster + value: foo + regex_value_rewrite: + pattern: + google_re2: {} + regex: "^/(cluster[\\d\\w-]+)/?.*$" + substitution: "\\1" + )EOF"; + + HeaderToMetadataProtoConfig proto_config; + EXPECT_THROW(TestUtility::loadFromYamlAndValidate(yaml, proto_config), EnvoyException); +} + +// Tests that configuration does not allow rule without either on_present or on_missing. +TEST(HeaderToMetadataFilterConfigTest, InvalidEmptyRule) { + const std::string yaml = R"EOF( +request_rules: + - header: x-no-exist + )EOF"; + + testForbiddenConfig(yaml, "header to metadata filter: rule for header 'x-no-exist' has neither " + "`on_present` nor `on_missing` set"); +} + +// Tests that on_missing rules don't allow an empty value. +TEST(HeaderToMetadataFilterConfigTest, OnHeaderMissingEmptyValue) { + const std::string yaml = R"EOF( +request_rules: + - header: x-version + on_missing: + metadata_namespace: envoy.lb + key: "foo" + type: STRING + )EOF"; + + testForbiddenConfig(yaml, "Cannot specify on_missing rule without non-empty value"); +} + +} // namespace HeaderToMetadataFilter +} // namespace ThriftFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter_test.cc b/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter_test.cc new file mode 100644 index 0000000000000..587bef6389dc3 --- /dev/null +++ b/test/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter_test.cc @@ -0,0 +1,502 @@ +#include + +#include "source/common/common/base64.h" +#include "source/extensions/filters/network/thrift_proxy/filters/header_to_metadata/header_to_metadata_filter.h" + +#include "test/extensions/filters/network/thrift_proxy/mocks.h" +#include "test/mocks/network/mocks.h" +#include "test/mocks/server/mocks.h" +#include "test/mocks/stream_info/mocks.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace ThriftFilters { +namespace HeaderToMetadataFilter { + +namespace { + +MATCHER_P(MapEq, rhs, "") { + const ProtobufWkt::Struct& obj = arg; + EXPECT_TRUE(!rhs.empty()); + for (auto const& entry : rhs) { + EXPECT_EQ(obj.fields().at(entry.first).string_value(), entry.second); + } + return true; +} + +MATCHER_P(MapEqNum, rhs, "") { + const ProtobufWkt::Struct& obj = arg; + EXPECT_TRUE(!rhs.empty()); + for (auto const& entry : rhs) { + EXPECT_EQ(obj.fields().at(entry.first).number_value(), entry.second); + } + return true; +} + +MATCHER_P(MapEqValue, rhs, "") { + const ProtobufWkt::Struct& obj = arg; + EXPECT_TRUE(!rhs.empty()); + for (auto const& entry : rhs) { + EXPECT_TRUE(TestUtility::protoEqual(obj.fields().at(entry.first), entry.second)); + } + return true; +} + +} // namespace + +using namespace Envoy::Extensions::NetworkFilters; + +class HeaderToMetadataTest : public testing::Test { +public: + void initializeFilter(const std::string& yaml) { + envoy::extensions::filters::network::thrift_proxy::filters::header_to_metadata::v3:: + HeaderToMetadata proto_config; + TestUtility::loadFromYaml(yaml, proto_config); + const auto& filter_config = std::make_shared(proto_config); + filter_ = std::make_shared(filter_config); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + } + + NiceMock decoder_callbacks_; + NiceMock req_info_; + std::shared_ptr filter_; +}; + +TEST_F(HeaderToMetadataTest, BasicRequestTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-version + on_present: + metadata_namespace: envoy.lb + key: version +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"version", "0xdeadbeef"}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-VERSION"), "0xdeadbeef"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +TEST_F(HeaderToMetadataTest, DefaultNamespaceTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-version + on_present: + key: version +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"version", "0xdeadbeef"}}; + EXPECT_CALL(req_info_, + setDynamicMetadata("envoy.filters.thrift.header_to_metadata", MapEq(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-VERSION"), "0xdeadbeef"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +TEST_F(HeaderToMetadataTest, ReplaceValueTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-replace + on_present: + metadata_namespace: envoy.lb + key: replace + value: world +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"replace", "world"}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-replace"), "hello"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +TEST_F(HeaderToMetadataTest, SubstituteValueTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-sub + on_present: + metadata_namespace: envoy.lb + key: subbed + regex_value_rewrite: + pattern: + google_re2: {} + regex: "^hello (\\w+)?.*$" + substitution: "\\1" +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"subbed", "world"}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-sub"), "hello world!!!!!"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +TEST_F(HeaderToMetadataTest, NoMatchSubstituteValueTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-sub + on_present: + metadata_namespace: envoy.lb + key: subbed + regex_value_rewrite: + pattern: + google_re2: {} + regex: "^hello (\\w+)?.*$" + substitution: "\\1" +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"subbed", "does not match"}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-sub"), "does not match"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Test empty value doesn't get written to metadata. + */ +TEST_F(HeaderToMetadataTest, SubstituteEmptyValueTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-sub + on_present: + metadata_namespace: envoy.lb + key: subbed + regex_value_rewrite: + pattern: + google_re2: {} + regex: "^hello (\\w+)?.*$" + substitution: "\\1" +)EOF"; + initializeFilter(request_config_yaml); + EXPECT_CALL(req_info_, setDynamicMetadata(_, _)).Times(0); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-sub"), "hello !!!!!"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Test the value gets written as a number. + */ +TEST_F(HeaderToMetadataTest, NumberTypeTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-number + on_present: + metadata_namespace: envoy.lb + key: number + type: NUMBER +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"number", 1}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEqNum(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-Number"), "1"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Test the value gets written as a number. + */ +TEST_F(HeaderToMetadataTest, BadNumberTypeTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-number + on_present: + metadata_namespace: envoy.lb + key: number + type: NUMBER +)EOF"; + initializeFilter(request_config_yaml); + EXPECT_CALL(req_info_, setDynamicMetadata(_, _)).Times(0); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-Number"), "invalid"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Test the Base64 encoded value gets written as a string. + */ +TEST_F(HeaderToMetadataTest, StringTypeInBase64UrlTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-base64 + on_present: + metadata_namespace: envoy.lb + key: base64_key + type: STRING + encode: BASE64 +)EOF"; + initializeFilter(request_config_yaml); + std::string data = "Non-ascii-characters"; + std::map expected = {{"base64_key", data}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + const auto encoded = Base64::encode(data.c_str(), data.size()); + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-Base64"), encoded); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Test the Base64 encoded protobuf value gets written as a protobuf value. + */ +TEST_F(HeaderToMetadataTest, ProtobufValueTypeInBase64UrlTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-proto-base64 + on_present: + metadata_namespace: envoy.lb + key: proto_key + type: PROTOBUF_VALUE + encode: BASE64 +)EOF"; + initializeFilter(request_config_yaml); + + ProtobufWkt::Value value; + auto* s = value.mutable_struct_value(); + + ProtobufWkt::Value v; + v.set_string_value("blafoo"); + (*s->mutable_fields())["k1"] = v; + v.set_number_value(2019.07); + (*s->mutable_fields())["k2"] = v; + v.set_bool_value(true); + (*s->mutable_fields())["k3"] = v; + + std::map expected = {{"proto_key", value}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEqValue(expected))); + + std::string data; + ASSERT_TRUE(value.SerializeToString(&data)); + const auto encoded = Base64::encode(data.c_str(), data.size()); + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-Proto-Base64"), encoded); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Test bad Base64 encoding is not written. + */ +TEST_F(HeaderToMetadataTest, ProtobufValueTypeInBadBase64UrlTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-bad-base64 + on_present: + key: proto_key + type: PROTOBUF_VALUE + encode: BASE64 +)EOF"; + initializeFilter(request_config_yaml); + EXPECT_CALL(req_info_, setDynamicMetadata(_, _)).Times(0); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-Bad-Base64"), "invalid"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Test the bad protobuf value is not written. + */ +TEST_F(HeaderToMetadataTest, BadProtobufValueTypeInBase64UrlTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-bad-proto + on_present: + key: proto_key + type: PROTOBUF_VALUE + encode: BASE64 +)EOF"; + initializeFilter(request_config_yaml); + EXPECT_CALL(req_info_, setDynamicMetadata(_, _)).Times(0); + + std::string data = "invalid"; + const auto encoded = Base64::encode(data.c_str(), data.size()); + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-Bad-Proto"), encoded); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/* + * Set configured value when header is missing. + */ +TEST_F(HeaderToMetadataTest, SetMissingValueTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-no-exist + on_missing: + metadata_namespace: envoy.lb + key: set + value: hi +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"set", "hi"}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + auto metadata = std::make_shared(); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Missing case is not executed when header is present. + */ +TEST_F(HeaderToMetadataTest, NoMissingWhenHeaderIsPresent) { + const std::string config = R"EOF( +request_rules: + - header: x-exist + on_missing: + metadata_namespace: envoy.lb + key: version + value: hi +)EOF"; + initializeFilter(config); + EXPECT_CALL(req_info_, setDynamicMetadata(_, _)).Times(0); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-Exist"), "hello"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +TEST_F(HeaderToMetadataTest, RemoveHeaderTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-remove + on_present: + metadata_namespace: envoy.lb + key: remove + value: hello + remove: true + - header: x-keep + on_present: + metadata_namespace: envoy.lb + key: keep + value: world +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"remove", "hello"}, {"keep", "world"}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-REMOVE"), + "replaced in metadata then removed from headers"); + metadata->headers().setCopy(Http::LowerCaseString("X-KEEP"), "remains"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + Http::TestRequestHeaderMapImpl headers{metadata->headers()}; + EXPECT_EQ("", headers.get_(Http::LowerCaseString("X-REMOVE"))); + EXPECT_EQ("remains", headers.get_(Http::LowerCaseString("X-KEEP"))); + filter_->onDestroy(); +} + +/** + * No header value does not set any metadata. + */ +TEST_F(HeaderToMetadataTest, EmptyHeaderValue) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-version + on_present: + metadata_namespace: envoy.lb + key: version +)EOF"; + initializeFilter(request_config_yaml); + EXPECT_CALL(req_info_, setDynamicMetadata(_, _)).Times(0); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-VERSION"), ""); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +/** + * Header value too long does not set header value as metadata. + */ +TEST_F(HeaderToMetadataTest, HeaderValueTooLong) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-version + on_present: + metadata_namespace: envoy.lb + key: version +)EOF"; + initializeFilter(request_config_yaml); + EXPECT_CALL(req_info_, setDynamicMetadata(_, _)).Times(0); + + auto metadata = std::make_shared(); + auto length = MAX_HEADER_VALUE_LEN + 1; + metadata->headers().setCopy(Http::LowerCaseString("X-VERSION"), std::string(length, 'x')); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +TEST_F(HeaderToMetadataTest, MultipleRulesTest) { + const std::string request_config_yaml = R"EOF( +request_rules: + - header: x-no-exist + on_missing: + metadata_namespace: envoy.lb + key: set + value: hello + - header: x-replace + on_present: + metadata_namespace: envoy.lb + key: replace + value: world +)EOF"; + initializeFilter(request_config_yaml); + std::map expected = {{"set", "hello"}, {"replace", "world"}}; + EXPECT_CALL(req_info_, setDynamicMetadata("envoy.lb", MapEq(expected))); + + auto metadata = std::make_shared(); + metadata->headers().setCopy(Http::LowerCaseString("X-REPLACE"), "should be replaced"); + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillRepeatedly(ReturnRef(req_info_)); + EXPECT_EQ(ThriftProxy::FilterStatus::Continue, filter_->transportBegin(metadata)); + filter_->onDestroy(); +} + +} // namespace HeaderToMetadataFilter +} // namespace ThriftFilters +} // namespace Extensions +} // namespace Envoy