diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d48253e..21fde014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/include/elio/http/http2_client.hpp b/include/elio/http/http2_client.hpp index c47450eb..675cbca2 100644 --- a/include/elio/http/http2_client.hpp +++ b/include/elio/http/http2_client.hpp @@ -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: @@ -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 arm_tls_io_watchdog(runtime::scheduler* sched, tls::tls_stream* stream, @@ -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; diff --git a/include/elio/http/http2_session.hpp b/include/elio/http/http2_session.hpp index 0727fa63..f27e52c1 100644 --- a/include/elio/http/http2_session.hpp +++ b/include/elio/http/http2_session.hpp @@ -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; @@ -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 { @@ -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(); } } @@ -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. @@ -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 { @@ -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); } @@ -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; @@ -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 { @@ -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; } diff --git a/tests/unit/test_http2.cpp b/tests/unit/test_http2.cpp index 09413fd0..6d5a0b87 100644 --- a/tests/unit/test_http2.cpp +++ b/tests/unit/test_http2.cpp @@ -316,6 +316,163 @@ TEST_CASE("HTTP/2 stream response body enforces max size", REQUIRE(stream.closed); } +TEST_CASE("HTTP/2 response headers enforce per-stream limits", + "[http2][security][headers]") { + SECTION("exact count and byte boundaries accept duplicate field lines") { + h2_stream stream; + + REQUIRE(stream.append_response_header("a", "1", 2, 4)); + REQUIRE(stream.append_response_header("a", "2", 2, 4)); + REQUIRE(stream.response_header_count == 2); + REQUIRE(stream.response_header_bytes == 4); + REQUIRE(stream.response_headers.get_all("a").size() == 2); + REQUIRE_FALSE(stream.response_headers_exceeded); + + REQUIRE_FALSE(stream.append_response_header("b", "", 2, 4)); + REQUIRE(stream.response_headers_exceeded); + REQUIRE(stream.error == h2_error::enhance_your_calm); + REQUIRE(detail::h2_stream_errno(stream) == EMSGSIZE); + REQUIRE_FALSE(stream.response_headers.contains("b")); + REQUIRE(stream.response_header_count == 2); + REQUIRE(stream.response_header_bytes == 4); + } + + SECTION("one field over the count limit is rejected independently") { + h2_stream stream; + + REQUIRE(stream.append_response_header("a", "1", 1, 100)); + REQUIRE_FALSE(stream.append_response_header("b", "2", 1, 100)); + REQUIRE(stream.response_header_count == 1); + REQUIRE(stream.response_header_bytes == 2); + REQUIRE_FALSE(stream.response_headers.contains("b")); + REQUIRE(stream.response_headers_exceeded); + REQUIRE(detail::h2_stream_errno(stream) == EMSGSIZE); + } + + SECTION("one byte over the aggregate limit is rejected before copying") { + h2_stream stream; + + REQUIRE(stream.append_response_header("abc", "de", 10, 5)); + REQUIRE(stream.response_header_bytes == 5); + REQUIRE_FALSE(stream.append_response_header("f", "", 10, 5)); + REQUIRE(stream.response_header_bytes == 5); + REQUIRE_FALSE(stream.response_headers.contains("f")); + REQUIRE(stream.response_headers_exceeded); + } + + SECTION("one oversized field is rejected without size overflow") { + h2_stream stream; + + REQUIRE_FALSE(stream.append_response_header("abc", "def", 10, 5)); + REQUIRE(stream.response_header_count == 0); + REQUIRE(stream.response_header_bytes == 0); + REQUIRE(stream.response_headers.empty()); + REQUIRE(stream.response_headers_exceeded); + } +} + +TEST_CASE("HTTP/2 header-limit reset remains stream-local", + "[http2][security][headers]") { + nghttp2_session_callbacks* callbacks = nullptr; + REQUIRE(nghttp2_session_callbacks_new(&callbacks) == 0); + nghttp2_session* client = nullptr; + REQUIRE(nghttp2_session_client_new(&client, callbacks, nullptr) == 0); + nghttp2_session_callbacks_del(callbacks); + + using session_ptr = + std::unique_ptr; + session_ptr client_owner(client, nghttp2_session_del); + const auto discard_available_client_output = [&] { + while (true) { + const uint8_t* data = nullptr; + const ssize_t len = nghttp2_session_mem_send(client, &data); + if (len < 0) { + return false; + } + if (len == 0) { + return true; + } + } + }; + + nghttp2_settings_entry settings[] = { + {NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS, 100}}; + REQUIRE(nghttp2_submit_settings(client, NGHTTP2_FLAG_NONE, settings, 1) == + 0); + + const auto nv = [](std::string_view name, std::string_view value) { + return nghttp2_nv{ + reinterpret_cast(const_cast(name.data())), + reinterpret_cast(const_cast(value.data())), + name.size(), value.size(), NGHTTP2_NV_FLAG_NONE}; + }; + std::array request_headers{ + nv(":method", "GET"), nv(":scheme", "https"), + nv(":authority", "example.test"), nv(":path", "/")}; + const int32_t limited_stream = nghttp2_submit_request( + client, nullptr, request_headers.data(), request_headers.size(), + nullptr, nullptr); + const int32_t sibling_stream = nghttp2_submit_request( + client, nullptr, request_headers.data(), request_headers.size(), + nullptr, nullptr); + REQUIRE(limited_stream > 0); + REQUIRE(sibling_stream > limited_stream); + REQUIRE(discard_available_client_output()); + + REQUIRE(detail::reject_excess_h2_response_headers(client, + limited_stream) == + NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE); + const uint8_t* reset_data = nullptr; + const ssize_t reset_len = nghttp2_session_mem_send(client, &reset_data); + REQUIRE(reset_len == 13); + REQUIRE(reset_data[3] == NGHTTP2_RST_STREAM); + const uint32_t reset_stream = + (static_cast(reset_data[5] & 0x7f) << 24) | + (static_cast(reset_data[6]) << 16) | + (static_cast(reset_data[7]) << 8) | + static_cast(reset_data[8]); + const uint32_t reset_error = + (static_cast(reset_data[9]) << 24) | + (static_cast(reset_data[10]) << 16) | + (static_cast(reset_data[11]) << 8) | + static_cast(reset_data[12]); + REQUIRE(reset_stream == static_cast(limited_stream)); + REQUIRE(reset_error == NGHTTP2_ENHANCE_YOUR_CALM); + + const int32_t followup_stream = nghttp2_submit_request( + client, nullptr, request_headers.data(), request_headers.size(), + nullptr, nullptr); + REQUIRE(followup_stream > sibling_stream); + REQUIRE(nghttp2_session_want_read(client)); +} + +TEST_CASE("HTTP/2 response header accounting resets informational blocks only", + "[http2][security][headers]") { + h2_stream stream; + + detail::begin_h2_response_header_block(stream, NGHTTP2_HCAT_RESPONSE); + REQUIRE(detail::record_h2_response_status(stream, "103")); + REQUIRE(stream.append_response_header("link", "a", 1, 16)); + REQUIRE(detail::finish_h2_response_header_block(stream)); + REQUIRE(stream.response_header_count == 1); + + detail::begin_h2_response_header_block(stream, NGHTTP2_HCAT_HEADERS); + REQUIRE(stream.response_headers.empty()); + REQUIRE(stream.response_header_count == 0); + REQUIRE(stream.response_header_bytes == 0); + REQUIRE(detail::record_h2_response_status(stream, "200")); + REQUIRE(stream.append_response_header("etag", "b", 1, 16)); + REQUIRE(detail::finish_h2_response_header_block(stream)); + + detail::begin_h2_response_header_block(stream, NGHTTP2_HCAT_HEADERS); + REQUIRE_FALSE(stream.current_header_status_required); + REQUIRE_FALSE(stream.append_response_header("trailer", "c", 1, 16)); + REQUIRE(stream.response_headers.contains("etag")); + REQUIRE_FALSE(stream.response_headers.contains("trailer")); + REQUIRE(stream.response_headers_exceeded); + REQUIRE(stream.error == h2_error::enhance_your_calm); +} + TEST_CASE("HTTP/2 response materialization preserves peer headers", "[http2][message]") { SECTION("Peer Content-Length is not rewritten") { @@ -366,11 +523,30 @@ TEST_CASE("HTTP/2 response materialization preserves peer headers", } TEST_CASE("HTTP/2 client configuration", "[http2][config]") { + SECTION("new limits preserve legacy positional aggregate initialization") { + h2_session_config session_cfg{ + 7, 32768, 4096, "legacy-session-agent", true}; + REQUIRE(session_cfg.user_agent == "legacy-session-agent"); + REQUIRE(session_cfg.enable_push); + REQUIRE(session_cfg.max_response_headers == 100); + REQUIRE(session_cfg.max_response_header_bytes == 64 * 1024); + + h2_client_config client_cfg{ + std::chrono::seconds{1}, std::chrono::seconds{2}, + 7, 32768, 4096, "legacy-client-agent", true}; + REQUIRE(client_cfg.user_agent == "legacy-client-agent"); + REQUIRE(client_cfg.enable_push); + REQUIRE(client_cfg.max_response_headers == 100); + REQUIRE(client_cfg.max_response_header_bytes == 64 * 1024); + } + SECTION("session config defaults match documented client defaults") { h2_session_config cfg; REQUIRE(cfg.max_concurrent_streams == 100); REQUIRE(cfg.initial_window_size == NGHTTP2_INITIAL_WINDOW_SIZE); REQUIRE(cfg.max_response_size == 16 * 1024 * 1024); + REQUIRE(cfg.max_response_headers == 100); + REQUIRE(cfg.max_response_header_bytes == 64 * 1024); REQUIRE(cfg.user_agent == "elio-http2/1.0"); REQUIRE_FALSE(cfg.enable_push); } @@ -380,6 +556,8 @@ TEST_CASE("HTTP/2 client configuration", "[http2][config]") { cfg.max_concurrent_streams = 7; cfg.initial_window_size = 32768; cfg.max_response_size = 4096; + cfg.max_response_headers = 17; + cfg.max_response_header_bytes = 8192; cfg.user_agent = "elio-test-agent/1.0"; cfg.enable_push = true; @@ -388,9 +566,22 @@ TEST_CASE("HTTP/2 client configuration", "[http2][config]") { REQUIRE(client.config().max_concurrent_streams == 7); REQUIRE(client.config().initial_window_size == 32768); REQUIRE(client.config().max_response_size == 4096); + REQUIRE(client.config().max_response_headers == 17); + REQUIRE(client.config().max_response_header_bytes == 8192); REQUIRE(client.config().user_agent == "elio-test-agent/1.0"); REQUIRE(client.config().enable_push); } + + SECTION("client response-header limits propagate to the session") { + h2_client_config client_cfg; + client_cfg.max_response_headers = 9; + client_cfg.max_response_header_bytes = 1234; + + auto session_cfg = detail::make_h2_session_config(client_cfg); + + REQUIRE(session_cfg.max_response_headers == 9); + REQUIRE(session_cfg.max_response_header_bytes == 1234); + } } TEST_CASE("HTTP/2 custom send rejects non-HTTPS targets", diff --git a/wiki/API-Contracts.md b/wiki/API-Contracts.md index d81ec6a8..52cc76ac 100644 --- a/wiki/API-Contracts.md +++ b/wiki/API-Contracts.md @@ -177,8 +177,8 @@ requests does not make their higher-level ordering or object lifetime safe. |-----------|-----------------|-----------------------| | `http::h2_client` | Negotiates TLS/ALPN where configured, initializes nghttp2 session state, validates frames through nghttp2, and reports response or error results. | Avoid concurrent use of one high-level client unless a future API documents shared-session multiplexing. Use separate clients for parallel application work. | | `http::h2_get()` and `http::h2_post()` | Create a default HTTP/2 client request path and return an optional response according to client failure semantics. | Treat them as convenience calls without application retry/authentication policy. Use explicit clients/configs when limits, deadlines, or certificates matter. | -| `http::h2_client_config` | Applies configured connect/read deadlines, body limits, and HTTP/2 settings where supported. | Choose limits/settings that match peer behavior and workload. | -| `http::h2_session` | Encapsulates nghttp2 session interaction and translates protocol validation to Elio result paths. | Treat direct session use as lower-level than `h2_client`; preserve stream/session ownership and callback lifetimes. | +| `http::h2_client_config` | Applies configured connect/read deadlines, per-stream response body/header limits, and HTTP/2 settings where supported. Header count and name/value-byte limits cover final headers plus trailers; discarded informational blocks reset accounting. | Choose finite limits/settings that match peer behavior and workload. | +| `http::h2_session` | Encapsulates nghttp2 session interaction and translates protocol validation to Elio result paths. A response-header limit breach rejects before copying, resets only the offending stream with `ENHANCE_YOUR_CALM`, and reports `EMSGSIZE`. | Treat direct session use as lower-level than `h2_client`; preserve stream/session ownership and callback lifetimes. Configure both response-header limits when overriding session defaults. | | nghttp2 package integration | Uses the configured bundled or system nghttp2 provider for HTTP/2 protocol handling. | Provide the dependency through the documented source, package, or install mode. Handle provider-specific deployment constraints. | ## WebSocket And SSE diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md index db9cc89b..94e652cd 100644 --- a/wiki/API-Reference.md +++ b/wiki/API-Reference.md @@ -2847,13 +2847,24 @@ struct h2_client_config { // pushed responses are not exposed net::resolve_options resolve_options = net::default_cached_resolve_options(); bool rotate_resolved_addresses = true; + size_t max_response_headers = 100; // Max accepted field lines + size_t max_response_header_bytes = 64 * 1024; // Max accepted name/value bytes }; ``` +The two response-header limits apply per stream to regular field lines. The +byte limit is the sum of accepted field-name and field-value lengths. Final +headers and trailers share the budget; discarded informational blocks do not. +A limit breach resets only the offending stream with +`NGHTTP2_ENHANCE_YOUR_CALM` and the request fails with `errno == EMSGSIZE`. + ### `h2_session` Low-level HTTP/2 session (for advanced use). +`h2_session_config` exposes the same `max_response_headers` and +`max_response_header_bytes` defaults for direct session users. + ```cpp class h2_session { public: diff --git a/wiki/HTTP2-Guide.md b/wiki/HTTP2-Guide.md index 124f3fa2..8d199674 100644 --- a/wiki/HTTP2-Guide.md +++ b/wiki/HTTP2-Guide.md @@ -107,6 +107,8 @@ struct h2_client_config { // pushed responses are not exposed net::resolve_options resolve_options = net::default_cached_resolve_options(); bool rotate_resolved_addresses = true; + size_t max_response_headers = 100; // Max accepted field lines per stream + size_t max_response_header_bytes = 64 * 1024; // Max accepted name/value bytes per stream }; // Usage @@ -121,6 +123,12 @@ h2_client client(config); `max_response_size` bounds the accumulated DATA payload stored in the returned `http::response`; responses exceeding the limit fail instead of continuing to buffer in memory. +`max_response_headers` and `max_response_header_bytes` bound accepted regular +response field lines per stream. Informational response fields are discarded +and reset the live budget when the next response block begins; final response +headers and trailers share one budget. Exceeding either limit rejects the field, +resets only that stream with `ENHANCE_YOUR_CALM`, and makes the request fail with +`errno == EMSGSIZE`. `enable_push` only controls the HTTP/2 `SETTINGS_ENABLE_PUSH` value sent to the peer. Elio does not currently expose pushed responses through the public client API, so applications cannot observe or consume server-pushed streams. diff --git a/wiki/Networking.md b/wiki/Networking.md index 92d3cf76..d68b3a4c 100644 --- a/wiki/Networking.md +++ b/wiki/Networking.md @@ -486,6 +486,8 @@ coro::task advanced_h2_client() { h2_client_config config; config.user_agent = "MyApp/1.0"; config.max_concurrent_streams = 100; + config.max_response_headers = 100; + config.max_response_header_bytes = 64 * 1024; h2_client client(config);