From 08c4648b3ceaee838a22771e129f79f9f6c0a7c5 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:26:03 +0700 Subject: [PATCH 01/15] feat: add response resource limits --- include/cpp_request/response_limits.hpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 include/cpp_request/response_limits.hpp diff --git a/include/cpp_request/response_limits.hpp b/include/cpp_request/response_limits.hpp new file mode 100644 index 0000000..cd9aeaf --- /dev/null +++ b/include/cpp_request/response_limits.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace cpp_request { + +struct ResponseLimits final { + static constexpr std::size_t default_max_head_bytes = 64u * 1024u; + static constexpr std::size_t default_max_body_bytes = 64u * 1024u * 1024u; + static constexpr std::size_t default_max_chunk_line_bytes = 8u * 1024u; + static constexpr std::size_t default_max_trailer_bytes = 64u * 1024u; + + std::size_t max_head_bytes{default_max_head_bytes}; + std::size_t max_body_bytes{default_max_body_bytes}; + std::size_t max_chunk_line_bytes{default_max_chunk_line_bytes}; + std::size_t max_trailer_bytes{default_max_trailer_bytes}; +}; + +} // namespace cpp_request From da38ac85b8e464e587c5ba2d8b2569750737168e Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:26:18 +0700 Subject: [PATCH 02/15] feat: expose configurable response limits --- include/cpp_request/client.hpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/include/cpp_request/client.hpp b/include/cpp_request/client.hpp index 2a8411d..6ec34fb 100644 --- a/include/cpp_request/client.hpp +++ b/include/cpp_request/client.hpp @@ -8,6 +8,7 @@ #include #include +#include #include namespace cpp_request { @@ -57,6 +58,14 @@ class Client final { max_redirects_ = count; } + void set_response_limits(ResponseLimits limits) noexcept { + response_limits_ = limits; + } + + [[nodiscard]] const ResponseLimits& response_limits() const noexcept { + return response_limits_; + } + private: [[nodiscard]] Result execute_once(const Request& request); void close_reusable_connection() noexcept; @@ -68,6 +77,7 @@ class Client final { bool follow_redirects_{true}; std::size_t max_redirects_{10}; + ResponseLimits response_limits_{}; std::chrono::milliseconds connect_timeout_{5000}; std::chrono::milliseconds read_timeout_{30000}; From c26a3505c7fb1cdf1abfef6ff18c297cf84baf61 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:26:28 +0700 Subject: [PATCH 03/15] feat: classify response limit failures --- include/cpp_request/error.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/cpp_request/error.hpp b/include/cpp_request/error.hpp index 7b178d6..f11bc87 100644 --- a/include/cpp_request/error.hpp +++ b/include/cpp_request/error.hpp @@ -27,6 +27,7 @@ enum class ErrorCode { InvalidChunkFraming, ConflictingMessageFraming, UnexpectedEof, + ResponseLimitExceeded, RedirectLimitExceeded, MissingRedirectLocation, @@ -62,6 +63,7 @@ struct Error { case ErrorCode::InvalidChunkFraming: return "invalid chunk framing"; case ErrorCode::ConflictingMessageFraming: return "conflicting HTTP message framing"; case ErrorCode::UnexpectedEof: return "unexpected end of stream"; + case ErrorCode::ResponseLimitExceeded: return "response resource limit exceeded"; case ErrorCode::RedirectLimitExceeded: return "redirect limit exceeded"; case ErrorCode::MissingRedirectLocation: return "redirect location missing"; case ErrorCode::UnsupportedRedirectScheme: return "unsupported redirect scheme"; From 23cfc9bc65fcaffcd9278def5892ffb741e66ec8 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:26:34 +0700 Subject: [PATCH 04/15] feat: thread response limits into chunk decoder --- src/http/chunked_decoder.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/http/chunked_decoder.hpp b/src/http/chunked_decoder.hpp index 384de49..e93ea54 100644 --- a/src/http/chunked_decoder.hpp +++ b/src/http/chunked_decoder.hpp @@ -3,6 +3,7 @@ #include #include +#include #include namespace cpp_request::detail::http { @@ -14,6 +15,9 @@ enum class ChunkDecodeProgress { class ChunkedDecoder final { public: + explicit ChunkedDecoder(ResponseLimits limits = {}) noexcept + : limits_(limits) {} + [[nodiscard]] Result process( std::string& input, std::string& output); @@ -27,8 +31,10 @@ class ChunkedDecoder final { Complete }; + ResponseLimits limits_{}; Stage stage_{Stage::SizeLine}; std::size_t chunk_remaining_{0}; + std::size_t trailer_bytes_seen_{0}; }; } // namespace cpp_request::detail::http From aaa4f4c8c34d1c1f740ec0c382c830d0077d36c4 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:27:10 +0700 Subject: [PATCH 05/15] feat: enforce chunked response limits --- src/http/chunked_decoder.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/http/chunked_decoder.cpp b/src/http/chunked_decoder.cpp index d3123ef..3aa0bdd 100644 --- a/src/http/chunked_decoder.cpp +++ b/src/http/chunked_decoder.cpp @@ -66,6 +66,13 @@ namespace { return ch == ' ' || ch == '\t'; } +[[nodiscard]] bool would_exceed( + std::size_t current, + std::size_t addition, + std::size_t limit) noexcept { + return current > limit || addition > limit - current; +} + [[nodiscard]] bool valid_field_name(std::string_view name) noexcept { if (name.empty()) { return false; @@ -268,10 +275,17 @@ Result ChunkedDecoder::process( case Stage::SizeLine: { const std::size_t line_end = input.find("\r\n", cursor); if (line_end == std::string::npos) { + if (input.size() - cursor > limits_.max_chunk_line_bytes) { + return Error{ErrorCode::ResponseLimitExceeded}; + } consume_prefix(input, cursor); return ChunkDecodeProgress::NeedMore; } + if (line_end - cursor > limits_.max_chunk_line_bytes) { + return Error{ErrorCode::ResponseLimitExceeded}; + } + const std::string_view line{ input.data() + cursor, line_end - cursor}; @@ -280,6 +294,11 @@ Result ChunkedDecoder::process( return size.error(); } + if (size.value() != 0 + && would_exceed(output.size(), size.value(), limits_.max_body_bytes)) { + return Error{ErrorCode::ResponseLimitExceeded}; + } + cursor = line_end + 2; chunk_remaining_ = size.value(); stage_ = chunk_remaining_ == 0 @@ -321,10 +340,26 @@ Result ChunkedDecoder::process( case Stage::Trailers: { const std::size_t line_end = input.find("\r\n", cursor); if (line_end == std::string::npos) { + const std::size_t pending = input.size() - cursor; + if (would_exceed( + trailer_bytes_seen_, + pending, + limits_.max_trailer_bytes)) { + return Error{ErrorCode::ResponseLimitExceeded}; + } consume_prefix(input, cursor); return ChunkDecodeProgress::NeedMore; } + const std::size_t line_bytes = line_end - cursor + 2; + if (would_exceed( + trailer_bytes_seen_, + line_bytes, + limits_.max_trailer_bytes)) { + return Error{ErrorCode::ResponseLimitExceeded}; + } + trailer_bytes_seen_ += line_bytes; + const std::string_view line{ input.data() + cursor, line_end - cursor}; From a016f943c305bc7a7d07d326a79c868b6d0ac4d8 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:27:19 +0700 Subject: [PATCH 06/15] feat: thread resource limits into response parser --- src/http/response_parser.hpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/http/response_parser.hpp b/src/http/response_parser.hpp index 44385ca..02ead56 100644 --- a/src/http/response_parser.hpp +++ b/src/http/response_parser.hpp @@ -8,6 +8,7 @@ #include #include +#include #include namespace cpp_request::detail::http { @@ -19,7 +20,9 @@ enum class ResponseParseProgress { class ResponseParser final { public: - explicit ResponseParser(Method request_method) noexcept; + explicit ResponseParser( + Method request_method, + ResponseLimits limits = {}) noexcept; [[nodiscard]] Result feed(std::string_view bytes); [[nodiscard]] Result finish_eof(); @@ -43,6 +46,7 @@ class ResponseParser final { [[nodiscard]] Result process_buffer(); Method request_method_; + ResponseLimits limits_{}; Stage stage_{Stage::Head}; Response response_; std::string buffer_; From 4affce796bae59bc1f9bc96c3d2f47c6a1bf5937 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:27:59 +0700 Subject: [PATCH 07/15] feat: enforce response head and body limits --- src/http/response_parser.cpp | 37 ++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/http/response_parser.cpp b/src/http/response_parser.cpp index 86edb5c..8a34230 100644 --- a/src/http/response_parser.cpp +++ b/src/http/response_parser.cpp @@ -112,6 +112,13 @@ struct FramingInfo { return value; } +[[nodiscard]] bool would_exceed( + std::size_t current, + std::size_t addition, + std::size_t limit) noexcept { + return current > limit || addition > limit - current; +} + [[nodiscard]] bool contains_token( std::string_view value, std::string_view expected) noexcept { @@ -313,8 +320,12 @@ struct FramingInfo { } // namespace -ResponseParser::ResponseParser(Method request_method) noexcept - : request_method_(request_method) {} +ResponseParser::ResponseParser( + Method request_method, + ResponseLimits limits) noexcept + : request_method_(request_method), + limits_(limits), + chunked_decoder_(limits) {} Result ResponseParser::feed(std::string_view bytes) { if (!bytes.empty()) { @@ -329,9 +340,16 @@ Result ResponseParser::process_buffer() { case Stage::Head: { const std::size_t head_end = buffer_.find("\r\n\r\n"); if (head_end == std::string::npos) { + if (buffer_.size() > limits_.max_head_bytes) { + return Error{ErrorCode::ResponseLimitExceeded}; + } return ResponseParseProgress::NeedMore; } + if (would_exceed(head_end, 4, limits_.max_head_bytes)) { + return Error{ErrorCode::ResponseLimitExceeded}; + } + auto parsed = parse_head(std::string_view{buffer_.data(), head_end}); if (!parsed) { return parsed.error(); @@ -411,6 +429,9 @@ Result ResponseParser::process_buffer() { } if (framing.content_length_count == 1) { + if (framing.content_length > limits_.max_body_bytes) { + return Error{ErrorCode::ResponseLimitExceeded}; + } content_length_remaining_ = framing.content_length; response_.body_.reserve(framing.content_length); if (content_length_remaining_ == 0) { @@ -448,6 +469,12 @@ Result ResponseParser::process_buffer() { return Error{ErrorCode::MalformedResponse}; } if (!buffer_.empty()) { + if (would_exceed( + response_.body_.size(), + buffer_.size(), + limits_.max_body_bytes)) { + return Error{ErrorCode::ResponseLimitExceeded}; + } response_.body_.append(buffer_); buffer_.clear(); } @@ -481,6 +508,12 @@ Result ResponseParser::finish_eof() { return Error{ErrorCode::MalformedResponse}; } if (!buffer_.empty()) { + if (would_exceed( + response_.body_.size(), + buffer_.size(), + limits_.max_body_bytes)) { + return Error{ErrorCode::ResponseLimitExceeded}; + } response_.body_.append(buffer_); buffer_.clear(); } From d60d72bf165c1558b1f556628cba084f37c41d56 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:29:03 +0700 Subject: [PATCH 08/15] feat: apply client response limits to parser --- src/core/client.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/client.cpp b/src/core/client.cpp index 047991b..38015d5 100644 --- a/src/core/client.cpp +++ b/src/core/client.cpp @@ -169,6 +169,7 @@ Client::Client(Client&& other) noexcept reusable_port_(std::exchange(other.reusable_port_, 0)), follow_redirects_(other.follow_redirects_), max_redirects_(other.max_redirects_), + response_limits_(other.response_limits_), connect_timeout_(other.connect_timeout_), read_timeout_(other.read_timeout_), write_timeout_(other.write_timeout_) {} @@ -186,6 +187,7 @@ Client& Client::operator=(Client&& other) noexcept { reusable_port_ = std::exchange(other.reusable_port_, 0); follow_redirects_ = other.follow_redirects_; max_redirects_ = other.max_redirects_; + response_limits_ = other.response_limits_; connect_timeout_ = other.connect_timeout_; read_timeout_ = other.read_timeout_; write_timeout_ = other.write_timeout_; @@ -350,7 +352,9 @@ Result Client::execute_once(const Request& request_value) { } } - detail::http::ResponseParser parser{request_value.method()}; + detail::http::ResponseParser parser{ + request_value.method(), + response_limits_}; std::array read_buffer{}; while (!parser.complete()) { From a29d39e3f208257d07781300939a20988b50c106 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:29:32 +0700 Subject: [PATCH 09/15] test: cover response resource limits --- tests/response_limits_test.cpp | 148 +++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/response_limits_test.cpp diff --git a/tests/response_limits_test.cpp b/tests/response_limits_test.cpp new file mode 100644 index 0000000..cbd9d29 --- /dev/null +++ b/tests/response_limits_test.cpp @@ -0,0 +1,148 @@ +#include + +#include "http/response_parser.hpp" + +#include +#include +#include +#include + +namespace { + +using cpp_request::Client; +using cpp_request::ErrorCode; +using cpp_request::Method; +using cpp_request::ResponseLimits; +using cpp_request::detail::http::ResponseParseProgress; +using cpp_request::detail::http::ResponseParser; + +TEST(ResponseLimitsTest, DefaultsAreFiniteAndNonZero) { + const ResponseLimits limits; + + EXPECT_GT(limits.max_head_bytes, 0u); + EXPECT_GT(limits.max_body_bytes, 0u); + EXPECT_GT(limits.max_chunk_line_bytes, 0u); + EXPECT_GT(limits.max_trailer_bytes, 0u); +} + +TEST(ResponseLimitsTest, ClientStoresConfiguredLimits) { + Client client; + ResponseLimits limits; + limits.max_head_bytes = 1024; + limits.max_body_bytes = 2048; + limits.max_chunk_line_bytes = 128; + limits.max_trailer_bytes = 512; + + client.set_response_limits(limits); + + EXPECT_EQ(client.response_limits().max_head_bytes, 1024u); + EXPECT_EQ(client.response_limits().max_body_bytes, 2048u); + EXPECT_EQ(client.response_limits().max_chunk_line_bytes, 128u); + EXPECT_EQ(client.response_limits().max_trailer_bytes, 512u); +} + +TEST(ResponseLimitsTest, RejectsOversizedResponseHeadBeforeTerminator) { + ResponseLimits limits; + limits.max_head_bytes = 24; + ResponseParser parser{Method::Get, limits}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "X-Long: 1234567890"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::ResponseLimitExceeded); +} + +TEST(ResponseLimitsTest, RejectsDeclaredContentLengthAboveBodyLimit) { + ResponseLimits limits; + limits.max_body_bytes = 4; + ResponseParser parser{Method::Get, limits}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::ResponseLimitExceeded); +} + +TEST(ResponseLimitsTest, AcceptsBodyExactlyAtConfiguredLimit) { + ResponseLimits limits; + limits.max_body_bytes = 5; + ResponseParser parser{Method::Get, limits}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "\r\n" + "hello"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_EQ(parser.response().body(), "hello"); +} + +TEST(ResponseLimitsTest, RejectsCloseDelimitedBodyAboveLimit) { + ResponseLimits limits; + limits.max_body_bytes = 4; + ResponseParser parser{Method::Get, limits}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "\r\n" + "hello"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::ResponseLimitExceeded); +} + +TEST(ResponseLimitsTest, RejectsChunkWhoseDeclaredSizeExceedsBodyBudget) { + ResponseLimits limits; + limits.max_body_bytes = 4; + ResponseParser parser{Method::Get, limits}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::ResponseLimitExceeded); +} + +TEST(ResponseLimitsTest, RejectsOversizedChunkSizeLine) { + ResponseLimits limits; + limits.max_chunk_line_bytes = 3; + ResponseParser parser{Method::Get, limits}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "1234"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::ResponseLimitExceeded); +} + +TEST(ResponseLimitsTest, RejectsOversizedTrailerSection) { + ResponseLimits limits; + limits.max_trailer_bytes = 8; + ResponseParser parser{Method::Get, limits}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "0\r\n" + "X: 123\r\n" + "\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::ResponseLimitExceeded); +} + +} // namespace From 45b41c86394018c87942a2a45a90984aaa65b04c Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:29:44 +0700 Subject: [PATCH 10/15] test: wire response limit coverage --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3b5d584..a0fa74f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,7 @@ add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/redirect_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/chunked_decoder_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/response_limits_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_upgrade_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/client_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/client_reuse_test.cpp From be8f10b0cbc6aea55d65e02db58fb4ed263bd32e Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:30:18 +0700 Subject: [PATCH 11/15] docs: define response resource limits --- docs/api/response-limits.md | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/api/response-limits.md diff --git a/docs/api/response-limits.md b/docs/api/response-limits.md new file mode 100644 index 0000000..52566eb --- /dev/null +++ b/docs/api/response-limits.md @@ -0,0 +1,77 @@ +# Response Resource Limits + +## Purpose + +`cpp_request` v1 stores completed response bodies in memory. To keep that design predictable under malformed or hostile peers, response parsing has finite resource limits. + +Limits are client configuration, not HTTP syntax rules. Exceeding a configured limit returns `ErrorCode::ResponseLimitExceeded` and the connection is not retained for reuse. + +## Public configuration + +```cpp +cpp_request::ResponseLimits limits; +limits.max_head_bytes = 64 * 1024; +limits.max_body_bytes = 64 * 1024 * 1024; +limits.max_chunk_line_bytes = 8 * 1024; +limits.max_trailer_bytes = 64 * 1024; + +cpp_request::Client client; +client.set_response_limits(limits); +``` + +`Client::response_limits()` returns the currently configured values. + +## Default limits + +| Resource | Default | +| --- | ---: | +| Response head, including terminating CRLF CRLF | 64 KiB | +| Decoded response body | 64 MiB | +| One chunk-size line, excluding CRLF | 8 KiB | +| Complete chunked trailer section, including CRLF delimiters | 64 KiB | + +The defaults are finite product safeguards rather than protocol maxima. Callers that intentionally accept larger responses can replace them with larger values before issuing a request. + +## Enforcement semantics + +### Response head + +The parser rejects a response once the current status/header block cannot fit within `max_head_bytes`. The check happens before header parsing and before unbounded header storage growth. + +Interim responses are checked independently per response head. + +### Content-Length body + +If a valid `Content-Length` exceeds `max_body_bytes`, parsing fails before reserving the declared body capacity. + +A body exactly equal to the configured limit is accepted. + +### Close-delimited body + +Before appending newly received bytes, the parser verifies that the accumulated body plus the new bytes remain within `max_body_bytes`. + +### Chunked body + +Each parsed chunk size is checked against the remaining decoded-body budget before chunk payload bytes are appended. + +`max_chunk_line_bytes` prevents an unterminated or pathologically long chunk-size/extension line from growing indefinitely. + +`max_trailer_bytes` bounds the full trailer section, including each CRLF and the terminal empty line. + +## Zero values + +Zero is a real limit, not a synonym for unlimited. For example, `max_body_bytes = 0` accepts only responses whose decoded body is empty. + +A caller that wants an effectively unbounded field can explicitly set that field to a suitably large `std::size_t` value. The library does not provide a separate unlimited sentinel in v1. + +## Error behavior + +Resource-limit failures use: + +```cpp +ErrorCode::ResponseLimitExceeded +``` + +They are kept separate from `MalformedResponse`, `InvalidHeader`, and other syntax/framing errors because a response can be syntactically valid while exceeding the caller's configured resource budget. + +A request that fails because of a response limit does not leave its connection eligible for reuse. From b2475a3a6c52e30d6346ef311a71e28429e13831 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:31:05 +0700 Subject: [PATCH 12/15] docs: track final v0.8 resource hardening --- docs/roadmap.md | 67 +++++++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 5b267fb..53211ad 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -291,38 +291,57 @@ Historical reference: Exit criteria: **complete**. -## 3. HTTP correctness review — 🚧 current work +## 3. HTTP correctness review — ✅ complete -Current hardening scope: +Completed work: -- enforce safe response framing precedence. -- reject `Transfer-Encoding` + `Content-Length` ambiguity before connection reuse. -- reject framing fields where `1xx` / `204` semantics forbid them. -- keep `HEAD` / `304` header-terminated while preserving allowed representation metadata. -- correct `205 Reset Content` framing so unframed responses are close-delimited rather than incorrectly reusable. -- reject actual content in a 205 response. -- validate chunk extension token / quoted-string grammar instead of accepting arbitrary printable bytes. -- retain trailer validation and reject framing-critical trailer fields. -- emit `Content-Length: 0` for empty POST/PUT/PATCH requests while leaving empty GET/HEAD/DELETE unchanged. -- document timeout boundaries: connect budget across endpoint attempts, one write budget across request transmission, read timeout per wait for response progress. -- preserve context-sensitive EOF semantics: incomplete explicit framing is `UnexpectedEof`; valid close-delimited EOF completes normally; timeout never masquerades as EOF. +- enforced safe response framing precedence. +- rejected `Transfer-Encoding` + `Content-Length` ambiguity before connection reuse. +- rejected framing fields where `1xx` / `204` semantics forbid them. +- kept `HEAD` / `304` header-terminated while preserving allowed representation metadata. +- corrected `205 Reset Content` framing so unframed responses are close-delimited rather than incorrectly reusable. +- rejected actual content in a 205 response. +- validated chunk extension token / quoted-string grammar instead of accepting arbitrary printable bytes. +- retained trailer validation and rejected framing-critical trailer fields. +- emitted `Content-Length: 0` for empty POST/PUT/PATCH requests while leaving empty GET/HEAD/DELETE unchanged. +- documented timeout boundaries: connect budget across endpoint attempts, one write budget across request transmission, read timeout per wait for response progress. +- preserved context-sensitive EOF semantics: incomplete explicit framing is `UnexpectedEof`; valid close-delimited EOF completes normally; timeout never masquerades as EOF. +- fixed a dangling `std::string_view` in Transfer-Encoding analysis exposed by MSVC Debug CI. -Exit for this substep: +Historical reference: + +- PR #24 — HTTP framing correctness hardening -- framing/status/chunk edge cases have deterministic parser tests. -- serializer framing policy is covered by tests. -- no reviewed HTTP message-boundary ambiguity remains known. +Exit criteria: **complete**. -## 4. Resource-bound review — ⏳ next +## 4. Resource-bound review — 🚧 current work -Because response bodies are memory-resident in v1, document or introduce practical defensive limits where appropriate: +Because response bodies are memory-resident in v1, the parser now receives explicit finite response limits from `Client`. + +Current hardening scope: + +- public `ResponseLimits` value type stored by-value in `Client`. +- default response-head limit: 64 KiB. +- default decoded-body limit: 64 MiB. +- default chunk-size-line limit: 8 KiB. +- default trailer-section limit: 64 KiB. +- reject oversized `Content-Length` before reserving body capacity. +- bound close-delimited accumulation before append. +- bound chunked decoded body before chunk payload append. +- bound unterminated/pathological chunk-size lines. +- bound aggregate chunked trailer bytes. +- classify limit failures as `ResponseLimitExceeded` rather than malformed HTTP. +- make zero a real limit rather than an implicit unlimited sentinel. +- document the resource-limit contract without adding response streaming. + +Exit for this substep: -- response-head growth. -- pathological chunk-size lines. -- excessive trailer/header sections. -- body-size expectations. +- head, body, chunk-line, and trailer limits have deterministic tests. +- a body exactly at the configured limit remains valid. +- `Client` preserves configured limits across move operations. +- limit failures cannot leave the active connection eligible for reuse. -This milestone must not add streaming; it only hardens the frozen in-memory design. +Once this substep merges, v0.8 is complete and the next milestone is v0.9 packaging/benchmarks/examples/documentation. v0.8 exit criteria: From 080fa192360dae90d0d8ad688ad261e79b10ed3a Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:31:28 +0700 Subject: [PATCH 13/15] docs: document response limit errors --- docs/api/error-model.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/api/error-model.md b/docs/api/error-model.md index 17a8c24..1a8757d 100644 --- a/docs/api/error-model.md +++ b/docs/api/error-model.md @@ -45,6 +45,7 @@ enum class ErrorCode { InvalidChunkFraming, ConflictingMessageFraming, UnexpectedEof, + ResponseLimitExceeded, RedirectLimitExceeded, MissingRedirectLocation, @@ -97,7 +98,7 @@ Rules: | `ReadTimeout` | Waiting for response bytes exceeded the configured read timeout. | | `ConnectionClosed` | Peer closure is observed where the operation requires an active connection. | -### HTTP protocol errors +### HTTP protocol / response errors | Code | Meaning | | --- | --- | @@ -109,6 +110,9 @@ Rules: | `InvalidChunkFraming` | Chunk delimiters, CRLF, or terminal framing are invalid. | | `ConflictingMessageFraming` | Response framing metadata is contradictory or unsafe to interpret silently. | | `UnexpectedEof` | Connection ended before protocol-defined response completion. | +| `ResponseLimitExceeded` | Response parsing would exceed a configured response-head, decoded-body, chunk-line, or trailer resource limit. | + +`ResponseLimitExceeded` is intentionally separate from syntax errors: a response may be syntactically valid but exceed the caller's configured in-memory resource budget. ### Redirect errors @@ -178,6 +182,17 @@ Examples: - EOF after a valid close-delimited response body → normal message completion, not an error. - EOF when a reusable connection is expected but no request is currently active may simply invalidate reuse state internally. +## Resource-limit semantics + +Response resource limits are described in `response-limits.md`. + +Exceeding a configured limit: + +- returns `ResponseLimitExceeded`, +- does not become `MalformedResponse`, +- does not produce a partial successful `Response`, +- does not leave the active connection eligible for reuse. + ## Diagnostic Message Function The library may expose a lightweight function such as: From 53224fe35907e660bf6d0ecac23484e264f4a2b7 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:31:54 +0700 Subject: [PATCH 14/15] test: preserve response limits across client moves --- tests/response_limits_test.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/response_limits_test.cpp b/tests/response_limits_test.cpp index cbd9d29..475dd2b 100644 --- a/tests/response_limits_test.cpp +++ b/tests/response_limits_test.cpp @@ -7,6 +7,8 @@ #include #include +#include + namespace { using cpp_request::Client; @@ -41,6 +43,29 @@ TEST(ResponseLimitsTest, ClientStoresConfiguredLimits) { EXPECT_EQ(client.response_limits().max_trailer_bytes, 512u); } +TEST(ResponseLimitsTest, ClientMovePreservesConfiguredLimits) { + Client source; + ResponseLimits limits; + limits.max_head_bytes = 111; + limits.max_body_bytes = 222; + limits.max_chunk_line_bytes = 333; + limits.max_trailer_bytes = 444; + source.set_response_limits(limits); + + Client moved{std::move(source)}; + EXPECT_EQ(moved.response_limits().max_head_bytes, 111u); + EXPECT_EQ(moved.response_limits().max_body_bytes, 222u); + EXPECT_EQ(moved.response_limits().max_chunk_line_bytes, 333u); + EXPECT_EQ(moved.response_limits().max_trailer_bytes, 444u); + + Client assigned; + assigned = std::move(moved); + EXPECT_EQ(assigned.response_limits().max_head_bytes, 111u); + EXPECT_EQ(assigned.response_limits().max_body_bytes, 222u); + EXPECT_EQ(assigned.response_limits().max_chunk_line_bytes, 333u); + EXPECT_EQ(assigned.response_limits().max_trailer_bytes, 444u); +} + TEST(ResponseLimitsTest, RejectsOversizedResponseHeadBeforeTerminator) { ResponseLimits limits; limits.max_head_bytes = 24; From 5df964efabd2dcac14eb15fdec27e66ee6bf428e Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:32:32 +0700 Subject: [PATCH 15/15] docs: add response limits to public API contract --- docs/api/public-api.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/api/public-api.md b/docs/api/public-api.md index 7d40e00..96250b6 100644 --- a/docs/api/public-api.md +++ b/docs/api/public-api.md @@ -32,6 +32,7 @@ The v1 public surface consists primarily of: - `Client` - `Request` - `Response` +- `ResponseLimits` - `Headers` - `Url` - `Error` @@ -44,6 +45,7 @@ classDiagram class Client class Request class Response + class ResponseLimits class Headers class Url class Error @@ -51,6 +53,7 @@ classDiagram Client --> Request : executes Client --> Response : returns + Client --> ResponseLimits : configures Request --> Headers : contains Request --> Url : targets Response --> Headers : contains @@ -68,6 +71,7 @@ Responsibilities: - retain reusable connection state, - retain client-level timeout configuration, - retain redirect configuration, +- retain response resource-limit configuration, - execute sequential HTTP requests, - expose convenience member functions for common methods. @@ -76,6 +80,13 @@ Conceptual interface: ```cpp namespace cpp_request { +struct ResponseLimits { + std::size_t max_head_bytes; + std::size_t max_body_bytes; + std::size_t max_chunk_line_bytes; + std::size_t max_trailer_bytes; +}; + class Client { public: Client(); @@ -95,6 +106,9 @@ public: void set_follow_redirects(bool enabled); void set_max_redirects(std::size_t count); + + void set_response_limits(ResponseLimits limits) noexcept; + const ResponseLimits& response_limits() const noexcept; }; } // namespace cpp_request @@ -126,6 +140,19 @@ Cross-origin redirects do not forward caller-supplied `Host`, `Authorization`, ` Redirects requiring HTTPS/TLS or another unsupported scheme fail with a structured redirect error rather than being followed. +### Response resource limits + +Because v1 responses are fully memory-resident, `Client` applies finite response parsing limits by default: + +- response head: 64 KiB, +- decoded body: 64 MiB, +- chunk-size line: 8 KiB, +- chunked trailer section: 64 KiB. + +Callers may replace the full configuration with `set_response_limits()`. Exceeding a configured limit returns `ErrorCode::ResponseLimitExceeded` and the active connection is not retained for reuse. + +Zero is a real limit rather than an unlimited sentinel. Full enforcement details are defined in `response-limits.md`. + --- ## `Request` @@ -366,6 +393,7 @@ Expected categories include: - send failure, - receive failure, - malformed HTTP response, +- response resource-limit failure, - redirect failure. ---