Compare commits

...
6 Commits
Author SHA1 Message Date
yhirose c419c904da Release v0.61.0 2026-10-13 22:56:02 -04:00
yhirose 878ba41099 Wait for the reconnect in the SSE retry test instead of sleeping
SSEIntegrationTest.DigitRetryFieldApplied slept for 500ms and then
expected a second connection. The first connection, the 100ms wait and
the reconnect do not always fit in that window: the macOS Mbed TLS job
saw only one connection.

Wait until the second connection arrives, and check that it came at
least 100ms after the first one rather than counting connections. The
test no longer depends on how fast the runner is, and takes about 150ms
instead of 500ms.
2026-10-13 09:07:56 -04:00
yhirose 941745c33d Send proxy Basic authentication when one credential is empty
Proxy-Authorization was added only when both the proxy username and the
proxy password were set, and a cross-host redirect carried the proxy
credentials over only when the username was set. Authorization has been
sent when either one is set since #958; do the same for the proxy.

A client that has both a proxy Basic credential with one empty field
and a proxy bearer token now sends Basic rather than Bearer, the order
Authorization already follows.
2026-10-12 22:59:55 -04:00
yhirose 11daa99c86 Stop at the last match when getting a value by index
get_header_value(), get_header_value_u64(), get_multimap_value() and
MultipartFormData::get_field() advanced an equal_range() iterator by
the caller's index after casting it to ssize_t. An index that is
negative as a signed value stepped back onto an entry of another key,
and a huge positive one kept looping after the last match.

Advance with bounded_next(), which stops at the end of the range.
2026-10-12 22:59:55 -04:00
yhirose cbaf751da1 Ignore quoted-strings when parsing an Accept header
parse_accept_header() split on every ',' and then on every ';', with no
idea that a parameter value can be a quoted-string (RFC 9110 §5.6.6). A
value such as text/html;profile="a,b" was cut in the middle, and the
request was answered with 400.

The parser never looks at a parameter value, so remove each
quoted-string (§5.6.4) before parsing rather than teach every split
about quotes. A header without a '"' takes the same path as before.

