From 7af40fce8bd341110ff521e153aedb485661cabf Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:43:05 +0700 Subject: [PATCH 01/14] feat: add owned request query parameters --- include/cpp_request/request.hpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/include/cpp_request/request.hpp b/include/cpp_request/request.hpp index f0c9841..a327437 100644 --- a/include/cpp_request/request.hpp +++ b/include/cpp_request/request.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include @@ -17,6 +19,11 @@ enum class Method { class Request { public: + struct QueryParam final { + std::string name; + std::string value; + }; + Request(Method method, std::string_view url) noexcept : method_(method), url_(url) {} @@ -29,11 +36,21 @@ class Request { void set_body(std::string_view body) noexcept { body_ = body; } [[nodiscard]] std::string_view body() const noexcept { return body_; } + void add_query_param(std::string_view name, std::string_view value) { + QueryParam param{std::string{name}, std::string{value}}; + query_params_.push_back(std::move(param)); + } + + [[nodiscard]] const std::vector& query_params() const noexcept { + return query_params_; + } + private: Method method_; std::string_view url_; Headers headers_; std::string_view body_; + std::vector query_params_; }; } // namespace cpp_request From b86f447f2a1b7ba5664e3b4c0bd50fd905b7993a Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:43:15 +0700 Subject: [PATCH 02/14] feat: expose effective request URL helper --- src/http/request_serializer.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/http/request_serializer.hpp b/src/http/request_serializer.hpp index 24eee57..b1f288c 100644 --- a/src/http/request_serializer.hpp +++ b/src/http/request_serializer.hpp @@ -16,5 +16,6 @@ struct SerializedRequest final { }; [[nodiscard]] Result serialize_request(const Request& request); +[[nodiscard]] std::string effective_request_url(const Request& request); } // namespace cpp_request::detail::http From 1f8420abe4d1cbef803208b31a7356039fa8aa5a Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:43:40 +0700 Subject: [PATCH 03/14] feat: percent-encode appended query parameters --- src/http/request_serializer.cpp | 79 +++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/src/http/request_serializer.cpp b/src/http/request_serializer.cpp index 954390b..34015f3 100644 --- a/src/http/request_serializer.cpp +++ b/src/http/request_serializer.cpp @@ -71,6 +71,54 @@ namespace { } } +[[nodiscard]] constexpr bool is_unreserved(unsigned char ch) noexcept { + return (ch >= '0' && ch <= '9') + || (ch >= 'A' && ch <= 'Z') + || (ch >= 'a' && ch <= 'z') + || ch == '-' + || ch == '.' + || ch == '_' + || ch == '~'; +} + +void append_percent_encoded(std::string& output, std::string_view input) { + constexpr char kHex[] = "0123456789ABCDEF"; + for (const unsigned char ch : input) { + if (is_unreserved(ch)) { + output.push_back(static_cast(ch)); + continue; + } + + output.push_back('%'); + output.push_back(kHex[(ch >> 4) & 0x0f]); + output.push_back(kHex[ch & 0x0f]); + } +} + +void append_query_params( + std::string& output, + const std::vector& params) { + if (params.empty()) { + return; + } + + const bool has_query = output.find('?') != std::string::npos; + if (!has_query) { + output.push_back('?'); + } else if (!output.empty() && output.back() != '?' && output.back() != '&') { + output.push_back('&'); + } + + for (std::size_t index = 0; index < params.size(); ++index) { + if (index != 0) { + output.push_back('&'); + } + append_percent_encoded(output, params[index].name); + output.push_back('='); + append_percent_encoded(output, params[index].value); + } +} + [[nodiscard]] bool valid_header_name(std::string_view name) noexcept { if (name.empty()) { return false; @@ -146,8 +194,28 @@ void append_generated_host(std::string& output, const Url& url) { output.append("\r\n"); } +[[nodiscard]] std::size_t encoded_query_reserve( + const std::vector& params) noexcept { + std::size_t result = 0; + for (const auto& param : params) { + result += (param.name.size() + param.value.size()) * 3 + 2; + } + return result; +} + } // namespace +std::string effective_request_url(const Request& request) { + const std::string_view raw = request.url(); + const std::size_t fragment = raw.find('#'); + const std::string_view without_fragment = raw.substr(0, fragment); + + std::string result{without_fragment}; + result.reserve(result.size() + encoded_query_reserve(request.query_params())); + append_query_params(result, request.query_params()); + return result; +} + Result serialize_request(const Request& request) { auto parsed_url = Url::parse(request.url()); if (!parsed_url) { @@ -198,9 +266,14 @@ Result serialize_request(const Request& request) { serialized.url = std::move(parsed_url).value(); serialized.body = request.body(); + std::string request_target{serialized.url.target()}; + request_target.reserve( + request_target.size() + encoded_query_reserve(request.query_params())); + append_query_params(request_target, request.query_params()); + std::size_t reserve_size = method.size() + 1 - + serialized.url.target().size() + + request_target.size() + sizeof(" HTTP/1.1\r\n") - 1 + request.headers().size() * 16 + 64; @@ -211,9 +284,7 @@ Result serialize_request(const Request& request) { serialized.head.append(method.data(), method.size()); serialized.head.push_back(' '); - serialized.head.append( - serialized.url.target().data(), - serialized.url.target().size()); + serialized.head.append(request_target); serialized.head.append(" HTTP/1.1\r\n"); if (host_count == 0) { From 88217b146aeeb82a22ff65edcd9e99db37952798 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:44:08 +0700 Subject: [PATCH 04/14] fix: preserve effective query URL across redirects --- src/core/client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/client.cpp b/src/core/client.cpp index 94e3289..047991b 100644 --- a/src/core/client.cpp +++ b/src/core/client.cpp @@ -217,7 +217,7 @@ Result Client::request(const Request& request_value) { return response; } - std::string current_url{request_value.url()}; + std::string current_url = detail::http::effective_request_url(request_value); Method current_method = request_value.method(); Headers current_headers = request_value.headers(); std::string_view current_body = request_value.body(); From 63f94b86369a24f4bc08b964ae5356c03d075af1 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:44:36 +0700 Subject: [PATCH 05/14] fix: harden URL percent escapes and IPv6 literals --- src/core/url.cpp | 74 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/core/url.cpp b/src/core/url.cpp index 8eae0b9..eb85304 100644 --- a/src/core/url.cpp +++ b/src/core/url.cpp @@ -1,22 +1,34 @@ #include #include +#include #include -#include +#include #include +#ifdef _WIN32 +#include +#include +#else +#include +#endif + namespace cpp_request { namespace { +[[nodiscard]] constexpr char ascii_lower(char ch) noexcept { + return ch >= 'A' && ch <= 'Z' + ? static_cast(ch + ('a' - 'A')) + : ch; +} + [[nodiscard]] bool ascii_iequals(std::string_view lhs, std::string_view rhs) noexcept { if (lhs.size() != rhs.size()) { return false; } for (std::size_t i = 0; i < lhs.size(); ++i) { - const auto a = static_cast(lhs[i]); - const auto b = static_cast(rhs[i]); - if (std::tolower(a) != std::tolower(b)) { + if (ascii_lower(lhs[i]) != ascii_lower(rhs[i])) { return false; } } @@ -32,6 +44,44 @@ namespace { return false; } +[[nodiscard]] constexpr bool is_hex_digit(char ch) noexcept { + return (ch >= '0' && ch <= '9') + || (ch >= 'A' && ch <= 'F') + || (ch >= 'a' && ch <= 'f'); +} + +[[nodiscard]] bool valid_percent_escapes(std::string_view text) noexcept { + for (std::size_t index = 0; index < text.size(); ++index) { + if (text[index] != '%') { + continue; + } + + if (index + 2 >= text.size() + || !is_hex_digit(text[index + 1]) + || !is_hex_digit(text[index + 2])) { + return false; + } + index += 2; + } + return true; +} + +[[nodiscard]] bool valid_ipv6_literal(std::string_view text) noexcept { + if (text.empty() || text.size() >= 64 || text.find('%') != std::string_view::npos) { + return false; + } + + std::array buffer{}; + std::copy(text.begin(), text.end(), buffer.begin()); + std::array address{}; + +#ifdef _WIN32 + return ::InetPtonA(AF_INET6, buffer.data(), address.data()) == 1; +#else + return ::inet_pton(AF_INET6, buffer.data(), address.data()) == 1; +#endif +} + [[nodiscard]] Result parse_port(std::string_view text) noexcept { if (text.empty()) { return Error{ErrorCode::InvalidPort}; @@ -76,7 +126,7 @@ Result Url::parse(std::string_view input) { result.storage_.begin(), result.storage_.begin() + static_cast(scheme_separator), result.storage_.begin(), - [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + [](char ch) { return ascii_lower(ch); }); result.scheme_begin_ = 0; result.scheme_size_ = scheme_separator; @@ -94,6 +144,10 @@ Result Url::parse(std::string_view input) { return Error{ErrorCode::InvalidUrl}; } + if (!valid_percent_escapes(std::string_view{result.storage_}.substr(authority_end))) { + return Error{ErrorCode::InvalidUrl}; + } + const std::string_view authority{ result.storage_.data() + authority_begin, authority_end - authority_begin}; @@ -108,6 +162,11 @@ Result Url::parse(std::string_view input) { return Error{ErrorCode::InvalidUrl}; } + const std::string_view ipv6 = authority.substr(1, closing - 1); + if (!valid_ipv6_literal(ipv6)) { + return Error{ErrorCode::InvalidUrl}; + } + result.host_is_ipv6_literal_ = true; result.host_begin_ = authority_begin + 1; result.host_size_ = closing - 1; @@ -125,6 +184,11 @@ Result Url::parse(std::string_view input) { result.has_explicit_port_ = true; } } else { + if (authority.find('[') != std::string_view::npos + || authority.find(']') != std::string_view::npos) { + return Error{ErrorCode::InvalidUrl}; + } + const std::size_t colon = authority.rfind(':'); if (colon != std::string_view::npos) { if (authority.find(':') != colon) { From e2a65f680c1dd1597eca20efd2ded1ff7076833d Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:44:59 +0700 Subject: [PATCH 06/14] fix: include utility for query parameter storage --- include/cpp_request/request.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/include/cpp_request/request.hpp b/include/cpp_request/request.hpp index a327437..0aa4fe0 100644 --- a/include/cpp_request/request.hpp +++ b/include/cpp_request/request.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include From f5bcafcbb5e6aedae10c1b0210c8e8740d5f9aa7 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:45:21 +0700 Subject: [PATCH 07/14] fix: include vector for query serialization --- src/http/request_serializer.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/http/request_serializer.cpp b/src/http/request_serializer.cpp index 34015f3..28c9734 100644 --- a/src/http/request_serializer.cpp +++ b/src/http/request_serializer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace cpp_request::detail::http { namespace { From 48ede50f1ad605acebafd0210a00fcfd784ef2d8 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:45:34 +0700 Subject: [PATCH 08/14] test: cover owned request query parameters --- tests/request_test.cpp | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/request_test.cpp b/tests/request_test.cpp index d3cd08f..9aa28c7 100644 --- a/tests/request_test.cpp +++ b/tests/request_test.cpp @@ -41,10 +41,38 @@ TEST(RequestTest, OwnsHeaderStorageIndependentlyFromBorrowedRequestData) { EXPECT_EQ(request.headers().get("content-type"), "application/json"); } +TEST(RequestTest, OwnsQueryParameterStorage) { + std::string name = "search"; + std::string value = "hello world"; + + Request request{Method::Get, "http://example.com/items"}; + request.add_query_param(name, value); + + name.assign("changed"); + value.assign("changed"); + + ASSERT_EQ(request.query_params().size(), 1u); + EXPECT_EQ(request.query_params()[0].name, "search"); + EXPECT_EQ(request.query_params()[0].value, "hello world"); +} + +TEST(RequestTest, PreservesRepeatedQueryParametersInInsertionOrder) { + Request request{Method::Get, "http://example.com/items"}; + request.add_query_param("tag", "cpp"); + request.add_query_param("tag", "http"); + + ASSERT_EQ(request.query_params().size(), 2u); + EXPECT_EQ(request.query_params()[0].name, "tag"); + EXPECT_EQ(request.query_params()[0].value, "cpp"); + EXPECT_EQ(request.query_params()[1].name, "tag"); + EXPECT_EQ(request.query_params()[1].value, "http"); +} + TEST(RequestTest, ConstAccessorsExposeRequestState) { Request mutable_request{Method::Patch, "http://example.com/item/1"}; mutable_request.set_body("patch-body"); mutable_request.headers().add("X-Test", "value"); + mutable_request.add_query_param("mode", "fast"); const Request& request = mutable_request; @@ -52,6 +80,9 @@ TEST(RequestTest, ConstAccessorsExposeRequestState) { EXPECT_EQ(request.url(), "http://example.com/item/1"); EXPECT_EQ(request.body(), "patch-body"); EXPECT_EQ(request.headers().get("x-test"), "value"); + ASSERT_EQ(request.query_params().size(), 1u); + EXPECT_EQ(request.query_params()[0].name, "mode"); + EXPECT_EQ(request.query_params()[0].value, "fast"); } TEST(RequestTest, SupportsAllV1Methods) { From acfd0d6fe7a6bab78352114a39d54c91a2d94200 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:45:56 +0700 Subject: [PATCH 09/14] test: cover query parameter serialization --- tests/query_test.cpp | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/query_test.cpp diff --git a/tests/query_test.cpp b/tests/query_test.cpp new file mode 100644 index 0000000..7c6fd4f --- /dev/null +++ b/tests/query_test.cpp @@ -0,0 +1,91 @@ +#include + +#include "http/request_serializer.hpp" + +#include + +#include + +namespace { + +using cpp_request::Method; +using cpp_request::Request; +using cpp_request::detail::http::effective_request_url; +using cpp_request::detail::http::serialize_request; + +TEST(QueryTest, PercentEncodesAppendedParameters) { + Request request{Method::Get, "http://example.com/search"}; + request.add_query_param("q", "hello world"); + request.add_query_param("reserved", "a/b?c&d=e"); + request.add_query_param("safe", "AZaz09-._~"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find( + "GET /search?q=hello%20world&reserved=a%2Fb%3Fc%26d%3De&safe=AZaz09-._~ HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, AppendsAfterExistingRawQuery) { + Request request{Method::Get, "http://example.com/search?lang=en"}; + request.add_query_param("q", "cpp http"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find( + "GET /search?lang=en&q=cpp%20http HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, HandlesExistingEmptyQueryMarker) { + Request request{Method::Get, "http://example.com/search?"}; + request.add_query_param("q", "value"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find("GET /search?q=value HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, PreservesRepeatedParametersInInsertionOrder) { + Request request{Method::Get, "http://example.com/items"}; + request.add_query_param("tag", "cpp"); + request.add_query_param("tag", "http"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find("GET /items?tag=cpp&tag=http HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, PercentEncodesUtf8Bytes) { + Request request{Method::Get, "http://example.com/search"}; + const std::string value{"caf\xC3\xA9"}; + request.add_query_param("q", value); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find("GET /search?q=caf%C3%A9 HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, EffectiveUrlIncludesParametersAndDropsFragment) { + Request request{Method::Get, "http://example.com/search?raw=1#section"}; + request.add_query_param("q", "hello world"); + + EXPECT_EQ( + effective_request_url(request), + "http://example.com/search?raw=1&q=hello%20world"); +} + +} // namespace From 770c28a2957e293c8fbbdde95c768424d44cc355 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:46:10 +0700 Subject: [PATCH 10/14] test: wire query parameter coverage --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 306bad8..3b5d584 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/headers_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_serializer_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/query_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/redirect_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/chunked_decoder_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp From 82fa0adf4ecabb91e86c3e6a02cb0b5b3a6fe98c Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:46:30 +0700 Subject: [PATCH 11/14] test: cover URL percent escapes and IPv6 validation --- tests/url_test.cpp | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/url_test.cpp b/tests/url_test.cpp index 3f0086a..700019d 100644 --- a/tests/url_test.cpp +++ b/tests/url_test.cpp @@ -56,6 +56,23 @@ TEST(UrlTest, ParsesBracketedIpv6Literal) { EXPECT_EQ(parsed.value().target(), "/a"); } +TEST(UrlTest, ParsesIpv6LiteralWithEmbeddedIpv4) { + auto parsed = Url::parse("http://[::ffff:192.0.2.128]/a"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().host(), "::ffff:192.0.2.128"); + EXPECT_TRUE(parsed.value().host_is_ipv6_literal()); +} + +TEST(UrlTest, PreservesValidPercentEscapes) { + auto parsed = Url::parse("http://example.com/a%20b?q=x%2Fy"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().path(), "/a%20b"); + EXPECT_EQ(parsed.value().query(), "q=x%2Fy"); + EXPECT_EQ(parsed.value().target(), "/a%20b?q=x%2Fy"); +} + TEST(UrlTest, FragmentIsNotPartOfRequestTarget) { auto parsed = Url::parse("http://example.com/a?x=1#section"); @@ -119,3 +136,45 @@ TEST(UrlTest, RejectsUnbracketedIpv6Literal) { ASSERT_FALSE(parsed); EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); } + +TEST(UrlTest, RejectsBracketedNonIpv6Host) { + auto parsed = Url::parse("http://[not-an-ipv6]/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsMalformedBracketedIpv6Literal) { + auto parsed = Url::parse("http://[2001:::1]/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsIpv6ZoneIdentifierInV1) { + auto parsed = Url::parse("http://[fe80::1%25eth0]/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsTruncatedPercentEscapeInPath) { + auto parsed = Url::parse("http://example.com/a%2"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsNonHexPercentEscapeInPath) { + auto parsed = Url::parse("http://example.com/a%XZ"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsMalformedPercentEscapeInQuery) { + auto parsed = Url::parse("http://example.com/a?q=%GG"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} From a7075e735f03f2fbcf5340a9f55d5d0d5400dce3 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:47:15 +0700 Subject: [PATCH 12/14] docs: define v1 query parameter contract --- docs/api/public-api.md | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/api/public-api.md b/docs/api/public-api.md index f69eec7..7d40e00 100644 --- a/docs/api/public-api.md +++ b/docs/api/public-api.md @@ -120,7 +120,7 @@ The v1 method/body policy is: When a body is dropped, body-specific request headers such as `Content-Length`, `Transfer-Encoding`, and `Content-Type` are not forwarded to the redirected request. -Relative `Location` values are resolved against the current request URL, including absolute-path, relative-path, query-only, and scheme-relative forms. URL fragments are never sent in the HTTP request target. +Relative `Location` values are resolved against the current effective request URL, including query parameters appended through `Request::add_query_param()`, and including absolute-path, relative-path, query-only, fragment-only, and scheme-relative forms. URL fragments are never sent in the HTTP request target. Cross-origin redirects do not forward caller-supplied `Host`, `Authorization`, `Proxy-Authorization`, or `Cookie` fields. Once these fields are removed during a redirect chain they are not automatically restored if a later hop returns to the original origin. @@ -148,6 +148,11 @@ enum class Method { class Request { public: + struct QueryParam { + std::string name; + std::string value; + }; + Request(Method method, std::string_view url); Method method() const noexcept; @@ -158,17 +163,39 @@ public: void set_body(std::string_view body) noexcept; std::string_view body() const noexcept; + + void add_query_param(std::string_view name, std::string_view value); + const std::vector& query_params() const noexcept; }; } // namespace cpp_request ``` -### Borrowed request data +### Borrowed and owned request data + +The base URL and body may remain non-owning views. Their lifetime requirements are defined in `lifetime.md`. -`Request` may hold non-owning views for URL and body data. Their lifetime requirements are defined in `lifetime.md`. +Headers and query parameters added through the request object own their copied text independently from the caller's source buffers. The v1 design does not require request-body ownership or request streaming. +### Query parameter behavior + +`add_query_param(name, value)` appends one parameter in insertion order. Repeated names are preserved rather than deduplicated. + +For request serialization: + +- an existing raw query already present in the URL is preserved, +- appended parameters are added after that raw query, +- parameter names and values are percent-encoded byte-for-byte, +- URI unreserved bytes (`ALPHA`, `DIGIT`, `-`, `.`, `_`, `~`) remain unescaped, +- spaces are encoded as `%20`, not `+`, +- reserved characters such as `/`, `?`, `&`, and `=` are encoded inside appended names/values, +- UTF-8 input is percent-encoded by its UTF-8 bytes, +- repeated parameters preserve insertion order. + +The raw URL parser does not silently repair malformed percent escapes. A raw path or query containing `%` must contain a complete `%HH` escape using hexadecimal digits. + --- ## `Response` @@ -280,6 +307,8 @@ public: For v1, only `http://` is supported. `https://` must fail explicitly. +Raw path/query percent escapes must be syntactically complete `%HH` sequences. Bracketed hosts are accepted only when they contain a valid numeric IPv6 literal. IPv6 zone identifiers are not part of the v1 URL contract. + Implementation may own normalized URL storage internally when necessary; callers must not depend on the exact storage representation. --- From 9dd67162c55870f374238d4796208005b05f2641 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 07:48:02 +0700 Subject: [PATCH 13/14] fix: reject encoded authority in v1 URL parser --- src/core/url.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/url.cpp b/src/core/url.cpp index eb85304..b8ea2c4 100644 --- a/src/core/url.cpp +++ b/src/core/url.cpp @@ -144,7 +144,9 @@ Result Url::parse(std::string_view input) { return Error{ErrorCode::InvalidUrl}; } - if (!valid_percent_escapes(std::string_view{result.storage_}.substr(authority_end))) { + if (!valid_percent_escapes(std::string_view{ + result.storage_.data() + authority_end, + result.storage_.size() - authority_end})) { return Error{ErrorCode::InvalidUrl}; } @@ -152,7 +154,8 @@ Result Url::parse(std::string_view input) { result.storage_.data() + authority_begin, authority_end - authority_begin}; - if (authority.find('@') != std::string_view::npos) { + if (authority.find('@') != std::string_view::npos + || authority.find('%') != std::string_view::npos) { return Error{ErrorCode::InvalidUrl}; } From 39051e47ab049069b585e307c9407ad8dfa1150d Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 08:00:21 +0700 Subject: [PATCH 14/14] fix: prevent Winsock minmax macro collision --- src/core/url.cpp | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/core/url.cpp b/src/core/url.cpp index b8ea2c4..1d4d6d2 100644 --- a/src/core/url.cpp +++ b/src/core/url.cpp @@ -7,6 +7,9 @@ #include #ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif #include #include #else @@ -144,12 +147,6 @@ Result Url::parse(std::string_view input) { return Error{ErrorCode::InvalidUrl}; } - if (!valid_percent_escapes(std::string_view{ - result.storage_.data() + authority_end, - result.storage_.size() - authority_end})) { - return Error{ErrorCode::InvalidUrl}; - } - const std::string_view authority{ result.storage_.data() + authority_begin, authority_end - authority_begin}; @@ -165,8 +162,8 @@ Result Url::parse(std::string_view input) { return Error{ErrorCode::InvalidUrl}; } - const std::string_view ipv6 = authority.substr(1, closing - 1); - if (!valid_ipv6_literal(ipv6)) { + const std::string_view literal = authority.substr(1, closing - 1); + if (!valid_ipv6_literal(literal)) { return Error{ErrorCode::InvalidUrl}; } @@ -187,11 +184,6 @@ Result Url::parse(std::string_view input) { result.has_explicit_port_ = true; } } else { - if (authority.find('[') != std::string_view::npos - || authority.find(']') != std::string_view::npos) { - return Error{ErrorCode::InvalidUrl}; - } - const std::size_t colon = authority.rfind(':'); if (colon != std::string_view::npos) { if (authority.find(':') != colon) { @@ -242,6 +234,12 @@ Result Url::parse(std::string_view input) { result.query_size_ = result.storage_.size() - result.query_begin_; } + if (!valid_percent_escapes(std::string_view{ + result.storage_.data() + path_begin, + result.storage_.size() - path_begin})) { + return Error{ErrorCode::InvalidUrl}; + } + result.target_begin_ = path_begin; result.target_size_ = result.storage_.size() - path_begin;