Compare commits

..
10 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
yhirose 5107381963 Fix the RFC reference in the Host validation test
The rule that a client sends an empty Host value when the target URI
has no authority is in RFC 9112 §3.2, not RFC 9110 §7.2.
2026-10-11 23:44:07 -04:00
yhirose 0acbee16dc Reject an ambiguous or invalid Host in a request
A request with more than one Host field line, or with a Host value
holding a byte that cannot appear in uri-host [ ":" port ], is now
answered with 400 (RFC 9112 §3.2). The handler sees only the first
Host line, so such a request could reach it with an authority that
differs from the one an intermediary acted on.

Only the byte set is checked, not the full grammar. An empty value
stays valid, and a request without a Host line is still accepted.

Close #2623
2026-10-11 23:39:50 -04:00
yhirose 1f12bbaabb Treat a bare CR as an SSE line ending
The SSE spec ends a line with CRLF, LF or CR, but SSEClient split only
on LF, so a stream that uses CR alone never produced an event. The LF
of a CRLF is skipped even when it arrives in a later chunk than its CR.

Search for a line ending only in the newly received data. What is left
in the buffer has none, and scanning it again for every chunk made a
long line quadratic.
2026-10-11 23:05:46 -04:00
yhirose 5189edc52e Ignore an SSE id that cannot be sent as Last-Event-ID
SSEClient stored any id it received, but the client rejects a request
whose header value is invalid. An id with a NUL, a control character or
surrounding whitespace therefore made every reconnect fail with
Error::InvalidHeaders before reaching the server, and the id could
never be replaced.