A quoted-string is removed wherever it appears, so a malformed value
can now be read without it instead of being rejected, and an
unterminated quote hides the rest of the value.
2026-10-12 22:59:47 -04:00
yhirose 33d84a12b6 Compare the Range unit case-insensitively
RFC 9110 §14.1 makes range unit names case-insensitive, but
parse_range_header() matched "bytes=" exactly, so a request with
Range: Bytes=1-3 was answered with 416.
2026-10-12 19:37:07 -04:00
3 changed files with 167 additions and 33 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.60.1"
version = "0.61.0"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+42 -16
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.60.1"
#define CPPHTTPLIB_VERSION_NUM "0x003c01"
#define CPPHTTPLIB_VERSION "0.61.0"
#define CPPHTTPLIB_VERSION_NUM "0x003d00"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -1141,8 +1141,7 @@ private:
pointer operator->() const { return data_ + idx_; }
iterator_t &operator++() {
// Saturating, so that advancing past the last entry of a key (which
// get_multimap_value() does when asked for an out-of-range id) stays at
// Saturating, so that advancing past the last entry of a key stays at
// end() instead of running off the container.
if (idx_ >= size_) { return *this; }
++idx_;
@@ -3477,13 +3476,20 @@ inline bool is_numeric(const std::string &str) {
return !str.empty() && std::all_of(str.cbegin(), str.cend(), is_ascii_digit);
}
// std::ranges::next(first, n, last) for C++11
template <typename It> inline It bounded_next(It first, size_t n, It last) {
for (; n > 0 && first != last; --n) {
++first;
}
return first;
}
inline size_t get_header_value_u64(const Headers &headers,
const std::string &key, size_t def,
size_t id, bool &is_invalid_value) {
is_invalid_value = false;
auto rng = headers.equal_range(key);
auto it = rng.first;
std::advance(it, static_cast<ssize_t>(id));
auto it = bounded_next(rng.first, id, rng.second);
if (it != rng.second) {
if (is_numeric(it->second)) {
// Parse at size_t width so an out-of-range Content-Length is reported
@@ -8146,8 +8152,7 @@ inline const char *get_header_value(const Headers &headers,
}
auto rng = headers.equal_range(key);
auto it = rng.first;
std::advance(it, static_cast<ssize_t>(id));
auto it = bounded_next(rng.first, id, rng.second);
if (it != rng.second) { return it->second.c_str(); }
return def;
}
@@ -8200,8 +8205,7 @@ template <typename Map>
inline typename Map::mapped_type
get_multimap_value(const Map &m, const std::string &key, size_t id) {
auto rng = m.equal_range(key);
auto it = rng.first;
std::advance(it, static_cast<ssize_t>(id));
auto it = bounded_next(rng.first, id, rng.second);
if (it != rng.second) { return it->second; }
return typename Map::mapped_type();
}
@@ -9189,7 +9193,7 @@ inline bool parse_range_header(const std::string &s, Ranges &ranges) try {
return std::all_of(str.cbegin(), str.cend(), is_ascii_digit);
};
if (s.size() > 7 && s.compare(0, 6, "bytes=") == 0) {
if (s.size() > 7 && case_ignore::equal(s.substr(0, 6), "bytes=")) {
const auto pos = static_cast<size_t>(6);
const auto len = static_cast<size_t>(s.size() - 6);
auto all_valid_ranges = true;
@@ -9247,8 +9251,30 @@ inline bool parse_range_header(const std::string &s, Ranges &ranges) try {
} catch (...) { return false; }
#endif
// Removes each quoted-string, honoring a quoted-pair (RFC 9110 Section 5.6.4)
inline std::string remove_quoted_strings(const std::string &s) {
std::string out;
auto in_quotes = false;
for (size_t i = 0; i < s.size(); i++) {
if (s[i] == '"') {
in_quotes = !in_quotes;
} else if (!in_quotes) {
out += s[i];
} else if (s[i] == '\\') {
i++;
}
}
return out;
}
inline bool parse_accept_header(const std::string &s,
std::vector<std::string> &content_types) {
// Parameter values are not used here, so a quoted-string is dropped rather
// than parsed: a ',' or ';' inside one must not be read as a delimiter.
if (s.find('"') != std::string::npos) {
return parse_accept_header(remove_quoted_strings(s), content_types);
}
content_types.clear();
// Empty string is considered valid (no preference)
@@ -11706,8 +11732,7 @@ inline bool Request::is_multipart_form_data() const {
inline std::string MultipartFormData::get_field(const std::string &key,
size_t id) const {
auto rng = fields.equal_range(key);
auto it = rng.first;
std::advance(it, static_cast<ssize_t>(id));
auto it = detail::bounded_next(rng.first, id, rng.second);
if (it != rng.second) { return it->second.content; }
return std::string();
}
@@ -15919,7 +15944,8 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) {
if (!proxy_host_.empty() && proxy_port_ != -1) {
client.set_proxy(proxy_host_, proxy_port_);
if (!proxy_basic_auth_username_.empty()) {
if (!proxy_basic_auth_username_.empty() ||
!proxy_basic_auth_password_.empty()) {
client.set_proxy_basic_auth(proxy_basic_auth_username_,
proxy_basic_auth_password_);
}
@@ -16022,8 +16048,8 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req,
// otherwise NO_PROXY-matched requests, and requests inside a TLS tunnel,
// would leak proxy credentials to the destination server.
if (is_proxy_enabled_for_host(host_) && (!is_ssl() || is_connect)) {
if (!proxy_basic_auth_username_.empty() &&
!proxy_basic_auth_password_.empty() &&
if ((!proxy_basic_auth_username_.empty() ||
!proxy_basic_auth_password_.empty()) &&
!req.has_header("Proxy-Authorization")) {
req.headers.insert(make_basic_authentication_header(
proxy_basic_auth_username_, proxy_basic_auth_password_, true));
+124 -16
View File
@@ -1119,6 +1119,39 @@ TEST(ParseAcceptHeaderTest, EmptyListElementsAreIgnored) {
EXPECT_FALSE(detail::parse_accept_header(",invalidtype,", result));
}
// RFC 9110 Section 5.6.6: a parameter value may be a quoted-string, and a ','
// or ';' inside one does not end the list element or the parameter. Section
// 5.6.4: nor does a '"' that a backslash escapes end the quoted-string.
TEST(ParseAcceptHeaderTest, DelimitersInQuotedParameterValues) {
struct {
const char *value;
std::vector<std::string> expected;
} cases[] = {
{"text/html;profile=\"a,b\",application/json",
{"text/html", "application/json"}},
// A "q=" inside a quoted value is not the weight
{"text/html;profile=\"a;q=0.1\",application/json;q=0.5",
{"text/html", "application/json"}},
// The weight after a quoted value still applies
{"text/html;profile=\"a,b;q=0.1\";q=0.8,application/json;q=0.9",
{"application/json", "text/html"}},
{"text/html;profile=\"a\\\",b\";q=0.8,application/json;q=0.9",
{"application/json", "text/html"}},
{"text/html;profile=\"a\\\"b\",application/json",
{"text/html", "application/json"}},
// An escaped backslash does not escape the '"' after it
{"text/html;profile=\"a\\\\\",application/json",
{"text/html", "application/json"}},
};
for (const auto &c : cases) {
std::vector<std::string> result;
EXPECT_TRUE(detail::parse_accept_header(c.value, result))
<< "value: " << c.value;
EXPECT_EQ(c.expected, result) << "value: " << c.value;
}
}
TEST(ParseAcceptHeaderTest, ContentTypesPopulatedAndInvalidHeaderHandling) {
Server svr;
@@ -1890,6 +1923,28 @@ TEST(HeadersOrderTest, IdBeyondTheLastDuplicateYieldsDefault) {
EXPECT_EQ("def", req.get_header_value("X-Missing", "def", 3));
}
TEST(HeadersOrderTest, HugeIdYieldsDefault) {
Request req;
req.set_header("X-First", "1");
req.set_header("X-Test", "2");
req.params.emplace("first", "1");
req.params.emplace("test", "2");
req.form.fields.emplace("first", FormField{"first", "1", {}});
req.form.fields.emplace("test", FormField{"test", "2", {}});
req.form.files.emplace("first", FormData{"first", "1", "", "", {}});
req.form.files.emplace("test", FormData{"test", "2", "", "", {}});
// The second one is negative when read as a signed value
const auto max_id = (std::numeric_limits<size_t>::max)();
for (auto id : {max_id / 2, max_id}) {
EXPECT_EQ("def", req.get_header_value("X-Test", "def", id));
EXPECT_EQ(7U, req.get_header_value_u64("X-Test", 7, id));
EXPECT_EQ("", req.get_param_value("test", id));
EXPECT_EQ("", req.form.get_field("test", id));
EXPECT_EQ("", req.form.get_file("test", id).content);
}
}
TEST(HeadersOrderTest, TraversalFollowsInsertionOrder) {
Headers headers;
headers.emplace("Host", "example.com");
@@ -2033,6 +2088,13 @@ TEST(ParseHeaderValueTest, Range) {
EXPECT_EQ(0, ranges[0].first);
EXPECT_EQ(-1, ranges[0].second);
}
// RFC 9110 14.1: range unit names are case-insensitive.
for (const auto &s : {"Bytes=1-10", "BYTES=1-10", "bYtEs=1-10"}) {
Ranges ranges;
EXPECT_TRUE(detail::parse_range_header(s, ranges)) << s;
EXPECT_EQ((Ranges{{1, 10}}), ranges) << s;
}
}
TEST(ParseAcceptEncoding1, AcceptEncoding) {
@@ -23425,20 +23487,25 @@ TEST_F(SSEIntegrationTest, NonDigitRetryFieldIgnored) {
// Test: A retry field of all ASCII digits sets the reconnection time
TEST_F(SSEIntegrationTest, DigitRetryFieldApplied) {
std::atomic<int> connection_count{0};
std::mutex mutex;
std::condition_variable cv;
std::vector<std::chrono::steady_clock::time_point> connected_at;
server_->Get("/zero-retry",
[&connection_count](const Request &, Response &res) {
connection_count.fetch_add(1);
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "retry: 0\ndata: hello\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
server_->Get("/zero-retry", [&](const Request &, Response &res) {
{
std::lock_guard<std::mutex> lock(mutex);
connected_at.push_back(std::chrono::steady_clock::now());
cv.notify_all();
}
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "retry: 0\ndata: hello\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/zero-retry");
@@ -23446,13 +23513,21 @@ TEST_F(SSEIntegrationTest, DigitRetryFieldApplied) {
sse.set_reconnect_interval(10000);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait_for(lock, std::chrono::seconds(5),
[&] { return connected_at.size() >= 2; });
}
sse.stop();
// The server-supplied interval is applied, but never below 100ms, so
// "retry: 0" does not cause a busy reconnect loop
EXPECT_GE(connection_count.load(), 2);
EXPECT_LE(connection_count.load(), 10);
std::lock_guard<std::mutex> lock(mutex);
ASSERT_GE(connected_at.size(), 2u);
auto interval_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
connected_at[1] - connected_at[0])
.count();
EXPECT_GE(interval_ms, 100);
}
// Test: Last-Event-ID sent on reconnect
@@ -26900,6 +26975,39 @@ TEST(NoProxyTest, ProxyAuthorizationSentWhenNotBypassed) {
EXPECT_TRUE(s.last_had_proxy_authz());
}
TEST(NoProxyTest, ProxyAuthorizationSentWithOneEmptyCredential) {
std::atomic<int> hits{0};
std::atomic<int> hits_with_authz{0};
no_proxy_test::ScopedServer proxy_mock;
proxy_mock.svr().Get(".*", [&](const Request &req, Response &res) {
hits++;
if (req.has_header("Proxy-Authorization")) { hits_with_authz++; }
if (req.target.find("/redir") != std::string::npos) {
res.set_redirect("http://other.example/landed");
} else {
res.set_content("via-proxy", "text/plain");
}
});
proxy_mock.listen();
for (const auto &c : {std::make_pair("u", ""), std::make_pair("", "p")}) {
hits = 0;
hits_with_authz = 0;
Client cli("public.example", 80);
cli.set_proxy("127.0.0.1", proxy_mock.port());
cli.set_proxy_basic_auth(c.first, c.second);
cli.set_follow_location(true);
// The redirect to another host goes through the proxy as well
auto res = cli.Get("/redir");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(2, hits.load());
EXPECT_EQ(2, hits_with_authz.load());
}
}
TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) {
ProxyAndTargetServers s;
auto cli = make_client("anything.test", s);