diff --git a/CODEOWNERS b/CODEOWNERS index 545df28b38292..1cb3aeacdede5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,3 +202,4 @@ extensions/filters/http/oauth2 @rgs1 @derekargueta @snowp /contrib/rocketmq_proxy/ @aaron-ai @lizhanhui @lizan /contrib/mysql_proxy/ @rshriram @venilnoronha /contrib/postgres_proxy/ @fabriziomello @cpakulski @dio +/contrib/sxg/ @cpapazian @rgs1 @alyssawilk diff --git a/api/BUILD b/api/BUILD index 3308e817362e6..5bbde32946b63 100644 --- a/api/BUILD +++ b/api/BUILD @@ -58,6 +58,7 @@ proto_library( visibility = ["//visibility:public"], deps = [ "//contrib/envoy/extensions/filters/http/squash/v3:pkg", + "//contrib/envoy/extensions/filters/http/sxg/v3alpha:pkg", "//contrib/envoy/extensions/filters/network/kafka_broker/v3:pkg", "//contrib/envoy/extensions/filters/network/mysql_proxy/v3:pkg", "//contrib/envoy/extensions/filters/network/postgres_proxy/v3alpha:pkg", diff --git a/api/contrib/envoy/extensions/filters/http/sxg/v3alpha/BUILD b/api/contrib/envoy/extensions/filters/http/sxg/v3alpha/BUILD new file mode 100644 index 0000000000000..3ca8242f77801 --- /dev/null +++ b/api/contrib/envoy/extensions/filters/http/sxg/v3alpha/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/extensions/transport_sockets/tls/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/api/contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.proto b/api/contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.proto new file mode 100644 index 0000000000000..b9efc278e6de8 --- /dev/null +++ b/api/contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.sxg.v3alpha; + +import "envoy/extensions/transport_sockets/tls/v3/secret.proto"; + +import "google/protobuf/duration.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.sxg.v3alpha"; +option java_outer_classname = "SxgProto"; +option java_multiple_files = true; +option (udpa.annotations.file_status).work_in_progress = true; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Signed HTTP Exchange Filter] +// SXG :ref:`configuration overview `. +// [#extension: envoy.filters.http.sxg] + +// [#next-free-field: 10] +message SXG { + // The SDS configuration for the public key data for the SSL certificate that will be used to sign the + // SXG response. + transport_sockets.tls.v3.SdsSecretConfig certificate = 1; + + // The SDS configuration for the private key data for the SSL certificate that will be used to sign the + // SXG response. + transport_sockets.tls.v3.SdsSecretConfig private_key = 2; + + // The duration for which the generated SXG package will be valid. Default is 604800s (7 days in seconds). + // Note that in order to account for clock skew, the timestamp will be backdated by a day. So, if duration + // is set to 7 days, that will be 7 days from 24 hours ago (6 days from now). Also note that while 6/7 days + // is appropriate for most content, if the downstream service is serving Javascript, or HTML with inline + // Javascript, 1 day (so, with backdated expiry, 2 days, or 172800 seconds) is more appropriate. + google.protobuf.Duration duration = 3; + + // The SXG response payload is Merkle Integrity Content Encoding (MICE) encoded (specification is [here](https://datatracker.ietf.org/doc/html/draft-thomson-http-mice-03)) + // This value indicates the record size in the encoded payload. The default value is 4096. + uint64 mi_record_size = 4; + + // The URI of certificate CBOR file published. Since it is required that the certificate CBOR file + // be served from the same domain as the SXG document, this should be a relative URI. + string cbor_url = 5 [(validate.rules).string = {min_len: 1 prefix: "/"}]; + + // URL to retrieve validity data for signature, a CBOR map. See specification [here](https://tools.ietf.org/html/draft-yasskin-httpbis-origin-signed-exchanges-impl-00#section-3.6) + string validity_url = 6 [(validate.rules).string = {min_len: 1 prefix: "/"}]; + + // Header that will be set if it is determined that the client can accept SXG (typically `accept: application/signed-exchange;v=b3) + // If not set, filter will default to: `x-client-can-accept-sxg` + string client_can_accept_sxg_header = 7 [ + (validate.rules).string = {well_known_regex: HTTP_HEADER_NAME strict: false ignore_empty: true} + ]; + + // Header set by downstream service to signal that the response should be transformed to SXG If not set, + // filter will default to: `x-should-encode-sxg` + string should_encode_sxg_header = 8 [ + (validate.rules).string = {well_known_regex: HTTP_HEADER_NAME strict: false ignore_empty: true} + ]; + + // Headers that will be stripped from the SXG document, by listing a prefix (i.e. `x-custom-` will cause + // all headers prefixed by `x-custom-` to be omitted from the SXG document) + repeated string header_prefix_filters = 9 [ + (validate.rules).repeated = {items {string {well_known_regex: HTTP_HEADER_NAME strict: false}}} + ]; +} diff --git a/api/versioning/BUILD b/api/versioning/BUILD index b42275b379927..52cb8c09eaf81 100644 --- a/api/versioning/BUILD +++ b/api/versioning/BUILD @@ -10,6 +10,7 @@ proto_library( visibility = ["//visibility:public"], deps = [ "//contrib/envoy/extensions/filters/http/squash/v3:pkg", + "//contrib/envoy/extensions/filters/http/sxg/v3alpha:pkg", "//contrib/envoy/extensions/filters/network/kafka_broker/v3:pkg", "//contrib/envoy/extensions/filters/network/mysql_proxy/v3:pkg", "//contrib/envoy/extensions/filters/network/postgres_proxy/v3alpha:pkg", diff --git a/bazel/foreign_cc/BUILD b/bazel/foreign_cc/BUILD index 0a34e16b0e58d..2c9b481282cb0 100644 --- a/bazel/foreign_cc/BUILD +++ b/bazel/foreign_cc/BUILD @@ -102,6 +102,24 @@ configure_make( tags = ["skip_on_windows"], ) +envoy_cmake_external( + name = "libsxg", + cache_entries = { + "CMAKE_BUILD_TYPE": "Release", + "SXG_BUILD_EXECUTABLES": "off", + "SXG_BUILD_SHARED": "off", + "SXG_BUILD_STATIC": "on", + "SXG_WITH_CERT_CHAIN": "off", + "RUN_TEST": "off", + "CMAKE_INSTALL_LIBDIR": "lib", + "CMAKE_TRY_COMPILE_TARGET_TYPE": "STATIC_LIBRARY", + }, + lib_source = "@com_github_google_libsxg//:all", + static_libraries = ["libsxg.a"], + tags = ["skip_on_windows"], + deps = ["@boringssl//:ssl"], +) + envoy_cmake_external( name = "ares", cache_entries = { diff --git a/bazel/repositories.bzl b/bazel/repositories.bzl index a411c1313638b..c53d62da1bbb6 100644 --- a/bazel/repositories.bzl +++ b/bazel/repositories.bzl @@ -8,6 +8,7 @@ load("@com_google_googleapis//:repository_rules.bzl", "switched_rules_by_languag PPC_SKIP_TARGETS = ["envoy.filters.http.lua"] WINDOWS_SKIP_TARGETS = [ + "envoy.filters.http.sxg", "envoy.tracers.dynamic_ot", "envoy.tracers.lightstep", "envoy.tracers.datadog", @@ -134,6 +135,7 @@ def envoy_dependencies(skip_targets = []): _com_github_google_benchmark() _com_github_google_jwt_verify() _com_github_google_libprotobuf_mutator() + _com_github_google_libsxg() _com_github_google_tcmalloc() _com_github_gperftools_gperftools() _com_github_grpc_grpc() @@ -312,6 +314,17 @@ def _com_github_google_libprotobuf_mutator(): build_file = "@envoy//bazel/external:libprotobuf_mutator.BUILD", ) +def _com_github_google_libsxg(): + external_http_archive( + name = "com_github_google_libsxg", + build_file_content = BUILD_ALL_CONTENT, + ) + + native.bind( + name = "libsxg", + actual = "@envoy//bazel/foreign_cc:libsxg", + ) + def _com_github_jbeder_yaml_cpp(): external_http_archive( name = "com_github_jbeder_yaml_cpp", diff --git a/bazel/repository_locations.bzl b/bazel/repository_locations.bzl index 822e0dfa9fde5..834a57b1aea94 100644 --- a/bazel/repository_locations.bzl +++ b/bazel/repository_locations.bzl @@ -211,6 +211,19 @@ REPOSITORY_LOCATIONS_SPEC = dict( release_date = "2020-11-13", use_category = ["test_only"], ), + com_github_google_libsxg = dict( + project_name = "libsxg", + project_desc = "Signed HTTP Exchange library", + project_url = "https://github.com/google/libsxg", + version = "beaa3939b76f8644f6833267e9f2462760838f18", + sha256 = "082bf844047a9aeec0d388283d5edc68bd22bcf4d32eb5a566654ae89956ad1f", + strip_prefix = "libsxg-{version}", + urls = ["https://github.com/google/libsxg/archive/{version}.tar.gz"], + use_category = ["other"], + extensions = ["envoy.filters.http.sxg"], + release_date = "2021-07-08", + cpe = "N/A", + ), com_github_google_tcmalloc = dict( project_name = "tcmalloc", project_desc = "Fast, multi-threaded malloc implementation", diff --git a/contrib/contrib_build_config.bzl b/contrib/contrib_build_config.bzl index 0f076ec9767c9..34ef00af9fd15 100644 --- a/contrib/contrib_build_config.bzl +++ b/contrib/contrib_build_config.bzl @@ -5,6 +5,7 @@ CONTRIB_EXTENSIONS = { # "envoy.filters.http.squash": "//contrib/squash/filters/http/source:config", + "envoy.filters.http.sxg": "//contrib/sxg/filters/http/source:config", # # Network filters diff --git a/contrib/extensions_metadata.yaml b/contrib/extensions_metadata.yaml index ed6333bfb79cc..c3ccc61e53ee1 100644 --- a/contrib/extensions_metadata.yaml +++ b/contrib/extensions_metadata.yaml @@ -3,6 +3,11 @@ envoy.filters.http.squash: - envoy.filters.http security_posture: requires_trusted_downstream_and_upstream status: stable +envoy.filters.http.sxg: + categories: + - envoy.filters.http + security_posture: robust_to_untrusted_downstream + status: alpha envoy.filters.network.kafka_broker: categories: - envoy.filters.network @@ -23,3 +28,4 @@ envoy.filters.network.postgres_proxy: - envoy.filters.network security_posture: requires_trusted_downstream_and_upstream status: stable + diff --git a/contrib/sxg/filters/http/source/BUILD b/contrib/sxg/filters/http/source/BUILD new file mode 100644 index 0000000000000..34d61bc21a946 --- /dev/null +++ b/contrib/sxg/filters/http/source/BUILD @@ -0,0 +1,47 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_contrib_extension", + "envoy_cc_library", + "envoy_contrib_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_contrib_package() + +envoy_cc_library( + name = "sxg_lib", + srcs = [ + "encoder.cc", + "filter.cc", + "filter_config.cc", + ], + hdrs = [ + "encoder.h", + "filter.h", + "filter_config.h", + ], + external_deps = ["libsxg"], + deps = [ + "//envoy/server:filter_config_interface", + "//source/common/config:datasource_lib", + "//source/common/http:codes_lib", + "//source/common/stats:symbol_table_lib", + "//source/common/stats:utility_lib", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "@boringssl//:ssl", + "@envoy_api//contrib/envoy/extensions/filters/http/sxg/v3alpha:pkg_cc_proto", + ], +) + +envoy_cc_contrib_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + deps = [ + ":sxg_lib", + "//envoy/registry", + "//source/extensions/filters/http/common:factory_base_lib", + "@envoy_api//contrib/envoy/extensions/filters/http/sxg/v3alpha:pkg_cc_proto", + ], +) diff --git a/contrib/sxg/filters/http/source/config.cc b/contrib/sxg/filters/http/source/config.cc new file mode 100644 index 0000000000000..d487e395803fc --- /dev/null +++ b/contrib/sxg/filters/http/source/config.cc @@ -0,0 +1,71 @@ +#include "contrib/sxg/filters/http/source/config.h" + +#include +#include + +#include "envoy/registry/registry.h" +#include "envoy/secret/secret_manager.h" +#include "envoy/secret/secret_provider.h" + +#include "source/common/protobuf/utility.h" + +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.h" +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.validate.h" +#include "contrib/sxg/filters/http/source/encoder.h" +#include "contrib/sxg/filters/http/source/filter.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +namespace { +Secret::GenericSecretConfigProviderSharedPtr +secretsProvider(const envoy::extensions::transport_sockets::tls::v3::SdsSecretConfig& config, + Secret::SecretManager& secret_manager, + Server::Configuration::TransportSocketFactoryContext& transport_socket_factory) { + if (config.has_sds_config()) { + return secret_manager.findOrCreateGenericSecretProvider(config.sds_config(), config.name(), + transport_socket_factory); + } else { + return secret_manager.findStaticGenericSecretProvider(config.name()); + } +} +} // namespace + +Http::FilterFactoryCb FilterFactory::createFilterFactoryFromProtoTyped( + const envoy::extensions::filters::http::sxg::v3alpha::SXG& proto_config, + const std::string& stat_prefix, Server::Configuration::FactoryContext& context) { + const auto& certificate = proto_config.certificate(); + const auto& private_key = proto_config.private_key(); + + auto& cluster_manager = context.clusterManager(); + auto& secret_manager = cluster_manager.clusterManagerFactory().secretManager(); + auto& transport_socket_factory = context.getTransportSocketFactoryContext(); + auto secret_provider_certificate = + secretsProvider(certificate, secret_manager, transport_socket_factory); + if (secret_provider_certificate == nullptr) { + throw EnvoyException("invalid certificate secret configuration"); + } + auto secret_provider_private_key = + secretsProvider(private_key, secret_manager, transport_socket_factory); + if (secret_provider_private_key == nullptr) { + throw EnvoyException("invalid private_key secret configuration"); + } + + auto secret_reader = std::make_shared( + secret_provider_certificate, secret_provider_private_key, context.api()); + auto config = std::make_shared(proto_config, context.timeSource(), secret_reader, + stat_prefix, context.scope()); + return [config](Http::FilterChainFactoryCallbacks& callbacks) -> void { + const EncoderPtr encoder = std::make_unique(config); + callbacks.addStreamFilter(std::make_shared(config, encoder)); + }; +} + +REGISTER_FACTORY(FilterFactory, Server::Configuration::NamedHttpFilterConfigFactory); + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/source/config.h b/contrib/sxg/filters/http/source/config.h new file mode 100644 index 0000000000000..1d71843641c18 --- /dev/null +++ b/contrib/sxg/filters/http/source/config.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "source/extensions/filters/http/common/factory_base.h" + +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.h" +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.validate.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +class FilterFactory : public Extensions::HttpFilters::Common::FactoryBase< + envoy::extensions::filters::http::sxg::v3alpha::SXG> { +public: + FilterFactory() : FactoryBase("envoy.filters.http.sxg") {} + +private: + Http::FilterFactoryCb createFilterFactoryFromProtoTyped( + const envoy::extensions::filters::http::sxg::v3alpha::SXG& config, + const std::string& stats_prefix, Server::Configuration::FactoryContext& context) override; +}; + +DECLARE_FACTORY(FilterFactory); + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/source/encoder.cc b/contrib/sxg/filters/http/source/encoder.cc new file mode 100644 index 0000000000000..924ea3dfebc39 --- /dev/null +++ b/contrib/sxg/filters/http/source/encoder.cc @@ -0,0 +1,243 @@ +#include "contrib/sxg/filters/http/source/encoder.h" + +#include +#include +#include +#include + +#include + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/http/headers.h" + +#include "absl/strings/escaping.h" +#include "contrib/sxg/filters/http/source/filter_config.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +EncoderImpl::~EncoderImpl() { + sxg_header_release(&headers_); + sxg_raw_response_release(&raw_response_); + sxg_signer_list_release(&signer_list_); + sxg_encoded_response_release(&encoded_response_); +} + +void EncoderImpl::setOrigin(const std::string origin) { origin_ = origin; }; + +void EncoderImpl::setUrl(const std::string url) { url_ = url; }; + +bool EncoderImpl::loadHeaders(Http::ResponseHeaderMap* headers) { + const auto& filtered_headers = filteredResponseHeaders(); + bool retval = true; + headers->iterate([this, filtered_headers, + &retval](const Http::HeaderEntry& header) -> Http::HeaderMap::Iterate { + const auto& header_key = header.key().getStringView(); + + // filter x-envoy-* headers + if (absl::StartsWith(header_key, ThreadSafeSingleton::get().prefix())) { + return Http::HeaderMap::Iterate::Continue; + } + // filter out the header that we use as a flag to trigger encoding + if (config_->shouldEncodeSXGHeader().get() == header_key) { + return Http::HeaderMap::Iterate::Continue; + } + // filter out other headers by prefix + for (const auto& prefix_filter : config_->headerPrefixFilters()) { + if (absl::StartsWith(header_key, prefix_filter)) { + return Http::HeaderMap::Iterate::Continue; + } + } + // filter out headers that are not allowed to be encoded in the SXG document + if (filtered_headers.find(header_key) != filtered_headers.end()) { + return Http::HeaderMap::Iterate::Continue; + } + + const auto header_value = header.value().getStringView(); + if (!sxg_header_append_string(std::string(header_key).c_str(), + std::string(header_value).c_str(), &headers_)) { + retval = false; + return Http::HeaderMap::Iterate::Break; + } + return Http::HeaderMap::Iterate::Continue; + }); + + return retval; +} + +bool EncoderImpl::loadContent(Buffer::Instance& data) { + const size_t size = data.length(); + if (!sxg_buffer_resize(size, &raw_response_.payload)) { + return false; + } + data.copyOut(0, size, raw_response_.payload.data); + + return true; +} + +constexpr uint64_t ONE_DAY_IN_SECONDS = 86400L; + +bool EncoderImpl::loadSigner() { + // backdate timestamp by 1 day, to account for clock skew + const uint64_t date = getTimestamp() - ONE_DAY_IN_SECONDS; + + const uint64_t expires = date + static_cast(config_->duration()); + const auto validity_url = getValidityUrl(); + + X509* cert = loadX09Cert(); + const auto cert_digest = generateCertDigest(cert); + const auto cbor_url = getCborUrl(cert_digest); + + EVP_PKEY* pri_key = loadPrivateKey(); + + const auto retval = + cert && pri_key && + sxg_add_ecdsa_signer(sxgSigLabel().c_str(), date, expires, validity_url.c_str(), pri_key, + cert, cbor_url.c_str(), &signer_list_); + + if (cert) { + X509_free(cert); + } + if (pri_key) { + EVP_PKEY_free(pri_key); + } + return retval; +} + +bool EncoderImpl::getEncodedResponse() { + // Pass response headers to the response before encoding + if (!sxg_header_copy(&headers_, &raw_response_.header)) { + return false; + } + if (!sxg_encode_response(config_->miRecordSize(), &raw_response_, &encoded_response_)) { + return false; + } + return true; +} + +Buffer::BufferFragment* EncoderImpl::writeSxg() { + sxg_buffer_t result = sxg_empty_buffer(); + if (!sxg_generate(url_.c_str(), &signer_list_, &encoded_response_, &result)) { + sxg_buffer_release(&result); + return nullptr; + } + + return new Buffer::BufferFragmentImpl( + result.data, result.size, + [result](const void*, size_t, const Buffer::BufferFragmentImpl* this_fragment) { + // Capture of result by value passes a const, but sxg_buffer_release does not accept + // a const buffer_t*, so we have to cast it back. This is OK since the important + // operation performed by sxg_buffer_release is to release the data buffer. + sxg_buffer_release(const_cast(&result)); + delete this_fragment; + }); +} + +uint64_t EncoderImpl::getTimestamp() { + const auto now = config_->timeSource().systemTime(); + const auto ts = std::abs(static_cast( + std::chrono::duration_cast(now.time_since_epoch()).count())); + + return ts; +} + +const std::string EncoderImpl::toAbsolute(const std::string& url_or_relative_path) const { + if (!url_or_relative_path.empty() && url_or_relative_path[0] == '/') { + return origin_ + url_or_relative_path; + } else { + return url_or_relative_path; + } +} + +const std::string EncoderImpl::getValidityUrl() const { return toAbsolute(config_->validityUrl()); } + +const std::string EncoderImpl::getCborUrl(const std::string& cert_digest) const { + return fmt::format("{}?d={}", toAbsolute(config_->cborUrl()), cert_digest); +} + +X509* EncoderImpl::loadX09Cert() { + X509* cert = nullptr; + BIO* bio = BIO_new(BIO_s_mem()); + RELEASE_ASSERT(bio != nullptr, ""); + + if (BIO_puts(bio, config_->certificate().c_str()) >= 0) { + cert = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr); + } + + BIO_vfree(bio); + return cert; +} + +EVP_PKEY* EncoderImpl::loadPrivateKey() { + EVP_PKEY* pri_key = nullptr; + BIO* bio = BIO_new(BIO_s_mem()); + RELEASE_ASSERT(bio != nullptr, ""); + + if (BIO_puts(bio, config_->privateKey().c_str()) >= 0) { + pri_key = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr); + } + + BIO_vfree(bio); + return pri_key; +} + +const uint8_t CERT_DIGEST_BYTES = 8; + +const std::string EncoderImpl::generateCertDigest(X509* cert) const { + uint8_t out[EVP_MAX_MD_SIZE]; + unsigned out_len; + if (!(X509_digest(cert, EVP_sha256(), out, &out_len) && out_len >= CERT_DIGEST_BYTES)) { + return ""; + } + + return absl::BytesToHexString( + absl::string_view(reinterpret_cast(out), CERT_DIGEST_BYTES)); +} + +const std::string& EncoderImpl::sxgSigLabel() const { + // this is currently ignored, so an arbitrary string is safe to use + CONSTRUCT_ON_FIRST_USE(std::string, "label"); +} + +const EncoderImpl::HeaderFilterSet& EncoderImpl::filteredResponseHeaders() const { + CONSTRUCT_ON_FIRST_USE( + HeaderFilterSet, + { + // handled by libsxg, or explicitly by this filter + ":status", + // hop-by-hop headers, see: + // https://tools.ietf.org/id/draft-yasskin-http-origin-signed-responses-05.html#uncached-headers + "connection", + "keep-alive", + "proxy-connection", + "trailer", + "transfer-encoding", + "upgrade", + // Stateful headers, see: + // https://tools.ietf.org/id/draft-yasskin-http-origin-signed-responses-05.html#stateful-headers + // and blocked in http://crrev.com/c/958945. + "authentication-control", + "authentication-info", + "clear-site-data", + "optional-www-authenticate", + "proxy-authenticate", + "proxy-authentication-info", + "public-key-pins", + "sec-websocket-accept", + "set-cookie", + "set-cookie2", + "setprofile", + "strict-transport-security", + "www-authenticate", + // other stateful headers + "vary", + "cache-control", + }); +} + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/source/encoder.h b/contrib/sxg/filters/http/source/encoder.h new file mode 100644 index 0000000000000..a43aeff243aeb --- /dev/null +++ b/contrib/sxg/filters/http/source/encoder.h @@ -0,0 +1,83 @@ +#pragma once + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/config/datasource.h" + +#include "contrib/sxg/filters/http/source/filter_config.h" +#include "libsxg.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +/** + * Helper type to facilitate comparing an absl::string_view key to a std::string. + */ +struct StringCmp { + using IsTransparent = void; + bool operator()(absl::string_view a, absl::string_view b) const { return a < b; } +}; + +class Encoder { +public: + virtual ~Encoder() = default; + + virtual void setOrigin(const std::string origin) PURE; + virtual void setUrl(const std::string url) PURE; + virtual bool loadSigner() PURE; + virtual bool loadHeaders(Http::ResponseHeaderMap* headers) PURE; + virtual bool loadContent(Buffer::Instance& data) PURE; + virtual bool getEncodedResponse() PURE; + virtual Buffer::BufferFragment* writeSxg() PURE; +}; + +using EncoderPtr = std::unique_ptr; + +class EncoderImpl : public Encoder, Logger::Loggable { +public: + explicit EncoderImpl(const FilterConfigSharedPtr& config) + : headers_(sxg_empty_header()), raw_response_(sxg_empty_raw_response()), + signer_list_(sxg_empty_signer_list()), encoded_response_(sxg_empty_encoded_response()), + config_(config) {} + + ~EncoderImpl() override; + + // Filter::Encoder + void setOrigin(const std::string origin) override; + void setUrl(const std::string url) override; + bool loadHeaders(Http::ResponseHeaderMap* headers) override; + bool loadSigner() override; + bool loadContent(Buffer::Instance& data) override; + bool getEncodedResponse() override; + Buffer::BufferFragment* writeSxg() override; + +private: + friend class EncoderTest; + + sxg_header_t headers_; + sxg_raw_response_t raw_response_; + sxg_signer_list_t signer_list_; + sxg_encoded_response_t encoded_response_; + FilterConfigSharedPtr config_; + std::string origin_; + std::string url_; + + uint64_t getTimestamp(); + const std::string toAbsolute(const std::string& url_or_relative_path) const; + const std::string getCborUrl(const std::string& cert_digest) const; + const std::string getValidityUrl() const; + + X509* loadX09Cert(); + EVP_PKEY* loadPrivateKey(); + const std::string& sxgSigLabel() const; + const std::string generateCertDigest(X509* cert) const; + + using HeaderFilterSet = std::set; + const HeaderFilterSet& filteredResponseHeaders() const; +}; + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/source/filter.cc b/contrib/sxg/filters/http/source/filter.cc new file mode 100644 index 0000000000000..7dc0d36d31520 --- /dev/null +++ b/contrib/sxg/filters/http/source/filter.cc @@ -0,0 +1,231 @@ +#include "contrib/sxg/filters/http/source/filter.h" + +#include + +#include "envoy/http/codes.h" +#include "envoy/stats/scope.h" + +#include "source/common/common/utility.h" +#include "source/common/http/headers.h" +#include "source/common/stats/utility.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +Http::RegisterCustomInlineHeader + accept_handle(Http::CustomHeaders::get().Accept); + +Http::FilterHeadersStatus Filter::decodeHeaders(Http::RequestHeaderMap& headers, bool) { + ENVOY_LOG(debug, "sxg filter from decodeHeaders: {}", headers); + if (headers.Host() && headers.Path() && clientAcceptSXG(headers)) { + client_accept_sxg_ = true; + headers.setReference(xCanAcceptSxgKey(), xCanAcceptSxgValue()); + auto origin = fmt::format("https://{}", headers.getHostValue()); + auto url = fmt::format("{}{}", origin, headers.getPathValue()); + encoder_->setOrigin(origin); + encoder_->setUrl(url); + config_->stats().total_client_can_accept_sxg_.inc(); + } + return Http::FilterHeadersStatus::Continue; +} + +void Filter::setDecoderFilterCallbacks(Http::StreamDecoderFilterCallbacks& callbacks) { + decoder_callbacks_ = &callbacks; +} + +Http::FilterHeadersStatus Filter::encodeHeaders(Http::ResponseHeaderMap& headers, bool) { + ENVOY_LOG(debug, "sxg filter from Filter::encodeHeaders"); + + if (client_accept_sxg_ && shouldEncodeSXG(headers)) { + response_headers_ = &headers; + should_encode_sxg_ = true; + config_->stats().total_should_sign_.inc(); + return Http::FilterHeadersStatus::StopIteration; + } + + return Http::FilterHeadersStatus::Continue; +} + +Http::FilterDataStatus Filter::encodeData(Buffer::Instance& data, bool end_stream) { + ENVOY_LOG(debug, "sxg filter from encodeData end_stream: {}", end_stream); + + if (!should_encode_sxg_) { + return Http::FilterDataStatus::Continue; + } + + data_total_ += data.length(); + if (encoderBufferLimitReached(data_total_)) { + should_encode_sxg_ = false; + return Http::FilterDataStatus::Continue; + } + + encoder_callbacks_->addEncodedData(data, false); + + if (!end_stream) { + // We need to know the size of the response in order to generate the SXG, so we wait. + return Http::FilterDataStatus::StopIterationAndBuffer; + } + + doSxg(); + return Http::FilterDataStatus::Continue; +} + +Http::FilterTrailersStatus Filter::encodeTrailers(Http::ResponseTrailerMap&) { + if (should_encode_sxg_) { + doSxg(); + } + return Http::FilterTrailersStatus::Continue; +} + +void Filter::doSxg() { + if (finished_) { + return; + } + + finished_ = true; + + encoder_callbacks_->modifyEncodingBuffer([this](Buffer::Instance& enc_buf) { + config_->stats().total_signed_attempts_.inc(); + + if (!encoder_->loadHeaders(response_headers_)) { + config_->stats().total_signed_failed_.inc(); + return; + } + + if (!encoder_->loadContent(enc_buf)) { + config_->stats().total_signed_failed_.inc(); + return; + } + + if (!encoder_->getEncodedResponse()) { + config_->stats().total_signed_failed_.inc(); + return; + } + + if (!encoder_->loadSigner()) { + config_->stats().total_signed_failed_.inc(); + return; + } + + auto output = encoder_->writeSxg(); + if (!output) { + config_->stats().total_signed_failed_.inc(); + return; + } + + // Make sure that the resulting SXG isn't too big before adding it to the encoding + // buffer. Note that since the buffer fragment hasn't been added to the enc_buf + // yet, we need to call done() directly. + if (encoderBufferLimitReached(output->size() + 100)) { + output->done(); + config_->stats().total_signed_failed_.inc(); + return; + } + + enc_buf.drain(enc_buf.length()); + enc_buf.addBufferFragment(*output); + + response_headers_->setContentLength(enc_buf.length()); + response_headers_->setContentType(sxgContentType()); + + config_->stats().total_signed_succeeded_.inc(); + }); +} + +void Filter::setEncoderFilterCallbacks(Http::StreamEncoderFilterCallbacks& callbacks) { + encoder_callbacks_ = &callbacks; +} + +bool Filter::clientAcceptSXG(const Http::RequestHeaderMap& headers) { + const absl::string_view accept = headers.getInlineValue(accept_handle.handle()); + + absl::string_view html_q_value = "0"; + absl::string_view sxg_q_value = ""; + // Client can accept signed exchange if accept header has: + // a) application/signed-exchange + // b) with appropriate version (v=b3) + // c) q-value of signed exchange is >= that of text/html + // from: https://web.dev/signed-exchanges/#best-practices + for (const auto& token : StringUtil::splitToken(accept, ",")) { + const auto& type = StringUtil::trim(StringUtil::cropRight(token, ";")); + absl::string_view q_value = "1"; + absl::string_view version = ""; + + const auto params = StringUtil::cropLeft(token, ";"); + for (const auto& param : StringUtil::splitToken(params, ";")) { + if (absl::EqualsIgnoreCase("q", StringUtil::trim(StringUtil::cropRight(param, "=")))) { + q_value = StringUtil::trim(StringUtil::cropLeft(param, "=")); + } + if (absl::EqualsIgnoreCase("v", StringUtil::trim(StringUtil::cropRight(param, "=")))) { + version = StringUtil::trim(StringUtil::cropLeft(param, "=")); + } + } + + if (type == sxgContentTypeUnversioned() && version == acceptedSxgVersion()) { + sxg_q_value = q_value; + } else if (type == htmlContentType()) { + html_q_value = q_value; + } + } + + return sxg_q_value.compare(html_q_value) >= 0; +} + +bool Filter::shouldEncodeSXG(const Http::ResponseHeaderMap& headers) { + if (!(headers.Status() && headers.getStatusValue() == "200")) { + return false; + } + + const auto x_should_encode_sxg_header = headers.get(xShouldEncodeSxgKey()); + return !x_should_encode_sxg_header.empty(); +} + +bool Filter::encoderBufferLimitReached(uint64_t buffer_length) { + const auto limit = encoder_callbacks_->encoderBufferLimit(); + const auto header_size = response_headers_->byteSize(); + + ENVOY_LOG(debug, + "Envoy::Extensions::HttpFilters::SXG::Filter::encoderBufferLimitReached limit: {}, " + "header_size: {} buffer_length: {}", + limit, header_size, buffer_length); + + // note that a value of 0 indicates that no limits are enforced + if (limit && header_size + buffer_length > limit) { + config_->stats().total_exceeded_max_payload_size_.inc(); + return true; + } + return false; +} + +const Http::LowerCaseString& Filter::xCanAcceptSxgKey() const { + return config_->clientCanAcceptSXGHeader(); +} + +const std::string& Filter::xCanAcceptSxgValue() const { + CONSTRUCT_ON_FIRST_USE(std::string, "true"); +} + +const Http::LowerCaseString& Filter::xShouldEncodeSxgKey() const { + return config_->shouldEncodeSXGHeader(); +} + +const std::string& Filter::htmlContentType() const { + CONSTRUCT_ON_FIRST_USE(std::string, "text/html"); +} + +const std::string& Filter::sxgContentTypeUnversioned() const { + CONSTRUCT_ON_FIRST_USE(std::string, "application/signed-exchange"); +} + +const std::string& Filter::acceptedSxgVersion() const { CONSTRUCT_ON_FIRST_USE(std::string, "b3"); } + +const std::string& Filter::sxgContentType() const { + CONSTRUCT_ON_FIRST_USE(std::string, "application/signed-exchange;v=b3"); +} + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/source/filter.h b/contrib/sxg/filters/http/source/filter.h new file mode 100644 index 0000000000000..c58c7e66cee6a --- /dev/null +++ b/contrib/sxg/filters/http/source/filter.h @@ -0,0 +1,72 @@ +#pragma once + +#include "envoy/stats/scope.h" + +#include "source/extensions/filters/http/common/pass_through_filter.h" + +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.h" +#include "contrib/sxg/filters/http/source/encoder.h" +#include "contrib/sxg/filters/http/source/filter_config.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +/** + * Transaction flow: + * 1. check accept request header for whether client can accept sxg + * 2. check response headers for flag to indicate whether downstream wants SXG encoding + * 3. if both true, buffer response body until stream end and then run through the libsxg encoder + * thingy + * + */ +class Filter : public Http::PassThroughFilter, Logger::Loggable { +public: + Filter(const FilterConfigSharedPtr& config, const EncoderPtr& encoder) + : config_(config), encoder_(encoder) {} + + // Http::StreamDecoderFilter + Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap&, bool end_stream) override; + void setDecoderFilterCallbacks(Http::StreamDecoderFilterCallbacks&) override; + + // Http::StreamEncodeFilter + Http::FilterHeadersStatus encodeHeaders(Http::ResponseHeaderMap&, bool end_stream) override; + Http::FilterDataStatus encodeData(Buffer::Instance& data, bool end_stream) override; + Http::FilterTrailersStatus encodeTrailers(Http::ResponseTrailerMap&) override; + void setEncoderFilterCallbacks(Http::StreamEncoderFilterCallbacks& callbacks) override; + +private: + friend class FilterTest; + + bool client_accept_sxg_{false}; + bool should_encode_sxg_{false}; + std::shared_ptr config_; + Http::ResponseHeaderMap* response_headers_; + uint64_t data_total_{0}; + bool finished_{false}; + const EncoderPtr& encoder_; + + Http::StreamDecoderFilterCallbacks* decoder_callbacks_; + Http::StreamEncoderFilterCallbacks* encoder_callbacks_; + + void doSxg(); + + const absl::string_view urlStripQueryFragment(absl::string_view path) const; + + bool clientAcceptSXG(const Http::RequestHeaderMap& headers); + bool shouldEncodeSXG(const Http::ResponseHeaderMap& headers); + bool encoderBufferLimitReached(uint64_t buffer_length); + const Http::LowerCaseString& xCanAcceptSxgKey() const; + const std::string& xCanAcceptSxgValue() const; + const Http::LowerCaseString& xShouldEncodeSxgKey() const; + const std::string& htmlContentType() const; + const std::string& sxgContentTypeUnversioned() const; + const std::string& acceptedSxgVersion() const; + const std::string& sxgContentType() const; +}; + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/source/filter_config.cc b/contrib/sxg/filters/http/source/filter_config.cc new file mode 100644 index 0000000000000..8d0fb33600175 --- /dev/null +++ b/contrib/sxg/filters/http/source/filter_config.cc @@ -0,0 +1,49 @@ +#include "contrib/sxg/filters/http/source/filter_config.h" + +#include + +#include "envoy/http/codes.h" +#include "envoy/server/filter_config.h" +#include "envoy/stats/scope.h" + +#include "source/common/common/utility.h" +#include "source/common/http/headers.h" +#include "source/common/stats/utility.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +template +const std::vector initializeHeaderPrefixFilters(const T& filters_proto) { + std::vector filters; + filters.reserve(filters_proto.size()); + + for (const auto& filter : filters_proto) { + filters.emplace_back(filter); + } + + return filters; +} + +FilterConfig::FilterConfig(const envoy::extensions::filters::http::sxg::v3alpha::SXG& proto_config, + TimeSource& time_source, std::shared_ptr secret_reader, + const std::string& stat_prefix, Stats::Scope& scope) + : stats_(generateStats(stat_prefix + "sxg.", scope)), + duration_(proto_config.has_duration() ? proto_config.duration().seconds() : 604800UL), + cbor_url_(proto_config.cbor_url()), validity_url_(proto_config.validity_url()), + mi_record_size_(proto_config.mi_record_size() ? proto_config.mi_record_size() : 4096L), + client_can_accept_sxg_header_(proto_config.client_can_accept_sxg_header().length() > 0 + ? proto_config.client_can_accept_sxg_header() + : "x-client-can-accept-sxg"), + should_encode_sxg_header_(proto_config.should_encode_sxg_header().length() > 0 + ? proto_config.should_encode_sxg_header() + : "x-should-encode-sxg"), + header_prefix_filters_(initializeHeaderPrefixFilters(proto_config.header_prefix_filters())), + time_source_(time_source), secret_reader_(secret_reader) {} + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/source/filter_config.h b/contrib/sxg/filters/http/source/filter_config.h new file mode 100644 index 0000000000000..11532d2e21454 --- /dev/null +++ b/contrib/sxg/filters/http/source/filter_config.h @@ -0,0 +1,120 @@ +#pragma once + +#include "envoy/server/filter_config.h" +#include "envoy/stats/scope.h" +#include "envoy/stats/stats_macros.h" + +#include "source/common/config/datasource.h" +#include "source/extensions/filters/http/common/pass_through_filter.h" + +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.h" +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.validate.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +#define ALL_SXG_STATS(COUNTER) \ + COUNTER(total_client_can_accept_sxg) \ + COUNTER(total_should_sign) \ + COUNTER(total_exceeded_max_payload_size) \ + COUNTER(total_signed_attempts) \ + COUNTER(total_signed_succeeded) \ + COUNTER(total_signed_failed) + +struct SignedExchangeStats { + ALL_SXG_STATS(GENERATE_COUNTER_STRUCT) +}; + +// Helper class used to fetch secrets (usually from SDS). +class SecretReader { +public: + virtual ~SecretReader() = default; + virtual const std::string& certificate() const PURE; + virtual const std::string& privateKey() const PURE; +}; + +class SDSSecretReader : public SecretReader { +public: + SDSSecretReader(Secret::GenericSecretConfigProviderSharedPtr certificate_provider, + Secret::GenericSecretConfigProviderSharedPtr private_key_provider, Api::Api& api) + : update_callback_client_(readAndWatchSecret(certificate_, certificate_provider, api)), + update_callback_token_(readAndWatchSecret(private_key_, private_key_provider, api)) {} + + // SecretReader + const std::string& certificate() const override { return certificate_; } + const std::string& privateKey() const override { return private_key_; } + +private: + Envoy::Common::CallbackHandlePtr + readAndWatchSecret(std::string& value, + Secret::GenericSecretConfigProviderSharedPtr& secret_provider, Api::Api& api) { + const auto* secret = secret_provider->secret(); + if (secret != nullptr) { + value = Config::DataSource::read(secret->secret(), true, api); + } + + return secret_provider->addUpdateCallback([secret_provider, &api, &value]() { + const auto* secret = secret_provider->secret(); + if (secret != nullptr) { + value = Config::DataSource::read(secret->secret(), true, api); + } + }); + } + + std::string certificate_; + std::string private_key_; + + Envoy::Common::CallbackHandlePtr update_callback_client_; + Envoy::Common::CallbackHandlePtr update_callback_token_; +}; + +class FilterConfig : public Logger::Loggable { +public: + FilterConfig(const envoy::extensions::filters::http::sxg::v3alpha::SXG& proto_config, + TimeSource& time_source, std::shared_ptr secret_reader, + const std::string& stat_prefix, Stats::Scope&); + ~FilterConfig() = default; + + const SignedExchangeStats stats() { return stats_; }; + + long duration() const { return duration_; }; + long miRecordSize() const { return mi_record_size_; }; + const std::string& cborUrl() const { return cbor_url_; }; + const std::string& validityUrl() const { return validity_url_; }; + TimeSource& timeSource() { return time_source_; }; + const Http::LowerCaseString& clientCanAcceptSXGHeader() { return client_can_accept_sxg_header_; } + const Http::LowerCaseString& shouldEncodeSXGHeader() { return should_encode_sxg_header_; } + const std::vector& headerPrefixFilters() { return header_prefix_filters_; } + + const std::string& certificate() const { return secret_reader_->certificate(); } + const std::string& privateKey() const { return secret_reader_->privateKey(); } + +private: + static SignedExchangeStats generateStats(const std::string& prefix, Stats::Scope& scope) { + return SignedExchangeStats{ALL_SXG_STATS(POOL_COUNTER_PREFIX(scope, prefix))}; + } + + SignedExchangeStats stats_; + + const long duration_; + const std::string cbor_url_; + const std::string validity_url_; + const long mi_record_size_; + const Http::LowerCaseString client_can_accept_sxg_header_; + const Http::LowerCaseString should_encode_sxg_header_; + const std::vector header_prefix_filters_; + + TimeSource& time_source_; + const std::shared_ptr secret_reader_; + const std::string certificate_identifier_; + const std::string private_key_identifier_; +}; + +using FilterConfigSharedPtr = std::shared_ptr; + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/test/BUILD b/contrib/sxg/filters/http/test/BUILD new file mode 100644 index 0000000000000..62df2d888c329 --- /dev/null +++ b/contrib/sxg/filters/http/test/BUILD @@ -0,0 +1,41 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_test", + "envoy_contrib_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_contrib_package() + +envoy_cc_test( + name = "filter_test", + srcs = ["filter_test.cc"], + tags = ["skip_on_windows"], + deps = [ + "//contrib/sxg/filters/http/source:config", + "//contrib/sxg/filters/http/source:sxg_lib", + "//source/common/secret:secret_manager_impl_lib", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "//test/integration:http_integration_lib", + "//test/mocks/server:server_mocks", + "//test/mocks/upstream:upstream_mocks", + "@envoy_api//contrib/envoy/extensions/filters/http/sxg/v3alpha:pkg_cc_proto", + ], +) + +envoy_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + tags = ["skip_on_windows"], + deps = [ + "//contrib/sxg/filters/http/source:config", + "//contrib/sxg/filters/http/source:sxg_lib", + "//source/common/secret:secret_manager_impl_lib", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "//test/integration:http_integration_lib", + "//test/mocks/server:server_mocks", + "//test/mocks/upstream:upstream_mocks", + "@envoy_api//contrib/envoy/extensions/filters/http/sxg/v3alpha:pkg_cc_proto", + ], +) diff --git a/contrib/sxg/filters/http/test/config_test.cc b/contrib/sxg/filters/http/test/config_test.cc new file mode 100644 index 0000000000000..1fa1fbd354c37 --- /dev/null +++ b/contrib/sxg/filters/http/test/config_test.cc @@ -0,0 +1,122 @@ +#include +#include + +#include "source/common/protobuf/message_validator_impl.h" +#include "source/common/protobuf/utility.h" +#include "source/common/secret/secret_provider_impl.h" + +#include "test/mocks/server/factory_context.h" + +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.h" +#include "contrib/sxg/filters/http/source/config.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +using testing::NiceMock; +using testing::Return; + +namespace { + +void expectCreateFilter(std::string yaml, bool is_sds_config) { + FilterFactory factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyConfigProto(); + TestUtility::loadFromYaml(yaml, *proto_config); + Server::Configuration::MockFactoryContext context; + context.cluster_manager_.initializeClusters({"foo"}, {}); + + // This returns non-nullptr for certificate and private_key. + auto& secret_manager = context.cluster_manager_.cluster_manager_factory_.secretManager(); + if (is_sds_config) { + ON_CALL(secret_manager, findOrCreateGenericSecretProvider(_, _, _)) + .WillByDefault(Return(std::make_shared( + envoy::extensions::transport_sockets::tls::v3::GenericSecret()))); + } else { + ON_CALL(secret_manager, findStaticGenericSecretProvider(_)) + .WillByDefault(Return(std::make_shared( + envoy::extensions::transport_sockets::tls::v3::GenericSecret()))); + } + EXPECT_CALL(context, messageValidationVisitor()); + EXPECT_CALL(context, clusterManager()); + EXPECT_CALL(context, scope()); + EXPECT_CALL(context, timeSource()); + EXPECT_CALL(context, api()); + EXPECT_CALL(context, getTransportSocketFactoryContext()); + Http::FilterFactoryCb cb = factory.createFilterFactoryFromProto(*proto_config, "stats", context); + Http::MockFilterChainFactoryCallbacks filter_callback; + EXPECT_CALL(filter_callback, addStreamFilter(_)); + cb(filter_callback); +} + +// This loads one of the secrets in credentials, and fails the other one. +void expectInvalidSecretConfig(const std::string& failed_secret_name, + const std::string& exception_message) { + const std::string yaml = R"YAML( +certificate: + name: certificate +private_key: + name: private_key +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +)YAML"; + + FilterFactory factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyConfigProto(); + TestUtility::loadFromYaml(yaml, *proto_config); + NiceMock context; + + auto& secret_manager = context.cluster_manager_.cluster_manager_factory_.secretManager(); + ON_CALL(secret_manager, findStaticGenericSecretProvider( + failed_secret_name == "private_key" ? "certificate" : "private_key")) + .WillByDefault(Return(std::make_shared( + envoy::extensions::transport_sockets::tls::v3::GenericSecret()))); + + EXPECT_THROW_WITH_MESSAGE(factory.createFilterFactoryFromProto(*proto_config, "stats", context), + EnvoyException, exception_message); +} + +} // namespace + +TEST(ConfigTest, CreateFilterStaticSecretProvider) { + const std::string yaml = R"YAML( +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +)YAML"; + expectCreateFilter(yaml, false); +} + +TEST(ConfigTest, CreateFilterHasSdsSecret) { + const std::string yaml = R"YAML( +certificate: + name: certificate + sds_config: + path: "xxxx" + resource_api_version: V3 +private_key: + name: private_key + sds_config: + path: "xxxx" + resource_api_version: V3 +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +)YAML"; + + expectCreateFilter(yaml, true); +} + +TEST(ConfigTest, InvalidCertificateSecret) { + expectInvalidSecretConfig("certificate", "invalid certificate secret configuration"); +} + +TEST(ConfigTest, InvalidPrivateKeySecret) { + expectInvalidSecretConfig("private_key", "invalid private_key secret configuration"); +} + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/contrib/sxg/filters/http/test/filter_test.cc b/contrib/sxg/filters/http/test/filter_test.cc new file mode 100644 index 0000000000000..0ec8fedcdd5fe --- /dev/null +++ b/contrib/sxg/filters/http/test/filter_test.cc @@ -0,0 +1,1032 @@ +#include + +#include "envoy/stats/stats.h" + +#include "source/common/secret/secret_manager_impl.h" + +#include "test/mocks/http/mocks.h" +#include "test/mocks/server/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.h" +#include "contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.pb.validate.h" +#include "contrib/sxg/filters/http/source/filter.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace SXG { + +using testing::_; +using testing::NiceMock; +using testing::Return; +using testing::ReturnRef; + +class MockSecretReader : public SecretReader { +public: + MockSecretReader(const std::string& certificate, const std::string& private_key) + : certificate_(certificate), private_key_(private_key){}; + + const std::string& certificate() const override { return certificate_; } + const std::string& privateKey() const override { return private_key_; } + +private: + const std::string certificate_; + const std::string private_key_; +}; + +class MockEncoder : public Encoder { +public: + MOCK_METHOD(void, setOrigin, (const std::string), (override)); + MOCK_METHOD(void, setUrl, (const std::string), (override)); + MOCK_METHOD(bool, loadSigner, (), (override)); + MOCK_METHOD(bool, loadHeaders, (Http::ResponseHeaderMap*), (override)); + MOCK_METHOD(bool, loadContent, (Buffer::Instance&), (override)); + MOCK_METHOD(bool, getEncodedResponse, (), (override)); + MOCK_METHOD(Buffer::BufferFragment*, writeSxg, (), (override)); +}; + +int extractIntFromBytes(std::string bytes, size_t offset, size_t size) { + if (size <= 0 || size > 8 || bytes.size() < offset + size) { + return 0; + } + int value = 0; + for (size_t i = 0; i < size; i++) { + value <<= 8; + value |= (0xff & bytes[offset + i]); + } + return value; +} + +bool writeIntToBytes(std::string& bytes, uint64_t int_to_write, size_t offset, size_t size) { + if (size <= 0 || size > 8 || bytes.size() < offset + size) { + return false; + } + for (int i = size - 1; i >= 0; i--) { + char byte = 0xff & int_to_write; + bytes[offset + i] = byte; + int_to_write >>= 8; + } + return true; +} + +// The sig value of the SXG document is unique, so we strip it in tests +bool clearSignature(std::string& buffer) { + if (buffer.find("sxg1-b3", 0, 7) == std::string::npos) { + return false; + } + if (buffer[7] != '\0') { + return false; + } + + // The fallback URL length is contained in the 2 bytes following the sxg-b3 + // prefix string and the nullptr byte that follows. We need to know this length + // because the signature length is located after the fallback URL. + size_t fallback_url_size_offset = 8; + size_t fallback_url_size = extractIntFromBytes(buffer, fallback_url_size_offset, 2); + + // the signature length is contained in the 3 bytes following the fallback URL + size_t sig_size_offset = fallback_url_size_offset + 2 + fallback_url_size; + size_t sig_size = extractIntFromBytes(buffer, sig_size_offset, 3); + + const size_t sig_pos = buffer.find("sig=*"); + if (sig_pos == std::string::npos) { + return false; + } + + const size_t start = sig_pos + 5; + const size_t len = buffer.find('*', start) - start; + + // decrement the sig_size in the SXG document by the calculated length + const size_t modified_sig_size = sig_size - len; + if (!writeIntToBytes(buffer, modified_sig_size, sig_size_offset, 3)) { + return false; + } + + // replace the signature piece with empty string + buffer.erase(start, len); + + return true; +} + +class FilterTest : public testing::Test { +public: + FilterTest() = default; + + void setConfiguration() { + std::string config_str(R"YAML( +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +)YAML"); + setConfiguration(config_str); + } + + void setConfiguration(const std::string& config_str) { + std::string certificate(R"PEM( +-----BEGIN CERTIFICATE----- +MIIBhjCCASygAwIBAgIJAIH9REPqIFXTMAkGByqGSM49BAEwMjEUMBIGA1UEAwwL +ZXhhbXBsZS5vcmcxDTALBgNVBAoMBFRlc3QxCzAJBgNVBAYTAlVTMB4XDTIxMDEx +MzAxMDcwMVoXDTIxMDQxMzAxMDcwMVowMjEUMBIGA1UEAwwLZXhhbXBsZS5vcmcx +DTALBgNVBAoMBFRlc3QxCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE4ZrHsGLEiP+pV70a8zIERNcu9MBJHHfbeqLUqwGWWU2/YHObf58nE9to +c6lgrko2JdbV6TyWLVUc/M0Pn+OVSaMsMCowEAYKKwYBBAHWeQIBFgQCBQAwFgYD +VR0RBA8wDYILZXhhbXBsZS5vcmcwCQYHKoZIzj0EAQNJADBGAiEAuQJjX+z7j4hR +xtxfs4VPY5RsF5Sawd+mtluRxpoURcsCIQCIGU/11jcuS0UbIpt4B5Gb1UJlSKGi +Dgu+2OKt7qVPrA== +-----END CERTIFICATE----- +)PEM"); + std::string private_key(R"PEM( +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJyGXecxIQtBwBJWU4Sc5A8UHNt5HnOBR9Oh11AGYa/2oAoGCCqGSM49 +AwEHoUQDQgAE4ZrHsGLEiP+pV70a8zIERNcu9MBJHHfbeqLUqwGWWU2/YHObf58n +E9toc6lgrko2JdbV6TyWLVUc/M0Pn+OVSQ== +-----END EC PRIVATE KEY----- +)PEM"); + + setConfiguration(config_str, certificate, private_key); + } + + void setConfiguration(const std::string& config_str, const std::string& certificate, + const std::string& private_key) { + envoy::extensions::filters::http::sxg::v3alpha::SXG proto; + TestUtility::loadFromYaml(config_str, proto); + + time_system_.setSystemTime(std::chrono::seconds(1610503040)); + + auto secret_reader = std::make_shared(certificate, private_key); + config_ = std::make_shared(proto, time_system_, secret_reader, "", scope_); + } + + void setFilter() { + if (encoder_ == nullptr) { + encoder_ = std::make_unique(config_); + } + setFilter(std::make_shared(config_, encoder_)); + } + + void setFilter(std::shared_ptr filter) { + filter_ = filter; + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + filter_->setEncoderFilterCallbacks(encoder_callbacks_); + } + + void testPassthroughHtml(Http::TestRequestHeaderMapImpl& request_headers, + Http::TestResponseHeaderMapImpl& response_headers, + bool client_can_accept_sxg) { + testPassthroughHtml(request_headers, response_headers, nullptr, client_can_accept_sxg); + } + + void testPassthroughHtml(Http::TestRequestHeaderMapImpl& request_headers, + Http::TestResponseHeaderMapImpl& response_headers, + Http::TestResponseTrailerMapImpl* response_trailers, + bool client_can_accept_sxg) { + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, false)); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->encodeHeaders(response_headers, false)); + Buffer::OwnedImpl data("hi!\n"); + + auto on_modify_encoding_buffer = [&data](std::function cb) { + cb(data); + }; + EXPECT_CALL(encoder_callbacks_, modifyEncodingBuffer) + .WillRepeatedly(Invoke(on_modify_encoding_buffer)); + + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->encodeData(data, true)); + if (response_trailers) { + EXPECT_EQ(Http::FilterTrailersStatus::Continue, filter_->encodeTrailers(*response_trailers)); + } + + EXPECT_EQ(response_headers.get(Http::LowerCaseString("content-type")).size(), 1); + EXPECT_EQ( + response_headers.get(Http::LowerCaseString("content-type"))[0]->value().getStringView(), + "text/html"); + EXPECT_EQ("hi!\n", data.toString()); + + const Envoy::Http::LowerCaseString x_client_can_accept_sxg_key("x-client-can-accept-sxg"); + if (client_can_accept_sxg) { + EXPECT_FALSE(request_headers.get(x_client_can_accept_sxg_key).empty()); + EXPECT_EQ("true", + request_headers.get(x_client_can_accept_sxg_key)[0]->value().getStringView()); + EXPECT_EQ(1UL, scope_.counter("sxg.total_client_can_accept_sxg").value()); + } else { + const Envoy::Http::LowerCaseString x_client_can_accept_sxg_key("x-client-can-accept-sxg"); + EXPECT_TRUE(request_headers.get(x_client_can_accept_sxg_key).empty()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_client_can_accept_sxg").value()); + } + EXPECT_EQ(0UL, scope_.counter("sxg.total_should_sign").value()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_exceeded_max_payload_size").value()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_signed_attempts").value()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_signed_succeeded").value()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_signed_failed").value()); + } + + void testFallbackToHtml(Http::TestRequestHeaderMapImpl& request_headers, + Http::TestResponseHeaderMapImpl& response_headers, + bool exceeded_max_payload_size, bool attempted_encode) { + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, false)); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->encodeHeaders(response_headers, false)); + Buffer::OwnedImpl data("hi!\n"); + + auto on_modify_encoding_buffer = [&data](std::function cb) { + cb(data); + }; + EXPECT_CALL(encoder_callbacks_, modifyEncodingBuffer) + .WillRepeatedly(Invoke(on_modify_encoding_buffer)); + + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->encodeData(data, true)); + EXPECT_EQ(response_headers.get(Http::LowerCaseString("content-type")).size(), 1); + EXPECT_EQ( + response_headers.get(Http::LowerCaseString("content-type"))[0]->value().getStringView(), + "text/html"); + EXPECT_EQ("hi!\n", data.toString()); + + const Envoy::Http::LowerCaseString x_client_can_accept_sxg_key("x-client-can-accept-sxg"); + EXPECT_FALSE(request_headers.get(x_client_can_accept_sxg_key).empty()); + EXPECT_EQ("true", request_headers.get(x_client_can_accept_sxg_key)[0]->value().getStringView()); + EXPECT_EQ(1UL, scope_.counter("sxg.total_client_can_accept_sxg").value()); + EXPECT_EQ(1UL, scope_.counter("sxg.total_should_sign").value()); + EXPECT_EQ(exceeded_max_payload_size ? 1UL : 0UL, + scope_.counter("sxg.total_exceeded_max_payload_size").value()); + EXPECT_EQ(attempted_encode ? 1UL : 0L, scope_.counter("sxg.total_signed_attempts").value()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_signed_succeeded").value()); + EXPECT_EQ(attempted_encode ? 1UL : 0UL, scope_.counter("sxg.total_signed_failed").value()); + } + + void testEncodeSignedExchange(Http::TestRequestHeaderMapImpl& request_headers, + Http::TestResponseHeaderMapImpl& response_headers) { + testEncodeSignedExchange(request_headers, response_headers, nullptr); + } + + void testEncodeSignedExchange(Http::TestRequestHeaderMapImpl& request_headers, + Http::TestResponseHeaderMapImpl& response_headers, + Http::TestResponseTrailerMapImpl* response_trailers) { + const Buffer::OwnedImpl sxg( + "sxg1-b3\0\0\x1Ehttps://example.org/hello.html\0\x1\0\0\0\x84" + "label;cert-sha256=*unJ3rwJT2DwWlJAw1lfVLvPjeYoJh0+QUQ97zJQPZtc=*;cert-url=\"https://" + "example.org/.sxg/" + "cert.cbor?d=ba7277af0253d83c\";date=1610416640;expires=1611021440;integrity=\"digest/" + "mi-sha256-03\";sig=**;validity-url=\"https://example.org/.sxg/" + "validity.msg\"\xA4" + "FdigestX9mi-sha256-03=0x0E2wkWVYOJ7Gq8+Kfaiyjo3gYCyaijhGGgkzjPoTo=G:statusC200Lcontent-" + "typeItext/htmlPcontent-encodingLmi-sha256-03\0\0\0\0\0\0\x10\0hi!\n", + 472); + testEncodeSignedExchange(request_headers, response_headers, response_trailers, sxg); + } + + void testEncodeSignedExchange(Http::TestRequestHeaderMapImpl& request_headers, + Http::TestResponseHeaderMapImpl& response_headers, + const Buffer::OwnedImpl& sxg) { + testEncodeSignedExchange(request_headers, response_headers, nullptr, sxg); + } + + void testEncodeSignedExchange(Http::TestRequestHeaderMapImpl& request_headers, + Http::TestResponseHeaderMapImpl& response_headers, + Http::TestResponseTrailerMapImpl* response_trailers, + const Buffer::OwnedImpl& sxg) { + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, false)); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->encodeHeaders(response_headers, false)); + + Buffer::OwnedImpl accumulated_data; + + EXPECT_CALL(encoder_callbacks_, addEncodedData(_, false)) + .Times(2) + .WillRepeatedly(Invoke( + [&accumulated_data](Buffer::Instance& data, bool) { accumulated_data.add(data); })); + + auto on_modify_encoding_buffer = + [&accumulated_data](std::function cb) { cb(accumulated_data); }; + EXPECT_CALL(encoder_callbacks_, modifyEncodingBuffer) + .WillRepeatedly(Invoke(on_modify_encoding_buffer)); + + Buffer::OwnedImpl chunk1("hi!", 15); + Buffer::OwnedImpl chunk2("\n", 15); + EXPECT_EQ(Http::FilterDataStatus::StopIterationAndBuffer, filter_->encodeData(chunk1, false)); + if (response_trailers) { + EXPECT_EQ(Http::FilterDataStatus::StopIterationAndBuffer, filter_->encodeData(chunk2, false)); + EXPECT_EQ(Http::FilterTrailersStatus::Continue, filter_->encodeTrailers(*response_trailers)); + } else { + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->encodeData(chunk2, true)); + } + + std::string result = accumulated_data.toString(); + EXPECT_TRUE(clearSignature(result)); + EXPECT_EQ(response_headers.get(Http::LowerCaseString("content-type")).size(), 1); + EXPECT_EQ( + response_headers.get(Http::LowerCaseString("content-type"))[0]->value().getStringView(), + "application/signed-exchange;v=b3"); + EXPECT_EQ(response_headers.get(Http::LowerCaseString("content-length")).size(), 1); + EXPECT_EQ( + response_headers.get(Http::LowerCaseString("content-length"))[0]->value().getStringView(), + std::to_string(accumulated_data.length())); + EXPECT_EQ(sxg.toString(), result); + + const Envoy::Http::LowerCaseString x_client_can_accept_sxg_key("x-client-can-accept-sxg"); + EXPECT_FALSE(request_headers.get(x_client_can_accept_sxg_key).empty()); + EXPECT_EQ("true", request_headers.get(x_client_can_accept_sxg_key)[0]->value().getStringView()); + EXPECT_EQ(1UL, scope_.counter("sxg.total_client_can_accept_sxg").value()); + EXPECT_EQ(1UL, scope_.counter("sxg.total_should_sign").value()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_exceeded_max_payload_size").value()); + EXPECT_EQ(1UL, scope_.counter("sxg.total_signed_attempts").value()); + EXPECT_EQ(1UL, scope_.counter("sxg.total_signed_succeeded").value()); + EXPECT_EQ(0UL, scope_.counter("sxg.total_signed_failed").value()); + } + + void callDoSxgAgain() { filter_->doSxg(); } + + Stats::TestUtil::TestStore scope_; + Event::SimulatedTimeSystem time_system_; + std::shared_ptr config_; + std::unique_ptr encoder_; + NiceMock decoder_callbacks_; + NiceMock encoder_callbacks_; + std::shared_ptr filter_; +}; + +// Verifies that the OAuth SDSSecretReader correctly updates dynamic generic secret. +TEST_F(FilterTest, SdsDynamicGenericSecret) { + NiceMock config_tracker; + Secret::SecretManagerImpl secret_manager{config_tracker}; + envoy::config::core::v3::ConfigSource config_source; + + NiceMock secret_context; + NiceMock local_info; + Api::ApiPtr api = Api::createApiForTest(); + Stats::IsolatedStoreImpl stats; + NiceMock init_manager; + Init::TargetHandlePtr init_handle; + NiceMock dispatcher; + EXPECT_CALL(secret_context, localInfo()).WillRepeatedly(ReturnRef(local_info)); + EXPECT_CALL(secret_context, api()).WillRepeatedly(ReturnRef(*api)); + EXPECT_CALL(secret_context, dispatcher()).WillRepeatedly(ReturnRef(dispatcher)); + EXPECT_CALL(secret_context, stats()).WillRepeatedly(ReturnRef(stats)); + EXPECT_CALL(secret_context, initManager()).WillRepeatedly(ReturnRef(init_manager)); + EXPECT_CALL(init_manager, add(_)) + .WillRepeatedly(Invoke([&init_handle](const Init::Target& target) { + init_handle = target.createHandle("test"); + })); + + auto certificate_secret_provider = secret_manager.findOrCreateGenericSecretProvider( + config_source, "certificate", secret_context); + auto certificate_callback = secret_context.cluster_manager_.subscription_factory_.callbacks_; + auto private_key_secret_provider = secret_manager.findOrCreateGenericSecretProvider( + config_source, "private_key", secret_context); + auto private_key_callback = secret_context.cluster_manager_.subscription_factory_.callbacks_; + + SDSSecretReader secret_reader(certificate_secret_provider, private_key_secret_provider, *api); + EXPECT_TRUE(secret_reader.certificate().empty()); + EXPECT_TRUE(secret_reader.privateKey().empty()); + + const std::string yaml_client = R"YAML( +name: certificate +generic_secret: + secret: + inline_string: "certificate_test" +)YAML"; + + envoy::extensions::transport_sockets::tls::v3::Secret typed_secret; + TestUtility::loadFromYaml(yaml_client, typed_secret); + const auto decoded_resources_client = TestUtility::decodeResources({typed_secret}); + + certificate_callback->onConfigUpdate(decoded_resources_client.refvec_, ""); + EXPECT_EQ(secret_reader.certificate(), "certificate_test"); + EXPECT_EQ(secret_reader.privateKey(), ""); + + const std::string yaml_token = R"YAML( +name: private_key +generic_secret: + secret: + inline_string: "private_key_test" +)YAML"; + TestUtility::loadFromYaml(yaml_token, typed_secret); + const auto decoded_resources_token = TestUtility::decodeResources({typed_secret}); + + private_key_callback->onConfigUpdate(decoded_resources_token.refvec_, ""); + EXPECT_EQ(secret_reader.certificate(), "certificate_test"); + EXPECT_EQ(secret_reader.privateKey(), "private_key_test"); + + const std::string yaml_client_recheck = R"EOF( +name: certificate +generic_secret: + secret: + inline_string: "certificate_test_recheck" +)EOF"; + TestUtility::loadFromYaml(yaml_client_recheck, typed_secret); + const auto decoded_resources_client_recheck = TestUtility::decodeResources({typed_secret}); + + certificate_callback->onConfigUpdate(decoded_resources_client_recheck.refvec_, ""); + EXPECT_EQ(secret_reader.certificate(), "certificate_test_recheck"); + EXPECT_EQ(secret_reader.privateKey(), "private_key_test"); +} + +TEST_F(FilterTest, NoHostHeader) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, AcceptTextHtml) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "text/html"}, {"host", "example.org"}, {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, HtmlWithTrailers) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "text/html"}, {"host", "example.org"}, {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{{"content-type", "text/html"}, + {":status", "200"}}; + Http::TestResponseTrailerMapImpl response_trailers{{"x-test-sample-trailer", "wait for me!"}}; + testPassthroughHtml(request_headers, response_headers, &response_trailers, false); +} + +TEST_F(FilterTest, NoPathHeader) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, NoAcceptHeader) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"host", "example.org"}, {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, NoStatusHeader) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{{"content-type", "text/html"}, + {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, true); +} + +TEST_F(FilterTest, Status404) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "404"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, true); +} + +TEST_F(FilterTest, XShouldEncodeNotSet) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{{"content-type", "text/html"}, + {":status", "200"}}; + testPassthroughHtml(request_headers, response_headers, true); +} + +TEST_F(FilterTest, AcceptTextHtmlWithQ) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "text/html;q=0.8"}, + {":protocol", "https"}, + {":host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, AcceptApplicationSignedExchangeNoVersion) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "application/signed-exchange"}, {"host", "example.org"}, {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, AcceptApplicationSignedExchangeWithVersionB2) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b2"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, AcceptApplicationSignedExchangeWithVersionB3) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testEncodeSignedExchange(request_headers, response_headers); +} + +TEST_F(FilterTest, AcceptApplicationSignedExchangeWithVersionB3WithQ) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "application/signed-exchange;v=b3;q=0.9"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testEncodeSignedExchange(request_headers, response_headers); +} + +TEST_F(FilterTest, AcceptMultipleTextHtml) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.8,text/html;q=0.9"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testPassthroughHtml(request_headers, response_headers, false); +} + +TEST_F(FilterTest, AcceptMultipleSignedExchange) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testEncodeSignedExchange(request_headers, response_headers); +} + +TEST_F(FilterTest, ResponseExceedsMaxPayloadSize) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + EXPECT_CALL(encoder_callbacks_, encoderBufferLimit).WillRepeatedly(Return(10)); + testFallbackToHtml(request_headers, response_headers, true, false); +} + +TEST_F(FilterTest, ResponseExceedsMaxPayloadSizeEncodeFail) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + EXPECT_CALL(encoder_callbacks_, encoderBufferLimit) + .WillOnce(Return(100000)) + .WillRepeatedly(Return(10)); + testFallbackToHtml(request_headers, response_headers, true, true); +} + +TEST_F(FilterTest, UrlWithQueryParam) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "application/signed-exchange;v=b3;q=0.9"}, + {"host", "example.org"}, + {":path", "/hello.html?good=bye"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + const Buffer::OwnedImpl expected_sxg( + "sxg1-b3\0\0\x27https://example.org/hello.html?good=bye\0\x1\0\0\0\x84" + "label;cert-sha256=*unJ3rwJT2DwWlJAw1lfVLvPjeYoJh0+QUQ97zJQPZtc=*;cert-url=\"https://" + "example.org/.sxg/" + "cert.cbor?d=ba7277af0253d83c\";date=1610416640;expires=1611021440;integrity=\"digest/" + "mi-sha256-03\";sig=**;validity-url=\"https://example.org/.sxg/" + "validity.msg\"\xA4" + "FdigestX9mi-sha256-03=0x0E2wkWVYOJ7Gq8+Kfaiyjo3gYCyaijhGGgkzjPoTo=G:statusC200Lcontent-" + "typeItext/htmlPcontent-encodingLmi-sha256-03\0\0\0\0\0\0\x10\0hi!\n", + 481); + testEncodeSignedExchange(request_headers, response_headers, expected_sxg); +} + +TEST_F(FilterTest, CborValdityFullUrls) { + setConfiguration({R"YAML( +cbor_url: "https://amp.example.org/cert.cbor" +validity_url: "https://amp.example.org/validity.msg" +)YAML"}); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + const Buffer::OwnedImpl expected_sxg( + "sxg1-b3\0\0\x1Ehttps://example.org/hello.html\0\0\xFE\0\0\x84" + "label;cert-sha256=*unJ3rwJT2DwWlJAw1lfVLvPjeYoJh0+QUQ97zJQPZtc=*;cert-url=\"https://" + "amp.example.org/" + "cert.cbor?d=ba7277af0253d83c\";date=1610416640;expires=1611021440;integrity=\"digest/" + "mi-sha256-03\";sig=**;validity-url=\"https://amp.example.org/" + "validity.msg\"\xA4" + "FdigestX9mi-sha256-03=0x0E2wkWVYOJ7Gq8+Kfaiyjo3gYCyaijhGGgkzjPoTo=G:statusC200Lcontent-" + "typeItext/htmlPcontent-encodingLmi-sha256-03\0\0\0\0\0\0\x10\0hi!\n", + 470); + testEncodeSignedExchange(request_headers, response_headers, expected_sxg); +} + +TEST_F(FilterTest, WithHttpTrailers) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "application/signed-exchange;v=b3;q=0.9"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + Http::TestResponseTrailerMapImpl response_trailers{{"x-test-sample-trailer", "wait for me!"}}; + testEncodeSignedExchange(request_headers, response_headers, &response_trailers); +} + +TEST_F(FilterTest, WithCustomShouldEncodeHeader) { + setConfiguration({R"YAML( +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +should_encode_sxg_header: "x-custom-should-encode-sxg" +)YAML"}); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "application/signed-exchange;v=b3;q=0.9"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-custom-should-encode-sxg", "true"}}; + testEncodeSignedExchange(request_headers, response_headers); +} + +TEST_F(FilterTest, FilterXEnvoyHeaders) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{{"content-type", "text/html"}, + {":status", "200"}, + {"x-should-encode-sxg", "true"}, + {"x-envoy-something", "something"}}; + testEncodeSignedExchange(request_headers, response_headers); +} + +TEST_F(FilterTest, FilterCustomHeaders) { + setConfiguration({R"YAML( +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +header_prefix_filters: + - "x-foo-" + - "x-bar-" +)YAML"}); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{{"content-type", "text/html"}, + {":status", "200"}, + {"x-should-encode-sxg", "true"}, + {"x-foo-bar", "foo"}, + {"x-bar-baz", "bar"}}; + testEncodeSignedExchange(request_headers, response_headers); + const Envoy::Http::LowerCaseString x_client_can_accept_sxg_key("x-client-can-accept-sxg"); +} + +TEST_F(FilterTest, CustomHeader) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{{"content-type", "text/html"}, + {":status", "200"}, + {"x-should-encode-sxg", "true"}, + {"x-special-header", "very special"}}; + const Buffer::OwnedImpl expected_sxg( + "sxg1-b3\0\0\x1Ehttps://example.org/hello.html\0\x1\0\0\0\xA2" + "label;cert-sha256=*unJ3rwJT2DwWlJAw1lfVLvPjeYoJh0+QUQ97zJQPZtc=*;cert-url=\"https://" + "example.org/.sxg/" + "cert.cbor?d=ba7277af0253d83c\";date=1610416640;expires=1611021440;integrity=\"digest/" + "mi-sha256-03\";sig=**;validity-url=\"https://example.org/.sxg/" + "validity.msg\"\xA5" + "FdigestX9mi-sha256-03=0x0E2wkWVYOJ7Gq8+Kfaiyjo3gYCyaijhGGgkzjPoTo=G:statusC200Lcontent-" + "typeItext/htmlPcontent-encodingLmi-sha256-03Px-special-headerLvery special" + "\0\0\0\0\0\0\x10\0hi!\n", + 502); + testEncodeSignedExchange(request_headers, response_headers, expected_sxg); +} + +TEST_F(FilterTest, ExtraHeaders) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{{"content-type", "text/html"}, + {":status", "200"}, + {"x-should-encode-sxg", "true"}, + {"x-special-header", "twice"}, + {"x-special-header", "as special"}}; + const Buffer::OwnedImpl expected_sxg( + "sxg1-b3\0\0\x1Ehttps://example.org/hello.html\0\x1\x0\0\0\xA6" + "label;cert-sha256=*unJ3rwJT2DwWlJAw1lfVLvPjeYoJh0+QUQ97zJQPZtc=*;cert-url=\"https://" + "example.org/.sxg/" + "cert.cbor?d=ba7277af0253d83c\";date=1610416640;expires=1611021440;integrity=\"digest/" + "mi-sha256-03\";sig=**;validity-url=\"https://example.org/.sxg/" + "validity.msg\"\xA5" + "FdigestX9mi-sha256-03=0x0E2wkWVYOJ7Gq8+Kfaiyjo3gYCyaijhGGgkzjPoTo=G:statusC200Lcontent-" + "typeItext/htmlPcontent-encodingLmi-sha256-03Px-special-headerP" + "twice,as special" + "\0\0\0\0\0\0\x10\0hi!\n", + 506); + + testEncodeSignedExchange(request_headers, response_headers, expected_sxg); +} + +TEST_F(FilterTest, TestDoubleDoSxg) { + setConfiguration(); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testEncodeSignedExchange(request_headers, response_headers); + callDoSxgAgain(); +} + +TEST_F(FilterTest, LoadHeadersFailure) { + setConfiguration(); + encoder_ = std::make_unique(); + setFilter(); + EXPECT_CALL(*static_cast(encoder_.get()), setOrigin); + EXPECT_CALL(*static_cast(encoder_.get()), setUrl); + EXPECT_CALL(*static_cast(encoder_.get()), loadHeaders).WillOnce(Return(false)); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testFallbackToHtml(request_headers, response_headers, false, true); +} + +TEST_F(FilterTest, LoadContentFailure) { + setConfiguration(); + encoder_ = std::make_unique(); + setFilter(); + EXPECT_CALL(*static_cast(encoder_.get()), setOrigin); + EXPECT_CALL(*static_cast(encoder_.get()), setUrl); + EXPECT_CALL(*static_cast(encoder_.get()), loadHeaders).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), loadContent).WillOnce(Return(false)); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testFallbackToHtml(request_headers, response_headers, false, true); +} + +TEST_F(FilterTest, GetEncodedResponseFailure) { + setConfiguration(); + encoder_ = std::make_unique(); + setFilter(); + EXPECT_CALL(*static_cast(encoder_.get()), setOrigin); + EXPECT_CALL(*static_cast(encoder_.get()), setUrl); + EXPECT_CALL(*static_cast(encoder_.get()), loadHeaders).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), loadContent).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), getEncodedResponse) + .WillOnce(Return(false)); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testFallbackToHtml(request_headers, response_headers, false, true); +} + +TEST_F(FilterTest, LoadSignerFailure) { + setConfiguration(); + encoder_ = std::make_unique(); + setFilter(); + EXPECT_CALL(*static_cast(encoder_.get()), setOrigin); + EXPECT_CALL(*static_cast(encoder_.get()), setUrl); + EXPECT_CALL(*static_cast(encoder_.get()), loadHeaders).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), loadContent).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), getEncodedResponse) + .WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), loadSigner).WillOnce(Return(false)); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testFallbackToHtml(request_headers, response_headers, false, true); +} + +TEST_F(FilterTest, WriteSxgFailure) { + setConfiguration(); + encoder_ = std::make_unique(); + setFilter(); + EXPECT_CALL(*static_cast(encoder_.get()), setOrigin); + EXPECT_CALL(*static_cast(encoder_.get()), setUrl); + EXPECT_CALL(*static_cast(encoder_.get()), loadHeaders).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), loadContent).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), getEncodedResponse) + .WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), loadSigner).WillOnce(Return(true)); + EXPECT_CALL(*static_cast(encoder_.get()), writeSxg).WillOnce(Return(nullptr)); + + Http::TestRequestHeaderMapImpl request_headers{ + {"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3;q=0.9,text/html;q=0.8"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testFallbackToHtml(request_headers, response_headers, false, true); +} + +// MyCombinedCertKeyId +TEST_F(FilterTest, CombiedCertificateId) { + const std::string certificate(R"PEM( +-----BEGIN CERTIFICATE----- +MIIBhjCCASygAwIBAgIJAIH9REPqIFXTMAkGByqGSM49BAEwMjEUMBIGA1UEAwwL +ZXhhbXBsZS5vcmcxDTALBgNVBAoMBFRlc3QxCzAJBgNVBAYTAlVTMB4XDTIxMDEx +MzAxMDcwMVoXDTIxMDQxMzAxMDcwMVowMjEUMBIGA1UEAwwLZXhhbXBsZS5vcmcx +DTALBgNVBAoMBFRlc3QxCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE4ZrHsGLEiP+pV70a8zIERNcu9MBJHHfbeqLUqwGWWU2/YHObf58nE9to +c6lgrko2JdbV6TyWLVUc/M0Pn+OVSaMsMCowEAYKKwYBBAHWeQIBFgQCBQAwFgYD +VR0RBA8wDYILZXhhbXBsZS5vcmcwCQYHKoZIzj0EAQNJADBGAiEAuQJjX+z7j4hR +xtxfs4VPY5RsF5Sawd+mtluRxpoURcsCIQCIGU/11jcuS0UbIpt4B5Gb1UJlSKGi +Dgu+2OKt7qVPrA== +-----END CERTIFICATE----- +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJyGXecxIQtBwBJWU4Sc5A8UHNt5HnOBR9Oh11AGYa/2oAoGCCqGSM49 +AwEHoUQDQgAE4ZrHsGLEiP+pV70a8zIERNcu9MBJHHfbeqLUqwGWWU2/YHObf58n +E9toc6lgrko2JdbV6TyWLVUc/M0Pn+OVSQ== +-----END EC PRIVATE KEY----- +)PEM"); + + setConfiguration({R"YAML( +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +)YAML"}, + certificate, certificate); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"accept", "application/signed-exchange;v=b3"}, + {"host", "example.org"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + testEncodeSignedExchange(request_headers, response_headers); +} + +TEST_F(FilterTest, BadCertificateId) { + const std::string certificate(""); + const std::string private_key(R"PEM( +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJyGXecxIQtBwBJWU4Sc5A8UHNt5HnOBR9Oh11AGYa/2oAoGCCqGSM49 +AwEHoUQDQgAE4ZrHsGLEiP+pV70a8zIERNcu9MBJHHfbeqLUqwGWWU2/YHObf58n +E9toc6lgrko2JdbV6TyWLVUc/M0Pn+OVSQ== +-----END EC PRIVATE KEY----- +)PEM"); + + setConfiguration({R"YAML( +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +)YAML"}, + certificate, private_key); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + + testFallbackToHtml(request_headers, response_headers, false, true); +} +std::string private_key(R"PEM( +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJyGXecxIQtBwBJWU4Sc5A8UHNt5HnOBR9Oh11AGYa/2oAoGCCqGSM49 +AwEHoUQDQgAE4ZrHsGLEiP+pV70a8zIERNcu9MBJHHfbeqLUqwGWWU2/YHObf58n +E9toc6lgrko2JdbV6TyWLVUc/M0Pn+OVSQ== +-----END EC PRIVATE KEY----- +)PEM"); + +TEST_F(FilterTest, BadPriKeyId) { + const std::string certificate(R"PEM( +-----BEGIN CERTIFICATE----- +MIIBhjCCASygAwIBAgIJAIH9REPqIFXTMAkGByqGSM49BAEwMjEUMBIGA1UEAwwL +ZXhhbXBsZS5vcmcxDTALBgNVBAoMBFRlc3QxCzAJBgNVBAYTAlVTMB4XDTIxMDEx +MzAxMDcwMVoXDTIxMDQxMzAxMDcwMVowMjEUMBIGA1UEAwwLZXhhbXBsZS5vcmcx +DTALBgNVBAoMBFRlc3QxCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE4ZrHsGLEiP+pV70a8zIERNcu9MBJHHfbeqLUqwGWWU2/YHObf58nE9to +c6lgrko2JdbV6TyWLVUc/M0Pn+OVSaMsMCowEAYKKwYBBAHWeQIBFgQCBQAwFgYD +VR0RBA8wDYILZXhhbXBsZS5vcmcwCQYHKoZIzj0EAQNJADBGAiEAuQJjX+z7j4hR +xtxfs4VPY5RsF5Sawd+mtluRxpoURcsCIQCIGU/11jcuS0UbIpt4B5Gb1UJlSKGi +Dgu+2OKt7qVPrA== +-----END CERTIFICATE----- +)PEM"); + const std::string private_key(""); + + setConfiguration({R"YAML( +cbor_url: "/.sxg/cert.cbor" +validity_url: "/.sxg/validity.msg" +)YAML"}, + certificate, private_key); + setFilter(); + + Http::TestRequestHeaderMapImpl request_headers{{"host", "example.org"}, + {"accept", "application/signed-exchange;v=b3"}, + {":path", "/hello.html"}}; + Http::TestResponseHeaderMapImpl response_headers{ + {"content-type", "text/html"}, {":status", "200"}, {"x-should-encode-sxg", "true"}}; + + testFallbackToHtml(request_headers, response_headers, false, true); +} + +} // namespace SXG +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/docs/root/configuration/http/http_filters/http_filters.rst b/docs/root/configuration/http/http_filters/http_filters.rst index a87b6447a2715..cb77f65eed621 100644 --- a/docs/root/configuration/http/http_filters/http_filters.rst +++ b/docs/root/configuration/http/http_filters/http_filters.rst @@ -43,6 +43,7 @@ HTTP filters router_filter set_metadata_filter squash_filter + sxg_filter tap_filter wasm_filter diff --git a/docs/root/configuration/http/http_filters/squash_filter.rst b/docs/root/configuration/http/http_filters/squash_filter.rst index 494f05f03b432..ff847f32327db 100644 --- a/docs/root/configuration/http/http_filters/squash_filter.rst +++ b/docs/root/configuration/http/http_filters/squash_filter.rst @@ -6,6 +6,8 @@ Squash Squash is an HTTP filter which enables Envoy to integrate with Squash microservices debugger. Code: https://github.com/solo-io/squash, API Docs: https://squash.solo.io/ +The Squash filter is only included in :ref:`contrib images ` + Overview -------- diff --git a/docs/root/configuration/http/http_filters/sxg_filter.rst b/docs/root/configuration/http/http_filters/sxg_filter.rst new file mode 100644 index 0000000000000..df85888621453 --- /dev/null +++ b/docs/root/configuration/http/http_filters/sxg_filter.rst @@ -0,0 +1,73 @@ + +.. _config_http_filters_sxg: + +SXG +====== + +* :ref:`v3 API reference ` +* This filter should be configured with the name *envoy.filters.http.sxg*. + +.. attention:: + + The SXG filter is experimental and is currently under active development. + +This filter generates a Signed HTTP Exchange (SXG) package from a downstream web application. It uses `libsxg `_ to perform the SXG packaging and signing, setting the Content-Type header to `application/signed-exchange;v=b3` and response body with the generated SXG document. + +The SXG filter is only included in :ref:`contrib images ` + +Transaction flow: + +* check accept request header for whether client can accept SXG and set a flag. ``x-envoy-client-can-accept-sxg`` (or the header defined in ``client_can_accept_sxg_header``) will be set on the request +* If ``x-envoy-should-encode-sxg`` (or the header defined in ``should_encode_sxg_header``) is present in the response headers set a flag +* If both flags are set, buffer response body until stream end and then replace response body with generated the SXG + +If there is an error generating the SXG package we fall back to the original HTML. + +For more information on Signed HTTP Exchanges see `this doc `_. + +Example configuration +--------------------- + +The following is an example configuring the filter. + +.. validated-code-block:: yaml + :type-name: envoy.extensions.filters.http.sxg.v3alpha.SXG + + cbor_url: "/.sxg/cert.cbor" + validity_url: "/.sxg/validity.msg" + certificate: + name: certificate + sds_config: + path: "/etc/envoy/sxg-certificate.yaml" + private_key: + name: private_key + sds_config: + path: "/etc/envoy/sxg-private-key.yaml" + duration: 432000s + mi_record_size: 1024 + client_can_accept_sxg_header: "x-custom-accept-sxg" + should_encode_sxg_header: "x-custom-should-encode" + header_prefix_filters: + - "x-foo-" + - "x-bar-" + +Notes +----- + +Instructions for generating a self-signed certificate and private key for testing can be found `here `__ + +Statistics +---------- + +The SXG filter outputs statistics in the *.sxg.* namespace. + +.. csv-table:: + :header: Name, Type, Description + :widths: 1, 1, 2 + + total_client_can_accept_sxg, Counter, Total requests where client passes valid Accept header for SXG documents. + total_should_sign, Counter, Total requests where downstream passes back header indicating Envoy should encocde document. + total_exceeded_max_payload_size, Counter, Total requests where response from downstream is to large. + total_signed_attempts, Counter, Total requests where SXG encoding is attempted. + total_signed_succeeded, Counter, Total requests where SXG encoding succeeds. + total_signed_failed, Counter, Total requests where SXG encoding fails. diff --git a/docs/root/faq/windows/win_not_supported_features.rst b/docs/root/faq/windows/win_not_supported_features.rst index 21685e8c97b0b..8e002a1f182c6 100644 --- a/docs/root/faq/windows/win_not_supported_features.rst +++ b/docs/root/faq/windows/win_not_supported_features.rst @@ -8,6 +8,7 @@ The most notable features that are not supported on Windows are: * :ref:`Tracers ` * :ref:`Original Src HTTP Filter `. * :ref:`Hot restart ` +* :ref:`Signed Exchange Filter ` There are certain Envoy features that require newer versions of Windows. These features explicitly document the required version. diff --git a/docs/root/version_history/current.rst b/docs/root/version_history/current.rst index 71c41c24694b5..75213c9618f77 100644 --- a/docs/root/version_history/current.rst +++ b/docs/root/version_history/current.rst @@ -96,6 +96,7 @@ New Features * listener: new listener metric ``downstream_cx_transport_socket_connect_timeout`` to track transport socket timeouts. * rbac: added :ref:`destination_port_range ` for matching range of destination ports. * route config: added :ref:`dynamic_metadata ` for routing based on dynamic metadata. +* sxg_filter: added filter to transform response to SXG package to :ref:`contrib images `. This can be enabled by setting :ref:`SXG ` configuration. * thrift_proxy: added support for :ref:`mirroring requests `. Deprecated diff --git a/generated_api_shadow/BUILD b/generated_api_shadow/BUILD index 3308e817362e6..5bbde32946b63 100644 --- a/generated_api_shadow/BUILD +++ b/generated_api_shadow/BUILD @@ -58,6 +58,7 @@ proto_library( visibility = ["//visibility:public"], deps = [ "//contrib/envoy/extensions/filters/http/squash/v3:pkg", + "//contrib/envoy/extensions/filters/http/sxg/v3alpha:pkg", "//contrib/envoy/extensions/filters/network/kafka_broker/v3:pkg", "//contrib/envoy/extensions/filters/network/mysql_proxy/v3:pkg", "//contrib/envoy/extensions/filters/network/postgres_proxy/v3alpha:pkg", diff --git a/generated_api_shadow/contrib/envoy/extensions/filters/http/sxg/v3alpha/BUILD b/generated_api_shadow/contrib/envoy/extensions/filters/http/sxg/v3alpha/BUILD new file mode 100644 index 0000000000000..3ca8242f77801 --- /dev/null +++ b/generated_api_shadow/contrib/envoy/extensions/filters/http/sxg/v3alpha/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/extensions/transport_sockets/tls/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/generated_api_shadow/contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.proto b/generated_api_shadow/contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.proto new file mode 100644 index 0000000000000..b9efc278e6de8 --- /dev/null +++ b/generated_api_shadow/contrib/envoy/extensions/filters/http/sxg/v3alpha/sxg.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.sxg.v3alpha; + +import "envoy/extensions/transport_sockets/tls/v3/secret.proto"; + +import "google/protobuf/duration.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.sxg.v3alpha"; +option java_outer_classname = "SxgProto"; +option java_multiple_files = true; +option (udpa.annotations.file_status).work_in_progress = true; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Signed HTTP Exchange Filter] +// SXG :ref:`configuration overview `. +// [#extension: envoy.filters.http.sxg] + +// [#next-free-field: 10] +message SXG { + // The SDS configuration for the public key data for the SSL certificate that will be used to sign the + // SXG response. + transport_sockets.tls.v3.SdsSecretConfig certificate = 1; + + // The SDS configuration for the private key data for the SSL certificate that will be used to sign the + // SXG response. + transport_sockets.tls.v3.SdsSecretConfig private_key = 2; + + // The duration for which the generated SXG package will be valid. Default is 604800s (7 days in seconds). + // Note that in order to account for clock skew, the timestamp will be backdated by a day. So, if duration + // is set to 7 days, that will be 7 days from 24 hours ago (6 days from now). Also note that while 6/7 days + // is appropriate for most content, if the downstream service is serving Javascript, or HTML with inline + // Javascript, 1 day (so, with backdated expiry, 2 days, or 172800 seconds) is more appropriate. + google.protobuf.Duration duration = 3; + + // The SXG response payload is Merkle Integrity Content Encoding (MICE) encoded (specification is [here](https://datatracker.ietf.org/doc/html/draft-thomson-http-mice-03)) + // This value indicates the record size in the encoded payload. The default value is 4096. + uint64 mi_record_size = 4; + + // The URI of certificate CBOR file published. Since it is required that the certificate CBOR file + // be served from the same domain as the SXG document, this should be a relative URI. + string cbor_url = 5 [(validate.rules).string = {min_len: 1 prefix: "/"}]; + + // URL to retrieve validity data for signature, a CBOR map. See specification [here](https://tools.ietf.org/html/draft-yasskin-httpbis-origin-signed-exchanges-impl-00#section-3.6) + string validity_url = 6 [(validate.rules).string = {min_len: 1 prefix: "/"}]; + + // Header that will be set if it is determined that the client can accept SXG (typically `accept: application/signed-exchange;v=b3) + // If not set, filter will default to: `x-client-can-accept-sxg` + string client_can_accept_sxg_header = 7 [ + (validate.rules).string = {well_known_regex: HTTP_HEADER_NAME strict: false ignore_empty: true} + ]; + + // Header set by downstream service to signal that the response should be transformed to SXG If not set, + // filter will default to: `x-should-encode-sxg` + string should_encode_sxg_header = 8 [ + (validate.rules).string = {well_known_regex: HTTP_HEADER_NAME strict: false ignore_empty: true} + ]; + + // Headers that will be stripped from the SXG document, by listing a prefix (i.e. `x-custom-` will cause + // all headers prefixed by `x-custom-` to be omitted from the SXG document) + repeated string header_prefix_filters = 9 [ + (validate.rules).repeated = {items {string {well_known_regex: HTTP_HEADER_NAME strict: false}}} + ]; +} diff --git a/tools/spelling/spelling_dictionary.txt b/tools/spelling/spelling_dictionary.txt index d2cde24524ce4..a2f70a403b7b6 100644 --- a/tools/spelling/spelling_dictionary.txt +++ b/tools/spelling/spelling_dictionary.txt @@ -24,6 +24,7 @@ BPF btree CAS CB +cbor CDN CDS CEL @@ -189,6 +190,7 @@ LEDS LEV LF LHS +libsxg LLVM LPT LRS @@ -196,6 +198,7 @@ Loggable MB MD MERCHANTABILITY +Merkle MGET MQ MSDN @@ -324,6 +327,7 @@ STRLEN STS SVG SVID +SXG Symbolizer TBD TCLAP