Accept an id only if it is a valid header value, so that the previous
id stays in effect. The SSE spec asks for this for NUL only; the rest
follows from what this client can send.
2026-10-11 23:05:38 -04:00
3 changed files with 315 additions and 44 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site] [site]
title = "cpp-httplib" title = "cpp-httplib"
version = "0.60.1" version = "0.61.0"
hostname = "https://yhirose.github.io" hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib" base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved." footer_message = "© 2026 Yuji Hirose. All rights reserved."
+85 -27
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H #ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.60.1" #define CPPHTTPLIB_VERSION "0.61.0"
#define CPPHTTPLIB_VERSION_NUM "0x003c01" #define CPPHTTPLIB_VERSION_NUM "0x003d00"
#ifdef _WIN32 #ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00 #if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -1141,8 +1141,7 @@ private:
pointer operator->() const { return data_ + idx_; } pointer operator->() const { return data_ + idx_; }
iterator_t &operator++() { iterator_t &operator++() {
// Saturating, so that advancing past the last entry of a key (which // Saturating, so that advancing past the last entry of a key stays at
// get_multimap_value() does when asked for an out-of-range id) stays at
// end() instead of running off the container. // end() instead of running off the container.
if (idx_ >= size_) { return *this; } if (idx_ >= size_) { return *this; }
++idx_; ++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); 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, inline size_t get_header_value_u64(const Headers &headers,
const std::string &key, size_t def, const std::string &key, size_t def,
size_t id, bool &is_invalid_value) { size_t id, bool &is_invalid_value) {
is_invalid_value = false; is_invalid_value = false;
auto rng = headers.equal_range(key); auto rng = headers.equal_range(key);
auto it = rng.first; auto it = bounded_next(rng.first, id, rng.second);
std::advance(it, static_cast<ssize_t>(id));
if (it != rng.second) { if (it != rng.second) {
if (is_numeric(it->second)) { if (is_numeric(it->second)) {
// Parse at size_t width so an out-of-range Content-Length is reported // Parse at size_t width so an out-of-range Content-Length is reported
@@ -3968,6 +3974,7 @@ bool is_field_content(const std::string &s);
bool is_field_value(const std::string &s); bool is_field_value(const std::string &s);
bool is_field_valid(const std::string &name, const std::string &value); bool is_field_valid(const std::string &name, const std::string &value);
bool is_request_target(const std::string &s); bool is_request_target(const std::string &s);
bool is_host(const std::string &s);
} // namespace fields } // namespace fields
} // namespace detail } // namespace detail
@@ -4918,9 +4925,12 @@ inline bool SSEClient::parse_sse_line(const std::string &line, SSEMessage &msg,
msg.data += value; msg.data += value;
has_data = true; has_data = true;
} else if (field == "id") { } else if (field == "id") {
// Empty id is valid (clears the last event ID) // An id that cannot be sent back as Last-Event-ID is ignored. Empty id is
msg.id = value; // valid (clears the last event ID)
has_id = true; if (detail::fields::is_field_value(value)) {
msg.id = value;
has_id = true;
}
} else if (field == "retry") { } else if (field == "retry") {
// Parse retry interval in milliseconds // Parse retry interval in milliseconds
// Per the SSE spec, a value that is not all ASCII digits is ignored. // Per the SSE spec, a value that is not all ASCII digits is ignored.
@@ -4994,22 +5004,29 @@ inline void SSEClient::run_event_loop() {
auto has_data = false; auto has_data = false;
auto has_id = false; auto has_id = false;
auto first_line = true; auto first_line = true;
auto after_cr = false;
while (running_.load() && result.next()) { while (running_.load() && result.next()) {
// What is left in the buffer has no line ending
auto search_pos = buffer.size();
buffer.append(result.data(), result.size()); buffer.append(result.data(), result.size());
// Process complete lines in the buffer // Process complete lines in the buffer. A line ends with \r\n, \n or \r
// per the SSE spec
size_t line_start = 0; size_t line_start = 0;
size_t newline_pos; size_t eol_pos;
while ((newline_pos = buffer.find('\n', line_start)) != while ((eol_pos = buffer.find_first_of("\r\n", search_pos)) !=
std::string::npos) { std::string::npos) {
auto line = buffer.substr(line_start, newline_pos - line_start); auto line = buffer.substr(line_start, eol_pos - line_start);
line_start = newline_pos + 1; line_start = eol_pos + 1;
search_pos = line_start;
// Strip the \r of a CRLF line ending so that every field, including // The \n of a \r\n may arrive in a later chunk than its \r
// one without a colon, sees the same line auto is_lf_of_crlf =
if (!line.empty() && line.back() == '\r') { line.pop_back(); } after_cr && line.empty() && buffer[eol_pos] == '\n';
after_cr = buffer[eol_pos] == '\r';
if (is_lf_of_crlf) { continue; }
// A UTF-8 BOM at the start of the stream is ignored per the SSE spec // A UTF-8 BOM at the start of the stream is ignored per the SSE spec
if (first_line) { if (first_line) {
@@ -8135,8 +8152,7 @@ inline const char *get_header_value(const Headers &headers,
} }
auto rng = headers.equal_range(key); auto rng = headers.equal_range(key);
auto it = rng.first; auto it = bounded_next(rng.first, id, rng.second);
std::advance(it, static_cast<ssize_t>(id));
if (it != rng.second) { return it->second.c_str(); } if (it != rng.second) { return it->second.c_str(); }
return def; return def;
} }
@@ -8189,8 +8205,7 @@ template <typename Map>
inline typename Map::mapped_type inline typename Map::mapped_type
get_multimap_value(const Map &m, const std::string &key, size_t id) { get_multimap_value(const Map &m, const std::string &key, size_t id) {
auto rng = m.equal_range(key); auto rng = m.equal_range(key);
auto it = rng.first; auto it = bounded_next(rng.first, id, rng.second);
std::advance(it, static_cast<ssize_t>(id));
if (it != rng.second) { return it->second; } if (it != rng.second) { return it->second; }
return typename Map::mapped_type(); return typename Map::mapped_type();
} }
@@ -9178,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); 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 pos = static_cast<size_t>(6);
const auto len = static_cast<size_t>(s.size() - 6); const auto len = static_cast<size_t>(s.size() - 6);
auto all_valid_ranges = true; auto all_valid_ranges = true;
@@ -9236,8 +9251,30 @@ inline bool parse_range_header(const std::string &s, Ranges &ranges) try {
} catch (...) { return false; } } catch (...) { return false; }
#endif #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, inline bool parse_accept_header(const std::string &s,
std::vector<std::string> &content_types) { 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(); content_types.clear();
// Empty string is considered valid (no preference) // Empty string is considered valid (no preference)
@@ -10312,6 +10349,17 @@ inline bool is_request_target(const std::string &s) {
return std::all_of(s.begin(), s.end(), is_field_vchar); return std::all_of(s.begin(), s.end(), is_field_vchar);
} }
// RFC 9110 §7.2: the bytes of uri-host [ ":" port ], i.e. unreserved,
// sub-delims, "%", ":" and the IP-literal brackets. An empty value is valid.
inline bool is_host(const std::string &s) {
return std::all_of(s.begin(), s.end(), [](char c) {
return is_ascii_alnum(c) || c == '-' || c == '.' || c == '_' || c == '~' ||
c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' ||
c == ')' || c == '*' || c == '+' || c == ',' || c == ';' ||
c == '=' || c == '%' || c == ':' || c == '[' || c == ']';
});
}
} // namespace fields } // namespace fields
inline bool perform_websocket_handshake(Stream &strm, Request &req, inline bool perform_websocket_handshake(Stream &strm, Request &req,
@@ -11684,8 +11732,7 @@ inline bool Request::is_multipart_form_data() const {
inline std::string MultipartFormData::get_field(const std::string &key, inline std::string MultipartFormData::get_field(const std::string &key,
size_t id) const { size_t id) const {
auto rng = fields.equal_range(key); auto rng = fields.equal_range(key);
auto it = rng.first; auto it = detail::bounded_next(rng.first, id, rng.second);
std::advance(it, static_cast<ssize_t>(id));
if (it != rng.second) { return it->second.content; } if (it != rng.second) { return it->second.content; }
return std::string(); return std::string();
} }
@@ -14570,6 +14617,16 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
return write_response(strm, close_connection, req, res); return write_response(strm, close_connection, req, res);
} }
// RFC 9112 §3.2: Reject more than one Host field line or an invalid Host
// value, so that every recipient of the request sees the same authority.
if (req.get_header_value_count("Host") > 1 ||
!detail::fields::is_host(req.get_header_value("Host"))) {
connection_closed = true;
res.status = StatusCode::BadRequest_400;
output_error_log(Error::InvalidHeaders, &req);
return write_response(strm, close_connection, req, res);
}
// Check if the request URI doesn't exceed the limit // Check if the request URI doesn't exceed the limit
if (req.target.size() > CPPHTTPLIB_REQUEST_URI_MAX_LENGTH) { if (req.target.size() > CPPHTTPLIB_REQUEST_URI_MAX_LENGTH) {
connection_closed = true; connection_closed = true;
@@ -15887,7 +15944,8 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) {
if (!proxy_host_.empty() && proxy_port_ != -1) { if (!proxy_host_.empty() && proxy_port_ != -1) {
client.set_proxy(proxy_host_, proxy_port_); 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_, client.set_proxy_basic_auth(proxy_basic_auth_username_,
proxy_basic_auth_password_); proxy_basic_auth_password_);
} }
@@ -15990,8 +16048,8 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req,
// otherwise NO_PROXY-matched requests, and requests inside a TLS tunnel, // otherwise NO_PROXY-matched requests, and requests inside a TLS tunnel,
// would leak proxy credentials to the destination server. // would leak proxy credentials to the destination server.
if (is_proxy_enabled_for_host(host_) && (!is_ssl() || is_connect)) { if (is_proxy_enabled_for_host(host_) && (!is_ssl() || is_connect)) {
if (!proxy_basic_auth_username_.empty() && if ((!proxy_basic_auth_username_.empty() ||
!proxy_basic_auth_password_.empty() && !proxy_basic_auth_password_.empty()) &&
!req.has_header("Proxy-Authorization")) { !req.has_header("Proxy-Authorization")) {
req.headers.insert(make_basic_authentication_header( req.headers.insert(make_basic_authentication_header(
proxy_basic_auth_username_, proxy_basic_auth_password_, true)); proxy_basic_auth_username_, proxy_basic_auth_password_, true));
+229 -16
View File
@@ -1119,6 +1119,39 @@ TEST(ParseAcceptHeaderTest, EmptyListElementsAreIgnored) {
EXPECT_FALSE(detail::parse_accept_header(",invalidtype,", result)); 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) { TEST(ParseAcceptHeaderTest, ContentTypesPopulatedAndInvalidHeaderHandling) {
Server svr; Server svr;
@@ -1890,6 +1923,28 @@ TEST(HeadersOrderTest, IdBeyondTheLastDuplicateYieldsDefault) {
EXPECT_EQ("def", req.get_header_value("X-Missing", "def", 3)); 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) { TEST(HeadersOrderTest, TraversalFollowsInsertionOrder) {
Headers headers; Headers headers;
headers.emplace("Host", "example.com"); headers.emplace("Host", "example.com");
@@ -2033,6 +2088,13 @@ TEST(ParseHeaderValueTest, Range) {
EXPECT_EQ(0, ranges[0].first); EXPECT_EQ(0, ranges[0].first);
EXPECT_EQ(-1, ranges[0].second); 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) { TEST(ParseAcceptEncoding1, AcceptEncoding) {
@@ -19686,6 +19748,58 @@ TEST(RequestSmugglingTest, DuplicateContentLengthSameValues) {
ASSERT_EQ("HTTP/1.1 200 OK", response.substr(0, response.find("\r\n"))); ASSERT_EQ("HTTP/1.1 200 OK", response.substr(0, response.find("\r\n")));
} }
TEST(HostHeaderValidationTest, AmbiguousOrInvalidHostRejected) {
Server svr;
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
auto status_line = [&](const std::string &host_lines) {
std::string res;
send_request(
5, "GET /test HTTP/1.1\r\n" + host_lines + "Connection: close\r\n\r\n",
&res, port);
return res.substr(0, res.find("\r\n"));
};
const char *accepted[] = {
"Host: localhost\r\n",
"Host: a.example:8080\r\n",
"Host: my_service\r\n",
"Host: [::1]:8080\r\n",
"Host: [fe80::1%25eth0]\r\n",
"Host: \r\n", // RFC 9112 §3.2: the target URI has no authority
};
for (auto host_lines : accepted) {
EXPECT_EQ("HTTP/1.1 200 OK", status_line(host_lines)) << host_lines;
}
const char *rejected[] = {
"Host: a.example\r\nHost: b.example\r\n",
"Host: a.example\r\nHost: a.example\r\n",
"Host: a@b.example\r\n",
"Host: a.example/b\r\n",
"Host: a.example\\b\r\n",
"Host: a.example?b\r\n",
"Host: a.example#b\r\n",
"Host: a.example b.example\r\n",
};
for (auto host_lines : rejected) {
EXPECT_EQ("HTTP/1.1 400 Bad Request", status_line(host_lines))
<< host_lines;
}
}
TEST(HeaderSmugglingTest, ChunkedTrailerHeadersMerged) { TEST(HeaderSmugglingTest, ChunkedTrailerHeadersMerged) {
Server svr; Server svr;
@@ -22793,6 +22907,37 @@ TEST_F(SSEParsingTest, FieldNameOnlyWithCarriageReturn) {
EXPECT_EQ(msgs[0].data, ""); EXPECT_EQ(msgs[0].data, "");
} }
TEST_F(SSEParsingTest, CarriageReturnLineEnding) {
auto msgs = parse("event: update\rdata: hello\r\r");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, LineEndingSplitAcrossChunks) {
// Chunk boundaries fall inside every \r\n and right before the first one
std::vector<std::string> chunks = {"event: update", "\r", "\ndata: hello\r",
"\n\r", "\nevent: end\ndata: end\n\n"};
server_->Get("/split", [chunks](const Request &, Response &res) {
auto i = std::make_shared<size_t>(0);
res.set_chunked_content_provider(
"text/event-stream", [chunks, i](size_t /*offset*/, DataSink &sink) {
if (*i == chunks.size()) {
sink.done();
} else {
sink.write(chunks[*i].data(), chunks[*i].size());
(*i)++;
}
return true;
});
});
auto msgs = collect("/split");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, LeadingByteOrderMarkIgnored) { TEST_F(SSEParsingTest, LeadingByteOrderMarkIgnored) {
// Only a BOM at the start of the stream is ignored // Only a BOM at the start of the stream is ignored
auto msgs = parse("\xEF\xBB\xBF" auto msgs = parse("\xEF\xBB\xBF"
@@ -22869,6 +23014,28 @@ TEST_F(SSEParsingTest, EmptyEventIdClearsLastEventId) {
EXPECT_EQ(msgs[2].data, "not sent"); EXPECT_EQ(msgs[2].data, "not sent");
} }
TEST_F(SSEParsingTest, UnsendableEventIdKeepsLastEventId) {
// The first connection sets an id and then sends ids that cannot be carried
// in a header; the second echoes back the Last-Event-ID it was reconnected
// with
std::atomic<int> connection_count{0};
server_->Get("/id-unsendable", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content(std::string("id: 42\n\nid: a") + '\0' +
"b\n\nid: \x01\n\nid: 7\n\nid: 7 \n\n",
"text/event-stream");
} else {
res.set_content("data: " + req.get_header_value("Last-Event-ID") +
"\n\nevent: end\ndata: end\n\n",
"text/event-stream");
}
});
auto msgs = collect("/id-unsendable");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "42");
}
TEST_F(SSEParsingTest, CompleteEventParsing) { TEST_F(SSEParsingTest, CompleteEventParsing) {
auto msgs = parse("event: notification\nid: evt-42\n" auto msgs = parse("event: notification\nid: evt-42\n"
"data: {\"type\":\"alert\"}\nretry: 1000\n\n"); "data: {\"type\":\"alert\"}\nretry: 1000\n\n");
@@ -23320,20 +23487,25 @@ TEST_F(SSEIntegrationTest, NonDigitRetryFieldIgnored) {
// Test: A retry field of all ASCII digits sets the reconnection time // Test: A retry field of all ASCII digits sets the reconnection time
TEST_F(SSEIntegrationTest, DigitRetryFieldApplied) { 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", server_->Get("/zero-retry", [&](const Request &, Response &res) {
[&connection_count](const Request &, Response &res) { {
connection_count.fetch_add(1); std::lock_guard<std::mutex> lock(mutex);
res.set_chunked_content_provider( connected_at.push_back(std::chrono::steady_clock::now());
"text/event-stream", [](size_t offset, DataSink &sink) { cv.notify_all();
if (offset == 0) { }
std::string event = "retry: 0\ndata: hello\n\n"; res.set_chunked_content_provider(
sink.write(event.data(), event.size()); "text/event-stream", [](size_t offset, DataSink &sink) {
} if (offset == 0) {
return false; std::string event = "retry: 0\ndata: hello\n\n";
}); sink.write(event.data(), event.size());
}); }
return false;
});
});
Client client("localhost", get_port()); Client client("localhost", get_port());
sse::SSEClient sse(client, "/zero-retry"); sse::SSEClient sse(client, "/zero-retry");
@@ -23341,13 +23513,21 @@ TEST_F(SSEIntegrationTest, DigitRetryFieldApplied) {
sse.set_reconnect_interval(10000); sse.set_reconnect_interval(10000);
sse.start_async(); 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(); sse.stop();
// The server-supplied interval is applied, but never below 100ms, so // The server-supplied interval is applied, but never below 100ms, so
// "retry: 0" does not cause a busy reconnect loop // "retry: 0" does not cause a busy reconnect loop
EXPECT_GE(connection_count.load(), 2); std::lock_guard<std::mutex> lock(mutex);
EXPECT_LE(connection_count.load(), 10); 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 // Test: Last-Event-ID sent on reconnect
@@ -26795,6 +26975,39 @@ TEST(NoProxyTest, ProxyAuthorizationSentWhenNotBypassed) {
EXPECT_TRUE(s.last_had_proxy_authz()); 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) { TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) {
ProxyAndTargetServers s; ProxyAndTargetServers s;
auto cli = make_client("anything.test", s); auto cli = make_client("anything.test", s);