Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Non-blocking epoll connect precondition**: the epoll backend now rejects
`async_connect()` on a blocking socket with `-EINVAL` before `connect(2)` can
block a scheduler worker. Direct callers must set `O_NONBLOCK` first (#997).
- **Bounded HTTP/2 response headers**: HTTP/2 clients now enforce finite
per-stream response field-count and name/value-byte limits across final
headers and trailers, resetting an offending stream before the field is
copied (#998).
- **Bounded raw WebSocket parsing by default**: `websocket::frame_parser` now
applies the same 16 MiB aggregate message limit as the high-level client and
server configurations. Direct parser users must explicitly set a limit of
Expand Down
33 changes: 21 additions & 12 deletions include/elio/http/http2_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,27 @@ struct h2_client_config {
bool enable_push = false; ///< Advertise SETTINGS_ENABLE_PUSH; pushed responses are not exposed
net::resolve_options resolve_options = net::default_cached_resolve_options(); ///< DNS resolve/cache behavior
bool rotate_resolved_addresses = true; ///< Rotate start index across resolved addresses
size_t max_response_headers = 100; ///< Max accepted response field lines per stream
size_t max_response_header_bytes = 64 * 1024; ///< Max accepted response name/value bytes per stream
};

namespace detail {

inline h2_session_config make_h2_session_config(
const h2_client_config& config) {
return h2_session_config{
.max_concurrent_streams = config.max_concurrent_streams,
.initial_window_size = config.initial_window_size,
.max_response_size = config.max_response_size,
.user_agent = config.user_agent,
.enable_push = config.enable_push,
.max_response_headers = config.max_response_headers,
.max_response_header_bytes = config.max_response_header_bytes,
};
}

} // namespace detail

/// HTTP/2 connection wrapper
class h2_connection {
public:
Expand Down Expand Up @@ -203,17 +222,6 @@ class h2_client {
const h2_client_config& config() const noexcept { return config_; }

private:
static h2_session_config make_session_config(
const h2_client_config& config) {
return h2_session_config{
.max_concurrent_streams = config.max_concurrent_streams,
.initial_window_size = config.initial_window_size,
.max_response_size = config.max_response_size,
.user_agent = config.user_agent,
.enable_push = config.enable_push,
};
}

static coro::join_handle<void>
arm_tls_io_watchdog(runtime::scheduler* sched,
tls::tls_stream* stream,
Expand Down Expand Up @@ -351,7 +359,8 @@ class h2_client {

ELIO_LOG_DEBUG("HTTP/2 connection established to {}:{}", host, port);

h2_connection conn(std::move(*stream), make_session_config(config_));
h2_connection conn(std::move(*stream),
detail::make_h2_session_config(config_));
if (!co_await process_with_timeout(conn, host, port)) {
ELIO_LOG_ERROR("HTTP/2 session initialization failed");
co_return std::nullopt;
Expand Down
108 changes: 97 additions & 11 deletions include/elio/http/http2_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ struct h2_stream {
bool closed = false;
h2_error error = h2_error::none;
bool response_size_exceeded = false;
size_t response_header_count = 0;
size_t response_header_bytes = 0;
bool response_headers_exceeded = false;

bool is_complete() const noexcept {
return headers_complete && body_complete;
Expand All @@ -80,6 +83,45 @@ struct h2_stream {
response_body.append(data);
return true;
}

void reset_response_headers() {
response_headers.clear();
response_header_count = 0;
response_header_bytes = 0;
}

bool append_response_header(std::string_view name,
std::string_view value,
size_t max_response_headers,
size_t max_response_header_bytes) {
if (error != h2_error::none) {
return false;
}

const bool count_exceeded =
response_header_count >= max_response_headers;
const bool field_bytes_exceeded =
name.size() > max_response_header_bytes ||
value.size() > max_response_header_bytes - name.size();
const size_t field_bytes = field_bytes_exceeded
? 0
: name.size() + value.size();
const bool aggregate_bytes_exceeded =
field_bytes_exceeded ||
response_header_bytes >
max_response_header_bytes - field_bytes;

if (count_exceeded || aggregate_bytes_exceeded) {
response_headers_exceeded = true;
error = h2_error::enhance_your_calm;
return false;
}

response_headers.add(name, value);
++response_header_count;
response_header_bytes += field_bytes;
return true;
}
};

namespace detail {
Expand Down Expand Up @@ -131,7 +173,10 @@ inline void begin_h2_response_header_block(h2_stream& stream,
h2_header_block_requires_status(stream, category);

if (stream.current_header_status_required) {
stream.response_headers.clear();
// A new informational/final response block replaces any retained
// informational headers. Final response headers and later trailers,
// by contrast, share one live per-stream budget.
stream.reset_response_headers();
}
}

Expand Down Expand Up @@ -177,6 +222,27 @@ inline response materialize_h2_response(h2_stream& stream) {
return resp;
}

inline int h2_stream_errno(const h2_stream& stream) noexcept {
if (stream.response_size_exceeded ||
stream.response_headers_exceeded) {
return EMSGSIZE;
}
if (stream.error == h2_error::protocol_error) {
return EPROTO;
}
return 0;
}

inline int reject_excess_h2_response_headers(nghttp2_session* session,
int32_t stream_id) noexcept {
if (session &&
nghttp2_submit_rst_stream(session, NGHTTP2_FLAG_NONE, stream_id,
NGHTTP2_ENHANCE_YOUR_CALM) != 0) {
return NGHTTP2_ERR_CALLBACK_FAILURE;
}
return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
}

} // namespace detail

/// Configuration used when creating an HTTP/2 session.
Expand All @@ -186,6 +252,8 @@ struct h2_session_config {
size_t max_response_size = 16 * 1024 * 1024;
std::string user_agent = "elio-http2/1.0";
bool enable_push = false; ///< Advertise SETTINGS_ENABLE_PUSH; pushed responses are not exposed
size_t max_response_headers = 100;
size_t max_response_header_bytes = 64 * 1024;
};

namespace detail {
Expand Down Expand Up @@ -441,10 +509,10 @@ class h2_session {
it = streams_.find(stream_id);
if (it != streams_.end() &&
it->second.error != h2_error::none) {
if (it->second.response_size_exceeded) {
errno = EMSGSIZE;
} else if (it->second.error == h2_error::protocol_error) {
errno = EPROTO;
if (const int stream_errno =
detail::h2_stream_errno(it->second);
stream_errno != 0) {
errno = stream_errno;
}
streams_.erase(it);
}
Expand All @@ -459,10 +527,9 @@ class h2_session {
auto& stream = it->second;

if (stream.error != h2_error::none) {
if (stream.response_size_exceeded) {
errno = EMSGSIZE;
} else if (stream.error == h2_error::protocol_error) {
errno = EPROTO;
if (const int stream_errno = detail::h2_stream_errno(stream);
stream_errno != 0) {
errno = stream_errno;
}
streams_.erase(it);
co_return std::nullopt;
Expand Down Expand Up @@ -639,7 +706,8 @@ class h2_session {
return 0;
}

static int on_header_callback(nghttp2_session*, const nghttp2_frame* frame,
static int on_header_callback(nghttp2_session* session,
const nghttp2_frame* frame,
const uint8_t* name, size_t namelen,
const uint8_t* value, size_t valuelen,
uint8_t, void* user_data) noexcept {
Expand All @@ -666,7 +734,25 @@ class h2_session {
// offending stream (RST_STREAM) instead of silently
// dropping the header.
try {
stream->response_headers.add(name_sv, value_sv);
if (!stream->append_response_header(
name_sv, value_sv,
self->config_.max_response_headers,
self->config_.max_response_header_bytes)) {
if (!stream->response_headers_exceeded) {
return NGHTTP2_ERR_CALLBACK_FAILURE;
}

ELIO_LOG_ERROR(
"HTTP/2 response headers on stream {} exceed "
"the per-stream limit (max fields {}, max "
"bytes {}; rejected field {} + {} bytes)",
frame->hd.stream_id,
self->config_.max_response_headers,
self->config_.max_response_header_bytes,
namelen, valuelen);
return detail::reject_excess_h2_response_headers(
session, frame->hd.stream_id);
}
} catch (...) {
return NGHTTP2_ERR_CALLBACK_FAILURE;
}
Expand Down
Loading