Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
667ee44
feat: allow obs-text in HTTP protocol options
fishpan1209 Mar 13, 2026
0924346
fix failed tests
fishpan1209 Mar 16, 2026
f4003f5
fix format issue
fishpan1209 Mar 16, 2026
377c3dd
fix typo
fishpan1209 Mar 16, 2026
eaf03cb
Merge remote-tracking branch 'upstream/main' into gfe-e2e
fishpan1209 Mar 16, 2026
5096364
rename allow_obs_text to disallow_obs_text which defaults to false
fishpan1209 Mar 17, 2026
ffb22f7
Merge remote-tracking branch 'upstream/main' into gfe-e2e
fishpan1209 Mar 17, 2026
8b19826
Apply clang-format fix and refactor disallow_obs_text to bool
fishpan1209 Mar 17, 2026
bbabb9b
Merge remote-tracking branch 'upstream/main' into gfe-e2e
fishpan1209 Mar 18, 2026
db5d792
refactor disallow_obs_text default to true for H3 to align with curre…
fishpan1209 Mar 20, 2026
b641d1d
Merge remote-tracking branch 'upstream/main' into gfe-e2e
fishpan1209 Mar 20, 2026
4b69b29
Merge remote-tracking branch 'upstream/main' into gfe-e2e
fishpan1209 Mar 23, 2026
4ab9b1d
address api comments
fishpan1209 Mar 23, 2026
c58e493
refactor disallow_obs_text type to BoolValue
fishpan1209 Mar 27, 2026
d2bc55c
Merge remote-tracking branch 'upstream/main' into gfe-e2e
fishpan1209 Mar 27, 2026
f9a7d6c
remove outdated comments
fishpan1209 Mar 31, 2026
5204355
Merge remote-tracking branch 'upstream/main' into gfe-e2e
fishpan1209 Mar 31, 2026
47b7189
Merge remote-tracking branch 'upstream/main' into gfe-e2e-stats
fishpan1209 Apr 1, 2026
c68d9ac
Add codec stats to count the frequency of requests contains obs_text
fishpan1209 Apr 1, 2026
449d667
address comments
fishpan1209 Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions api/envoy/config/core/v3/protocol.proto
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ message KeepaliveSettings {
[(validate.rules).duration = {gte {nanos: 1000000}}];
}

