From ea98e1ee2f8155de95fe77f434d15058f87834c9 Mon Sep 17 00:00:00 2001 From: Cooper Gamble Date: Sat, 22 Aug 2026 06:34:34 +0000 Subject: [PATCH] [envoy formatter] add SHA-256 request header formatter [ci changed_files] Signed-off-by: Cooper Gamble --- ...s_log__request-header-sha256-formatter.rst | 2 + .../advanced/substitution_formatter.rst | 15 +++++ source/common/formatter/BUILD | 3 + .../formatter/http_specific_formatter.cc | 55 +++++++++++++++++++ .../formatter/http_specific_formatter.h | 17 ++++++ .../formatter/substitution_formatter_test.cc | 52 ++++++++++++++++++ 6 files changed, 144 insertions(+) create mode 100644 changelogs/current/new_features/access_log__request-header-sha256-formatter.rst diff --git a/changelogs/current/new_features/access_log__request-header-sha256-formatter.rst b/changelogs/current/new_features/access_log__request-header-sha256-formatter.rst new file mode 100644 index 0000000000000..9b41ce3664bb5 --- /dev/null +++ b/changelogs/current/new_features/access_log__request-header-sha256-formatter.rst @@ -0,0 +1,2 @@ +Added the ``%REQUEST_HEADER_SHA256(X?Y)%`` and ``%REQ_SHA256(X?Y)%`` substitution +formatters to compute the SHA-256 digest of a request header without exposing its value. diff --git a/docs/root/configuration/advanced/substitution_formatter.rst b/docs/root/configuration/advanced/substitution_formatter.rst index 664cfcf41aef2..cffbc7d227f1d 100644 --- a/docs/root/configuration/advanced/substitution_formatter.rst +++ b/docs/root/configuration/advanced/substitution_formatter.rst @@ -974,6 +974,21 @@ Current supported substitution commands include: TCP/UDP Not implemented. It will appear as ``"-"`` in the access logs. +.. _config_access_log_format_req_sha256: + +``%REQUEST_HEADER_SHA256(X?Y)%`` / ``%REQ_SHA256(X?Y)%`` + HTTP + The lowercase hexadecimal SHA-256 digest of an HTTP request header. ``X`` is the main request + header and ``Y`` is an optional alternative. If ``X`` is absent or empty, the value of ``Y`` is + used instead. If neither header has a nonempty value, ``"-"`` appears in the access logs. + + This formatter can be used to correlate requests or construct consistent-hashing keys without + copying a sensitive header value into another request header or log field. A SHA-256 digest is + not keyed, so values with low entropy may remain vulnerable to guessing. + + TCP/UDP + Not implemented. It will appear as ``"-"`` in the access logs. + ``%RESPONSE_HEADER(X?Y):Z%`` / ``%RESP(X?Y):Z%`` HTTP Same as ``%REQUEST_HEADER(X?Y):Z%`` but taken from HTTP response headers. diff --git a/source/common/formatter/BUILD b/source/common/formatter/BUILD index a5eafe09b4849..87618cd5e384f 100644 --- a/source/common/formatter/BUILD +++ b/source/common/formatter/BUILD @@ -114,8 +114,11 @@ envoy_cc_library( "//envoy/runtime:runtime_interface", "//envoy/stream_info:stream_info_interface", "//envoy/upstream:upstream_interface", + "//source/common/buffer:buffer_lib", "//source/common/common:assert_lib", + "//source/common/common:hex_lib", "//source/common/common:utility_lib", + "//source/common/crypto:utility_lib", "//source/common/formatter:substitution_format_utility_lib", "//source/common/grpc:common_lib", "//source/common/http:utility_lib", diff --git a/source/common/formatter/http_specific_formatter.cc b/source/common/formatter/http_specific_formatter.cc index 207cfdba1ee10..67386ea40b406 100644 --- a/source/common/formatter/http_specific_formatter.cc +++ b/source/common/formatter/http_specific_formatter.cc @@ -6,12 +6,15 @@ #include #include +#include "source/common/buffer/buffer_impl.h" #include "source/common/common/assert.h" #include "source/common/common/empty_string.h" #include "source/common/common/fmt.h" +#include "source/common/common/hex.h" #include "source/common/common/thread.h" #include "source/common/common/utility.h" #include "source/common/config/metadata.h" +#include "source/common/crypto/utility.h" #include "source/common/formatter/coalesce_formatter.h" #include "source/common/grpc/common.h" #include "source/common/grpc/status.h" @@ -166,6 +169,40 @@ void RequestHeaderFormatter::formatValueTo(ValueSink& sink, const Context& conte HeaderFormatter::formatValueTo(sink, context.requestHeaders()); } +RequestHeaderSha256Formatter::RequestHeaderSha256Formatter(absl::string_view main_header, + absl::string_view alternative_header) + : main_header_(main_header), alternative_header_(alternative_header) {} + +std::optional +RequestHeaderSha256Formatter::format(const Context& context, const StreamInfo::StreamInfo&) const { + const auto headers = context.requestHeaders(); + if (!headers.has_value()) { + return std::nullopt; + } + + const auto find_non_empty_header = [&headers](const Http::LowerCaseString& name) { + const auto values = headers->get(name); + return values.empty() || values[0]->value().empty() ? nullptr : values[0]; + }; + + const Http::HeaderEntry* header = find_non_empty_header(main_header_); + if (header == nullptr && !alternative_header_.get().empty()) { + header = find_non_empty_header(alternative_header_); + } + if (header == nullptr) { + return std::nullopt; + } + + return Hex::encode(Common::Crypto::UtilitySingleton::get().getSha256Digest( + Buffer::OwnedImpl(header->value().getStringView()))); +} + +Protobuf::Value +RequestHeaderSha256Formatter::formatValue(const Context& context, + const StreamInfo::StreamInfo& stream_info) const { + return ValueUtil::optionalStringValue(format(context, stream_info)); +} + ResponseTrailerFormatter::ResponseTrailerFormatter(absl::string_view main_header, absl::string_view alternative_header, std::optional max_length) @@ -555,6 +592,24 @@ BuiltInHttpCommandParser::getKnownFormatters() { return std::make_unique(result.value().first, result.value().second, max_length); }}}, + {"REQ_SHA256", + {CommandSyntaxChecker::PARAMS_REQUIRED, + [](absl::string_view format, + std::optional) -> absl::StatusOr { + auto result = SubstitutionFormatUtils::parseSubcommandHeaders(format); + RETURN_IF_NOT_OK(result.status()); + return std::make_unique(result.value().first, + result.value().second); + }}}, + {"REQUEST_HEADER_SHA256", + {CommandSyntaxChecker::PARAMS_REQUIRED, + [](absl::string_view format, + std::optional) -> absl::StatusOr { + auto result = SubstitutionFormatUtils::parseSubcommandHeaders(format); + RETURN_IF_NOT_OK(result.status()); + return std::make_unique(result.value().first, + result.value().second); + }}}, {"RESP", // Same as RESPONSE_HEADER and used for backward compatibility. {CommandSyntaxChecker::PARAMS_REQUIRED | CommandSyntaxChecker::LENGTH_ALLOWED, [](absl::string_view format, diff --git a/source/common/formatter/http_specific_formatter.h b/source/common/formatter/http_specific_formatter.h index 336e126bf86f0..3450eaa2001dc 100644 --- a/source/common/formatter/http_specific_formatter.h +++ b/source/common/formatter/http_specific_formatter.h @@ -107,6 +107,23 @@ class RequestHeaderFormatter : public FormatterProvider, HeaderFormatter { const StreamInfo::StreamInfo& stream_info) const override; }; +/** + * FormatterProvider for the SHA-256 digest of a request header. + */ +class RequestHeaderSha256Formatter : public FormatterProvider { +public: + RequestHeaderSha256Formatter(absl::string_view main_header, absl::string_view alternative_header); + + std::optional format(const Context& context, + const StreamInfo::StreamInfo& stream_info) const override; + Protobuf::Value formatValue(const Context& context, + const StreamInfo::StreamInfo& stream_info) const override; + +private: + Http::LowerCaseString main_header_; + Http::LowerCaseString alternative_header_; +}; + /** * FormatterProvider for response headers. */ diff --git a/test/common/formatter/substitution_formatter_test.cc b/test/common/formatter/substitution_formatter_test.cc index 93713d9122fe7..48ccfcbfd287b 100644 --- a/test/common/formatter/substitution_formatter_test.cc +++ b/test/common/formatter/substitution_formatter_test.cc @@ -3514,6 +3514,58 @@ TEST(SubstitutionFormatterTest, requestHeaderFormatter) { } } +TEST(SubstitutionFormatterTest, RequestHeaderSha256Formatter) { + StreamInfo::MockStreamInfo stream_info; + const std::string expected_digest = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + Http::TestRequestHeaderMapImpl request_headers{{"authorization", "abc"}, + {"x-request-id", "ignored"}}; + Context formatter_context; + formatter_context.setRequestHeaders(request_headers); + + RequestHeaderSha256Formatter formatter("AUTHORIZATION", "X-REQUEST-ID"); + EXPECT_EQ(expected_digest, formatter.format(formatter_context, stream_info)); + EXPECT_THAT(formatter.formatValue(formatter_context, stream_info), + ProtoEq(ValueUtil::stringValue(expected_digest))); + + for (const std::string& command : { + "%REQUEST_HEADER_SHA256(authorization?x-request-id)%", + "%REQ_SHA256(authorization?x-request-id)%", + }) { + auto providers = SubstitutionFormatParser::parse(command); + ASSERT_TRUE(providers.ok()) << providers.status(); + ASSERT_EQ(providers->size(), 1); + EXPECT_EQ(expected_digest, (*providers)[0]->format(formatter_context, stream_info)); + } + + Http::TestRequestHeaderMapImpl fallback_headers{{"x-request-id", "abc"}}; + Context fallback_context; + fallback_context.setRequestHeaders(fallback_headers); + EXPECT_EQ(expected_digest, formatter.format(fallback_context, stream_info)); + + Http::TestRequestHeaderMapImpl empty_primary_headers{{"authorization", ""}, + {"x-request-id", "abc"}}; + Context empty_primary_context; + empty_primary_context.setRequestHeaders(empty_primary_headers); + EXPECT_EQ(expected_digest, formatter.format(empty_primary_context, stream_info)); + + Http::TestRequestHeaderMapImpl empty_headers{{"authorization", ""}, {"x-request-id", ""}}; + Context empty_context; + empty_context.setRequestHeaders(empty_headers); + EXPECT_EQ(std::nullopt, formatter.format(empty_context, stream_info)); + EXPECT_THAT(formatter.formatValue(empty_context, stream_info), ProtoEq(ValueUtil::nullValue())); + + RequestHeaderSha256Formatter no_fallback("missing", ""); + EXPECT_EQ(std::nullopt, no_fallback.format(formatter_context, stream_info)); + EXPECT_EQ(std::nullopt, formatter.format({}, stream_info)); + + EXPECT_FALSE(SubstitutionFormatParser::parse("%REQ_SHA256%").ok()); + EXPECT_FALSE(SubstitutionFormatParser::parse("%REQ_SHA256(authorization):8%").ok()); + EXPECT_FALSE( + SubstitutionFormatParser::parse("%REQ_SHA256(authorization?fallback?another)%").ok()); +} + TEST(SubstitutionFormatterTest, QueryParameterFormatter) { StreamInfo::MockStreamInfo stream_info; Http::TestRequestHeaderMapImpl request_header{{":method", "GET"}, {":path", "/path?x=xxxxxx"}};