// [#next-free-field: 20]
// [#next-free-field: 21]
message Http2ProtocolOptions {
option (udpa.annotations.versioning).previous_message_type =
"envoy.api.v2.core.Http2ProtocolOptions";
Expand Down Expand Up @@ -774,6 +774,12 @@ message Http2ProtocolOptions {
// request failures.
google.protobuf.UInt32Value max_header_field_size_kb = 19
[(validate.rules).uint32 = {lte: 256 gte: 64}];

// Whether to disallow obsolete text for oghttp2 in header field values.
// If not set, it defaults to false.
// From RFC 9110, https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5:
// obs-text = %x80-FF
google.protobuf.BoolValue disallow_obs_text = 20;
}

// [#not-implemented-hide:]
Expand All @@ -785,7 +791,7 @@ message GrpcProtocolOptions {
}

// A message which allows using HTTP/3.
// [#next-free-field: 9]
// [#next-free-field: 10]
message Http3ProtocolOptions {
QuicProtocolOptions quic_protocol_options = 1;

Expand Down Expand Up @@ -827,6 +833,12 @@ message Http3ProtocolOptions {
// Disables connection level flow control for HTTP/3 streams. This is useful in situations where the streams share the same connection
// but originate from different end-clients, so that each stream can make progress independently at non-front-line proxies.
bool disable_connection_flow_control_for_streams = 8;

// Whether to disallow obsolete text in header field values.
// If not set, it defaults to true for alignment with current behavior.
// As defined in RFC 9110, https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5:
// an obs-text character is a character in the range %x80-FF
google.protobuf.BoolValue disallow_obs_text = 9;
}

// A message to control transformations to the :scheme header
Expand Down
1 change: 1 addition & 0 deletions envoy/http/header_validator.h
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ class HeaderValidatorStats {
virtual void incDroppedHeadersWithUnderscores() PURE;
virtual void incRequestsRejectedWithUnderscoresInHeaders() PURE;
virtual void incMessagingError() PURE;
virtual void incRequestsWithObsText() PURE;
};

/**
Expand Down
104 changes: 64 additions & 40 deletions source/common/http/header_utility.cc
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ HeaderUtility::GetAllOfHeaderAsStringResult
HeaderUtility::getAllOfHeaderAsString(const HeaderMap::GetResult& header_value,
absl::string_view separator) {
GetAllOfHeaderAsStringResult result;
// In this case we concatenate all found headers using a delimiter before performing the
// final match. We use an InlinedVector of absl::string_view to invoke the optimized join
// algorithm. This requires a copying phase before we invoke join. The 3 used as the inline
// size has been arbitrarily chosen.
// In this case we concatenate all found headers using a delimiter before
// performing the final match. We use an InlinedVector of absl::string_view to
// invoke the optimized join algorithm. This requires a copying phase before
// we invoke join. The 3 used as the inline size has been arbitrarily chosen.
// TODO(mattklein123): Do we need to normalize any whitespace here?
absl::InlinedVector<absl::string_view, 3> string_view_vector;
string_view_vector.reserve(header_value.size());
Expand All @@ -75,8 +75,8 @@ HeaderUtility::getAllOfHeaderAsString(const HeaderMap& headers, const Http::Lowe
const auto header_value = headers.get(key);

if (header_value.empty()) {
// Empty for clarity. Avoid handling the empty case in the block below if the runtime feature
// is disabled.
// Empty for clarity. Avoid handling the empty case in the block below if
// the runtime feature is disabled.
} else if (header_value.size() == 1) {
result.result_ = header_value[0]->value().getStringView();
} else {
Expand Down Expand Up @@ -136,11 +136,12 @@ bool HeaderUtility::headerNameIsValid(absl::string_view header_key) {
return false;
}
}
// For all other header use HTTP/1 semantics. The only difference from HTTP/2 is that
// uppercase characters are allowed. This allows HTTP filters to add header with mixed
// case names. The HTTP/1 codec will send as is, as uppercase characters are allowed.
// However the HTTP/2 codec will NOT convert these to lowercase when serializing the
// header map, thus producing an invalid request.
// For all other header use HTTP/1 semantics. The only difference from HTTP/2
// is that uppercase characters are allowed. This allows HTTP filters to add
// header with mixed case names. The HTTP/1 codec will send as is, as
// uppercase characters are allowed. However the HTTP/2 codec will NOT convert
// these to lowercase when serializing the header map, thus producing an
// invalid request.
// TODO(yanavlasov): make validation in HTTP/2 case stricter.
bool is_valid = true;
for (auto iter = header_key.begin(); iter != header_key.end() && is_valid; ++iter) {
Expand All @@ -155,11 +156,12 @@ bool HeaderUtility::headerNameContainsUnderscore(const absl::string_view header_

bool HeaderUtility::authorityIsValid(const absl::string_view header_value) {
// This function validates the authority header for both HTTP/1 and HTTP/2.
// Note the HTTP/1 spec allows "user-info@host:port" for the authority, whereas
// the HTTP/2 spec only allows "host:port". Thus, this function permits all the
// HTTP/2 valid characters (similar to oghttp2's implementation) and the "@" character.
// Once UHV is used, this function should be removed, and the HTTP/1 and HTTP/2
// authority validations should be different.
// Note the HTTP/1 spec allows "user-info@host:port" for the authority,
// whereas the HTTP/2 spec only allows "host:port". Thus, this function
// permits all the HTTP/2 valid characters (similar to oghttp2's
// implementation) and the "@" character. Once UHV is used, this function
// should be removed, and the HTTP/1 and HTTP/2 authority validations should
// be different.
static constexpr char ValidAuthorityChars[] = {
0 /* NUL */, 0 /* SOH */, 0 /* STX */, 0 /* ETX */,
0 /* EOT */, 0 /* ENQ */, 0 /* ACK */, 0 /* BEL */,
Expand Down Expand Up @@ -251,8 +253,9 @@ bool HeaderUtility::isConnectUdpRequest(const RequestHeaderMap& headers) {
}

bool HeaderUtility::isConnectUdpResponse(const ResponseHeaderMap& headers) {
// In connect-udp case, Envoy will transform the H2 headers to H1 upgrade headers.
// A valid response should have SwitchingProtocol status and connect-udp upgrade.
// In connect-udp case, Envoy will transform the H2 headers to H1 upgrade
// headers. A valid response should have SwitchingProtocol status and
// connect-udp upgrade.
return headers.Upgrade() && Utility::getResponseStatus(headers) == 101 &&
absl::EqualsIgnoreCase(headers.getUpgradeValue(),
Http::Headers::get().UpgradeValues.ConnectUdp);
Expand All @@ -266,7 +269,8 @@ bool HeaderUtility::isConnectResponse(const RequestHeaderMap* request_headers,
}

bool HeaderUtility::rewriteAuthorityForConnectUdp(RequestHeaderMap& headers) {
// Per RFC 9298, the URI template must only contain ASCII characters in the range 0x21-0x7E.
// Per RFC 9298, the URI template must only contain ASCII characters in the
// range 0x21-0x7E.
absl::string_view path = headers.getPathValue();
for (char c : path) {
unsigned char ascii_code = static_cast<unsigned char>(c);
Expand All @@ -289,8 +293,8 @@ bool HeaderUtility::rewriteAuthorityForConnectUdp(RequestHeaderMap& headers) {
return false;
}

// Utility::PercentEncoding::decode never returns an empty string if the input argument is not
// empty.
// Utility::PercentEncoding::decode never returns an empty string if the input
// argument is not empty.
std::string target_host = Utility::PercentEncoding::decode(path_split[4]);
// Per RFC 9298, IPv6 Zone ID is not supported.
if (target_host.find('%') != std::string::npos) {
Expand All @@ -313,9 +317,10 @@ bool HeaderUtility::rewriteAuthorityForConnectUdp(RequestHeaderMap& headers) {
bool HeaderUtility::isCapsuleProtocol(const RequestOrResponseHeaderMap& headers) {
Http::HeaderMap::GetResult capsule_protocol =
headers.get(Envoy::Http::LowerCaseString("Capsule-Protocol"));
// When there are multiple Capsule-Protocol header entries, it returns false. RFC 9297 specifies
// that non-boolean value types must be ignored. If there are multiple header entries, the value
// type becomes a List so the header field must be ignored.
// When there are multiple Capsule-Protocol header entries, it returns false.
// RFC 9297 specifies that non-boolean value types must be ignored. If there
// are multiple header entries, the value type becomes a List so the header
// field must be ignored.
if (capsule_protocol.size() != 1) {
return false;
}
Expand Down Expand Up @@ -354,7 +359,8 @@ void HeaderUtility::stripTrailingHostDot(RequestHeaderMap& headers) {
}
// If the dot is just before a colon, it must be preceding the port number.
// IPv6 addresses may contain colons or dots, but the dot will never directly
// precede the colon, so this check should be sufficient to detect a trailing port number.
// precede the colon, so this check should be sufficient to detect a trailing
// port number.
if (host[dot_index + 1] == ':') {
headers.setHost(absl::StrCat(host.substr(0, dot_index), host.substr(dot_index + 1)));
}
Expand Down Expand Up @@ -386,8 +392,8 @@ absl::optional<uint32_t> HeaderUtility::stripPortFromHost(RequestHeaderMap& head
return absl::nullopt;
}
if (listener_port.has_value() && port != listener_port) {
// We would strip ports only if it is specified and they are the same, as local port of the
// listener.
// We would strip ports only if it is specified and they are the same, as
// local port of the listener.
return absl::nullopt;
}
const absl::string_view host = original_host.substr(0, port_start);
Expand Down Expand Up @@ -454,8 +460,8 @@ HeaderUtility::requestHeadersValid(const RequestHeaderMap& headers) {

bool HeaderUtility::shouldCloseConnection(Http::Protocol protocol,
const RequestOrResponseHeaderMap& headers) {
// HTTP/1.0 defaults to single-use connections. Make sure the connection will be closed unless
// Keep-Alive is present.
// HTTP/1.0 defaults to single-use connections. Make sure the connection will
// be closed unless Keep-Alive is present.
if (protocol == Protocol::Http10 &&
(!headers.Connection() ||
!Envoy::StringUtil::caseFindToken(headers.Connection()->value().getStringView(), ",",
Expand Down Expand Up @@ -493,12 +499,14 @@ Http::Status HeaderUtility::checkRequiredRequestHeaders(const Http::RequestHeade
absl::StrCat("missing required header: ", Envoy::Http::Headers::get().Host.get()));
}
if (headers.Path() && !headers.Protocol()) {
// Path and Protocol header should only be present for CONNECT for upgrade style CONNECT.
// Path and Protocol header should only be present for CONNECT for upgrade
// style CONNECT.
return absl::InvalidArgumentError(
absl::StrCat("missing required header: ", Envoy::Http::Headers::get().Protocol.get()));
}
if (!headers.Path() && headers.Protocol()) {
// Path and Protocol header should only be present for CONNECT for upgrade style CONNECT.
// Path and Protocol header should only be present for CONNECT for upgrade
// style CONNECT.
return absl::InvalidArgumentError(
absl::StrCat("missing required header: ", Envoy::Http::Headers::get().Path.get()));
}
Expand Down Expand Up @@ -536,8 +544,8 @@ Http::Status HeaderUtility::checkValidRequestHeaders(const Http::RequestHeaderMa
});

if (invalid_entry) {
// The header key may contain non-printable characters. Escape the key so that the error
// details can be safely presented.
// The header key may contain non-printable characters. Escape the key so
// that the error details can be safely presented.
const absl::string_view key = invalid_entry->key().getStringView();
uint64_t extra_length = JsonEscaper::extraSpace(key);
const std::string escaped_key = JsonEscaper::escapeString(key, extra_length);
Expand Down Expand Up @@ -607,10 +615,10 @@ HeaderUtility::validateContentLength(absl::string_view header_value,
continue;
}
if (new_value != content_length.value()) {
ENVOY_LOG_MISC(
debug,
"Parsed content length {} is inconsistent with previously detected content length {}",
new_value, content_length.value());
ENVOY_LOG_MISC(debug,
"Parsed content length {} is inconsistent with previously "
"detected content length {}",
new_value, content_length.value());
should_close_connection = !override_stream_error_on_invalid_http_message;
return HeaderValidationResult::REJECT;
}
Expand Down Expand Up @@ -649,9 +657,9 @@ std::string HeaderUtility::addEncodingToAcceptEncoding(absl::string_view accept_
absl::string_view strippedEncoding =
Http::HeaderUtility::getSemicolonDelimitedAttribute(contentEncoding);
if (strippedEncoding != encoding) {
// Add back all content encodings back except for the content encoding that we want to
// add. For example, if content encoding is "gzip", this filters out encodings "gzip" and
// "gzip;q=0.6".
// Add back all content encodings back except for the content encoding
// that we want to add. For example, if content encoding is "gzip", this
// filters out encodings "gzip" and "gzip;q=0.6".
newContentEncodings.push_back(contentEncoding);
}
}
Expand All @@ -674,5 +682,21 @@ bool HeaderUtility::isPseudoHeader(absl::string_view header_name) {
return !header_name.empty() && header_name[0] == ':';
}

void HeaderUtility::checkHeaderValueForObsText(absl::string_view header_value,
HeaderValidatorStats& stats) {
if (headerValueContainsObsText(header_value)) {
stats.incRequestsWithObsText();
}
}

bool HeaderUtility::headerValueContainsObsText(absl::string_view header_value) {
for (char c : header_value) {
if (static_cast<uint8_t>(c) >= 0x80) {
return true;
}
}
return false;
}

} // namespace Http
} // namespace Envoy
11 changes: 11 additions & 0 deletions source/common/http/header_utility.h
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,17 @@ class HeaderUtility {
* Return true if the given header name is a pseudo header.
*/
static bool isPseudoHeader(absl::string_view header_name);

/**
* Log stats if the given header value contains obs-text.
*/
static void checkHeaderValueForObsText(absl::string_view header_value,
HeaderValidatorStats& stats);

/**
* Return true if the given header value contains obs-text.
*/
static bool headerValueContainsObsText(absl::string_view header_value);
};

} // namespace Http
Expand Down
1 change: 1 addition & 0 deletions source/common/http/http1/codec_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,7 @@ Status ConnectionImpl::onHeaderValueImpl(const char* data, size_t length) {
}

absl::string_view header_value{data, length};
Http::HeaderUtility::checkHeaderValueForObsText(header_value, stats_);
if (!Http::HeaderUtility::headerValueIsValid(header_value)) {
ENVOY_CONN_LOG(debug, "invalid header value: {}", connection_, header_value);
error_code_ = Http::Code::BadRequest;
Expand Down
4 changes: 3 additions & 1 deletion source/common/http/http1/codec_stats.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ namespace Http1 {
COUNTER(dropped_headers_with_underscores) \
COUNTER(metadata_not_supported_error) \
COUNTER(requests_rejected_with_underscores_in_headers) \
COUNTER(response_flood)
COUNTER(response_flood) \
COUNTER(requests_with_obs_text)

/**
* Wrapper struct for the HTTP/1 codec stats. @see stats_macros.h
Expand All @@ -41,6 +42,7 @@ struct CodecStats : public ::Envoy::Http::HeaderValidatorStats {
}
// TODO(yanavlasov): add corresponding counter for H/1 codec.
void incMessagingError() override {}
void incRequestsWithObsText() override { requests_with_obs_text_.inc(); }

ALL_HTTP1_CODEC_STATS(GENERATE_COUNTER_STRUCT)
};
Expand Down
4 changes: 4 additions & 0 deletions source/common/http/http2/codec_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2082,6 +2082,8 @@ ConnectionImpl::Http2Options::Http2Options(
og_options_.max_header_field_size = max_headers_kb * 1024;
og_options_.allow_extended_connect = http2_options.allow_connect();
og_options_.allow_different_host_and_authority = true;
og_options_.allow_obs_text =
!PROTOBUF_GET_WRAPPED_OR_DEFAULT(http2_options, disallow_obs_text, false);
if (!PROTOBUF_GET_WRAPPED_OR_DEFAULT(http2_options, enable_huffman_encoding, true)) {
if (http2_options.has_hpack_table_size() && http2_options.hpack_table_size().value() == 0) {
og_options_.compression_option = http2::adapter::OgHttp2Session::Options::DISABLE_COMPRESSION;
Expand Down Expand Up @@ -2354,6 +2356,7 @@ Status ClientConnectionImpl::onBeginHeaders(int32_t stream_id) {

int ClientConnectionImpl::onHeader(int32_t stream_id, HeaderString&& name, HeaderString&& value) {
ASSERT(connection_.state() == Network::Connection::State::Open);
Http::HeaderUtility::checkHeaderValueForObsText(value.getStringView(), stats_);
return saveHeader(stream_id, std::move(name), std::move(value));
}

Expand Down Expand Up @@ -2432,6 +2435,7 @@ Status ServerConnectionImpl::onBeginHeaders(int32_t stream_id) {
}

int ServerConnectionImpl::onHeader(int32_t stream_id, HeaderString&& name, HeaderString&& value) {
Http::HeaderUtility::checkHeaderValueForObsText(value.getStringView(), stats_);
if (Runtime::runtimeFeatureEnabled("envoy.reloadable_features.http2_discard_host_header")) {
StreamImpl* stream = getStreamUnchecked(stream_id);
if (stream && name == static_cast<absl::string_view>(Http::Headers::get().HostLegacy)) {
Expand Down
2 changes: 2 additions & 0 deletions source/common/http/http2/codec_stats.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ namespace Http2 {
COUNTER(outbound_control_flood) \
COUNTER(outbound_flood) \
COUNTER(requests_rejected_with_underscores_in_headers) \
COUNTER(requests_with_obs_text) \
COUNTER(rx_messaging_error) \
COUNTER(rx_reset) \
COUNTER(stream_refused_errors) \
Expand Down Expand Up @@ -60,6 +61,7 @@ struct CodecStats : public ::Envoy::Http::HeaderValidatorStats {
requests_rejected_with_underscores_in_headers_.inc();
}
void incMessagingError() override { rx_messaging_error_.inc(); }
void incRequestsWithObsText() override { requests_with_obs_text_.inc(); }

ALL_HTTP2_CODEC_STATS(GENERATE_COUNTER_STRUCT, GENERATE_GAUGE_STRUCT)
};
Expand Down
4 changes: 3 additions & 1 deletion source/common/http/http3/codec_stats.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ namespace Http3 {
COUNTER(metadata_not_supported_error) \
COUNTER(quic_version_h3_29) \
COUNTER(quic_version_rfc_v1) \
COUNTER(tx_flush_timeout)
COUNTER(tx_flush_timeout) \
COUNTER(requests_with_obs_text)

/**
* Wrapper struct for the HTTP/3 codec stats. @see stats_macros.h
Expand All @@ -47,6 +48,7 @@ struct CodecStats : public ::Envoy::Http::HeaderValidatorStats {
}
// TODO(yanavlasov): add corresponding counter for H/3 codec.
void incMessagingError() override {}
void incRequestsWithObsText() override { requests_with_obs_text_.inc(); }

ALL_HTTP3_CODEC_STATS(GENERATE_COUNTER_STRUCT, GENERATE_GAUGE_STRUCT)
};
Expand Down
Loading
Loading