Compare commits

..
9 Commits
Author SHA1 Message Date
yhirose cf3693cb5c Release v0.59.0 2026-10-02 22:06:39 -04:00
yhirose 4fd9ae8f42 Serve pipelined requests without waiting for the keep-alive timeout
A client may pipeline its requests (RFC 9112 9.3.2). The server read the
following request(s) into a per-request SocketStream buffer, discarded them
with the stream after the first response, and then waited in keep_alive()
for socket data that never came, closing the connection after the
keep-alive timeout. Over TLS the bytes stayed decrypted in the TLS library,
where keep_alive() could not see them either.

Create one stream per connection and serve a request that is already
buffered (Stream::is_readable()) without waiting in keep_alive().

Keeping the buffer means an extra CRLF that some clients send after a
request body is now parsed as the next request line, which answered 400
and closed the connection. Ignore one empty line before the request-line,
as RFC 9112 2.2 recommends.

Fixes #2599
2026-10-02 21:47:18 -04:00
yhirose 3d40dfc727 Fix SSE parsing of CRLF field names and data-less events
A field line without a colon kept the \r of a CRLF line ending in its
name, so "data\r\n" was not recognized. Strip the \r once per line
before parsing instead of from each value.

An event without a data field was neither dispatched nor cleared, so
its event type leaked into the next event and its id only reached
last_event_id after a later event was dispatched. Reset the message on
every blank line and record the id even when nothing is dispatched.

The parsing tests exercised a copy of parse_sse_line that had drifted
from the real one. Run them through SSEClient against a local server
instead, and add regression tests for the cases above.
2026-10-02 21:38:39 -04:00
metsw24-max dd71728110 escape quoted-string auth-params in make_digest_authentication_header (#2597) 2026-10-02 21:29:35 -04:00
DosX 7255a7e979 Preserve empty SSE data fields (#2594)
SSE events may contain an empty data field, and a data field without a colon also has an empty value. Track whether a data field was seen separately from the accumulated payload so empty events are dispatched and leading empty lines are preserved. Add an integration regression test for both forms.
2026-10-02 21:17:47 -04:00
yhirose 8a3abfb597 Extract parse_int_in_range from parse_port
parse_port and the NO_PROXY CIDR prefix parsing each repeated the same
from_chars call, full-consumption check and range check. Move that into
a parse_int_in_range helper and use it in both places.
2026-10-02 21:15:14 -04:00
KBS 10aadd57f7 Reject trailing characters in URL port numbers (#2593)
parse_port checked only the error code of from_chars, which stops at
the first non-digit, so http://host:80abc was accepted as port 80, and
a redirect Location with such a port was followed. RFC 3986 defines
port as *DIGIT. Require the whole string to be consumed, as #2590 does
for quality values.
2026-10-02 21:07:34 -04:00
yhirose 43863e1f67 Make CryptoAPI the only chain verifier when Windows verification is on (#2604)
Every backend loads the Windows ROOT and CA stores, but Windows adds a
root to them only when CryptoAPI needs it to build a chain. On a machine
that had not needed a root yet, the backend rejected the chain before
the CryptoAPI check ran, so Windows never fetched the root (for example
OpenSSL error 20 for accounts.spotify.com under Starfield Root G2).

With Windows verification enabled, the backend's chain verdict is no
longer used. Mbed TLS and wolfSSL skip chain verification during the
handshake, and the post-handshake verify result is ignored. The
CryptoAPI check becomes mandatory: a leaf that cannot be encoded now
fails the connection instead of skipping the check, and the chain must
allow server authentication, which the backends used to check.

A server certificate verifier works on the backend's chain verification,
so when one is set the backend still decides and CryptoAPI only adds its
own check, as before.

ClientCertMissing now disables hostname verification: cert.pem does not
match HOST, and on Windows the hostname check fails before the chain
check.

Refs #2596
2026-10-02 20:48:47 -04:00
yhirose 0db1df7cf2 Pass the server's intermediates to Windows certificate verification (#2602)
CryptoAPI got only the leaf, so it fetched an issuer from the leaf's AIA
URL instead of using the intermediates the server sent. For
accounts.spotify.com that issuer chains to Certainly Root R1, which
Windows does not trust, while the server's own chain ends at Starfield
Root G2.

Add tls::get_peer_certs(), which returns the certificates the peer sent
in the same way get_ca_certs() returns the CA certificates, for every
backend. The CryptoAPI check puts them into a memory store that it
passes to CertGetCertificateChain(), skipping any certificate that
cannot be added. wolfSSL keeps the received chain only when built with
SESSION_CERTS; without it, CryptoAPI still gets the leaf alone.

Refs #2596
2026-10-02 20:48:10 -04:00
4 changed files with 699 additions and 326 deletions

No files matched your search

+1 -1
View File
@@ -230,7 +230,7 @@ cpp-httplib automatically integrates with the OS certificate store on macOS and
| Platform | Behavior | Disable (compile time) |
| :------- | :------- | :--------------------- |
| macOS | Loads system certs from Keychain (link `CoreFoundation` and `Security` with `-framework`). Requires Apple Clang; GCC is not supported for this feature. | `CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES` |
| Windows | Verifies certs via CryptoAPI (`CertGetCertificateChain` / `CertVerifyCertificateChainPolicy`) with revocation checking | `CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE` |
| Windows | Verifies the certificate chain with CryptoAPI (`CertGetCertificateChain` / `CertVerifyCertificateChainPolicy`) instead of the TLS backend, with revocation checking. Windows fetches missing roots and intermediates on demand. With a custom CA, the TLS backend verifies the chain instead; with `set_server_certificate_verifier()`, both do. | `CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE` |
On Windows, verification can also be disabled at runtime:
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.58.0"
version = "0.59.0"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+238 -99
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.58.0"
#define CPPHTTPLIB_VERSION_NUM "0x003a00"
#define CPPHTTPLIB_VERSION "0.59.0"
#define CPPHTTPLIB_VERSION_NUM "0x003b00"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -831,14 +831,21 @@ inline from_chars_result<double> from_chars(const char *first, const char *last,
return {p, std::errc{}};
}
inline bool parse_port(const char *s, size_t len, int &port) {
inline bool parse_int_in_range(const char *s, size_t len, int lo, int hi,
int &out) {
int val = 0;
auto r = from_chars(s, s + len, val);
if (r.ec != std::errc{} || val < 1 || val > 65535) { return false; }
port = val;
if (r.ec != std::errc{} || r.ptr != s + len || val < lo || val > hi) {
return false;
}
out = val;
return true;
}
inline bool parse_port(const char *s, size_t len, int &port) {
return parse_int_in_range(s, len, 1, 65535, port);
}
inline bool parse_port(const std::string &s, int &port) {
return parse_port(s.data(), s.size(), port);
}
@@ -3442,6 +3449,9 @@ private:
#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE
bool enable_windows_cert_verification_ = true;
// Like ca_cert_store_set_, tracks what ctx_ cannot report back: whether
// set_server_certificate_verifier() installed a verifier.
bool server_certificate_verifier_set_ = false;
#endif
friend class ClientImpl;
@@ -4366,7 +4376,8 @@ public:
void stop();
private:
bool parse_sse_line(const std::string &line, SSEMessage &msg, int &retry_ms);
bool parse_sse_line(const std::string &line, SSEMessage &msg, int &retry_ms,
bool &has_data);
void run_event_loop();
void dispatch_event(const SSEMessage &msg);
bool should_reconnect(int count) const;
@@ -4876,30 +4887,23 @@ inline void SSEClient::stop() {
}
inline bool SSEClient::parse_sse_line(const std::string &line, SSEMessage &msg,
int &retry_ms) {
int &retry_ms, bool &has_data) {
// Blank line signals end of event
if (line.empty() || line == "\r") { return true; }
if (line.empty()) { return true; }
// Lines starting with ':' are comments (ignored)
if (!line.empty() && line[0] == ':') { return false; }
if (line[0] == ':') { return false; }
// Find the colon separator
auto colon_pos = line.find(':');
if (colon_pos == std::string::npos) {
// Line with no colon is treated as field name with empty value
return false;
}
auto field = line.substr(0, colon_pos);
std::string value;
// Value starts after colon, skip optional single space
if (colon_pos + 1 < line.size()) {
if (colon_pos != std::string::npos && colon_pos + 1 < line.size()) {
auto value_start = colon_pos + 1;
if (line[value_start] == ' ') { value_start++; }
value = line.substr(value_start);
// Remove trailing \r if present
if (!value.empty() && value.back() == '\r') { value.pop_back(); }
}
// Handle known fields
@@ -4907,8 +4911,9 @@ inline bool SSEClient::parse_sse_line(const std::string &line, SSEMessage &msg,
msg.event = value;
} else if (field == "data") {
// Multiple data lines are concatenated with newlines
if (!msg.data.empty()) { msg.data += "\n"; }
if (has_data) { msg.data += "\n"; }
msg.data += value;
has_data = true;
} else if (field == "id") {
// Empty id is valid (clears the last event ID)
msg.id = value;
@@ -4982,6 +4987,7 @@ inline void SSEClient::run_event_loop() {
// Event receiving loop
std::string buffer;
SSEMessage current_msg;
bool has_data = false;
while (running_.load() && result.next()) {
buffer.append(result.data(), result.size());
@@ -4995,18 +5001,25 @@ inline void SSEClient::run_event_loop() {
auto line = buffer.substr(line_start, newline_pos - line_start);
line_start = newline_pos + 1;
// Strip the \r of a CRLF line ending so that every field, including
// one without a colon, sees the same line
if (!line.empty() && line.back() == '\r') { line.pop_back(); }
// Parse the line and check if event is complete
auto event_complete =
parse_sse_line(line, current_msg, reconnect_interval_ms_);
parse_sse_line(line, current_msg, reconnect_interval_ms_, has_data);
if (event_complete && !current_msg.data.empty()) {
// Update last_event_id for reconnection
if (event_complete) {
// Update last_event_id for reconnection, even for an event that
// has no data
if (!current_msg.id.empty()) { last_event_id_ = current_msg.id; }
// Dispatch event to appropriate handler
dispatch_event(current_msg);
// An event without a data field is not dispatched
if (has_data) { dispatch_event(current_msg); }
// Reset the message for the next event either way
current_msg.clear();
has_data = false;
}
}
@@ -5120,6 +5133,9 @@ bool is_peer_closed(session_t session, socket_t sock);
// Certificate verification
cert_t get_peer_cert(const_session_t session);
// The certificates the peer sent, leaf first. Free each with free_cert(), and
// do not use them after free_session(), as with get_peer_cert().
size_t get_peer_certs(const_session_t session, std::vector<cert_t> &certs);
void free_cert(cert_t cert);
bool verify_hostname(cert_t cert, const char *hostname);
uint64_t hostname_mismatch_code();
@@ -6608,18 +6624,30 @@ inline bool keep_alive(const std::atomic<socket_t> &svr_sock, socket_t sock,
return false;
}
template <typename T>
inline bool
process_server_socket_core(const std::atomic<socket_t> &svr_sock, socket_t sock,
size_t keep_alive_max_count,
time_t keep_alive_timeout_sec, T callback) {
// `has_buffered_request` reports whether the connection's stream already holds
// bytes of the next request. A client may pipeline its requests (RFC 9112
// 9.3.2), so reading one request can pull the start of the next one into the
// stream's buffer; that request must be served without waiting for the socket
// to become readable again, since its bytes are no longer on the socket.
// `callback` is told whether keep_alive() has just seen the socket go readable.
template <typename P, typename T>
inline bool process_server_socket_core(const std::atomic<socket_t> &svr_sock,
socket_t sock,
size_t keep_alive_max_count,
time_t keep_alive_timeout_sec,
P has_buffered_request, T callback) {
assert(keep_alive_max_count > 0);
auto ret = false;
auto count = keep_alive_max_count;
while (count > 0 && keep_alive(svr_sock, sock, keep_alive_timeout_sec)) {
while (count > 0) {
auto socket_readable = false;
if (!has_buffered_request()) {
if (!keep_alive(svr_sock, sock, keep_alive_timeout_sec)) { break; }
socket_readable = true;
}
auto close_connection = count == 1;
auto connection_closed = false;
ret = callback(close_connection, connection_closed);
ret = callback(socket_readable, close_connection, connection_closed);
if (!ret || connection_closed) { break; }
count--;
}
@@ -6633,14 +6661,16 @@ process_server_socket(const std::atomic<socket_t> &svr_sock, socket_t sock,
time_t keep_alive_timeout_sec, time_t read_timeout_sec,
time_t read_timeout_usec, time_t write_timeout_sec,
time_t write_timeout_usec, T callback) {
// One stream per connection: its read buffer can already hold the start of
// the next, pipelined request.
SocketStream strm(sock, read_timeout_sec, read_timeout_usec,
write_timeout_sec, write_timeout_usec);
return process_server_socket_core(
svr_sock, sock, keep_alive_max_count, keep_alive_timeout_sec,
[&](bool close_connection, bool &connection_closed) {
SocketStream strm(sock, read_timeout_sec, read_timeout_usec,
write_timeout_sec, write_timeout_usec);
// process_server_socket_core() only gets here once keep_alive() has
// seen the socket go readable.
strm.set_readable_hint();
[&]() { return strm.is_readable(); },
[&](bool socket_readable, bool close_connection,
bool &connection_closed) {
if (socket_readable) { strm.set_readable_hint(); }
return callback(strm, close_connection, connection_closed);
});
}
@@ -10099,6 +10129,19 @@ inline std::string unescape_quoted_pairs(const std::string &s) {
return out;
}
// Inverse of unescape_quoted_pairs: prepares a value to sit inside a
// quoted-string. RFC 9110 §5.6.4 requires a literal '\' or '"' to be sent as a
// quoted-pair, so the recipient recovers the original value.
inline std::string escape_quoted_pairs(const std::string &s) {
std::string out;
out.reserve(s.size());
for (auto c : s) {
if (c == '\\' || c == '"') { out += '\\'; }
out += c;
}
return out;
}
inline bool parse_www_authenticate(const Response &res,
std::map<std::string, std::string> &auth,
bool is_proxy) {
@@ -10555,13 +10598,16 @@ inline bool process_server_socket_ssl(
socket_t sock, size_t keep_alive_max_count, time_t keep_alive_timeout_sec,
time_t read_timeout_sec, time_t read_timeout_usec, time_t write_timeout_sec,
time_t write_timeout_usec, T callback) {
// See process_server_socket(). The TLS library keeps already decrypted bytes
// of a pipelined request, which keep_alive() cannot see on the socket.
SSLSocketStream strm(sock, session, read_timeout_sec, read_timeout_usec,
write_timeout_sec, write_timeout_usec);
return process_server_socket_core(
svr_sock, sock, keep_alive_max_count, keep_alive_timeout_sec,
[&](bool close_connection, bool &connection_closed) {
SSLSocketStream strm(sock, session, read_timeout_sec, read_timeout_usec,
write_timeout_sec, write_timeout_usec);
// See the non-TLS path in process_server_socket().
strm.set_readable_hint();
[&]() { return strm.is_readable(); },
[&](bool socket_readable, bool close_connection,
bool &connection_closed) {
if (socket_readable) { strm.set_readable_hint(); }
return callback(strm, close_connection, connection_closed);
});
}
@@ -10602,7 +10648,13 @@ inline std::pair<std::string, std::string> make_digest_authentication_header(
}
std::string algo = "MD5";
if (auth.find("algorithm") != auth.end()) { algo = auth.at("algorithm"); }
if (auth.find("algorithm") != auth.end()) {
// algorithm is an unquoted token (RFC 7616 §3.4). A server value that is
// not a token would otherwise be emitted verbatim and could carry commas
// or quotes that inject further auth-params into the header below.
const auto &a = auth.at("algorithm");
if (fields::is_token(a)) { algo = a; }
}
std::string response;
{
@@ -10625,14 +10677,23 @@ inline std::pair<std::string, std::string> make_digest_authentication_header(
auto opaque = (auth.find("opaque") != auth.end()) ? auth.at("opaque") : "";
auto field = "Digest username=\"" + username + "\", realm=\"" +
auth.at("realm") + "\", nonce=\"" + auth.at("nonce") +
"\", uri=\"" + req.path + "\", algorithm=" + algo +
(qop.empty() ? ", response=\""
: ", qop=" + qop + ", nc=" + nc + ", cnonce=\"" +
cnonce + "\", response=\"") +
response + "\"" +
(opaque.empty() ? "" : ", opaque=\"" + opaque + "\"");
// Every value placed inside a quoted-string is escaped so a '"' in it cannot
// close the string early. realm, nonce and opaque come straight from the
// server's challenge (parse_www_authenticate() already de-escaped them), so
// without this a crafted challenge injects extra auth-params into the header.
auto field =
"Digest username=\"" + detail::escape_quoted_pairs(username) +
"\", realm=\"" + detail::escape_quoted_pairs(auth.at("realm")) +
"\", nonce=\"" + detail::escape_quoted_pairs(auth.at("nonce")) +
"\", uri=\"" + detail::escape_quoted_pairs(req.path) +
"\", algorithm=" + algo +
(qop.empty() ? ", response=\""
: ", qop=" + qop + ", nc=" + nc + ", cnonce=\"" + cnonce +
"\", response=\"") +
response + "\"" +
(opaque.empty()
? ""
: ", opaque=\"" + detail::escape_quoted_pairs(opaque) + "\"");
auto key = is_proxy ? "Proxy-Authorization" : "Authorization";
return std::make_pair(key, field);
@@ -10697,7 +10758,7 @@ inline bool match_hostname(const std::string &pattern,
#ifdef _WIN32
// Verify certificate using Windows CertGetCertificateChain API.
// This provides real-time certificate validation with Windows Update
// integration, independent of the TLS backend (OpenSSL or MbedTLS).
// integration, independent of the TLS backend.
inline bool verify_cert_with_windows_schannel(
const std::vector<unsigned char> &der_cert, const std::string &hostname,
bool verify_hostname, uint64_t &out_error, tls::const_session_t session) {
@@ -10718,28 +10779,37 @@ inline bool verify_cert_with_windows_schannel(
auto cert_guard =
scope_exit([&] { CertFreeCertificateContext(cert_context); });
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
// Add the intermediates the server sent. Without them CryptoAPI follows the
// leaf's AIA URL, which may lead to an issuer under an untrusted root.
// Give CryptoAPI the certificates the server sent. Without them it follows
// the leaf's AIA URL, which may lead to an issuer under an untrusted root.
std::vector<tls::cert_t> peer_certs;
tls::get_peer_certs(session, peer_certs);
auto store = CertOpenStore(CERT_STORE_PROV_MEMORY, 0, 0, 0, nullptr);
auto store_guard = scope_exit([&] { CertCloseStore(store, 0); });
auto sk = SSL_get_peer_cert_chain(static_cast<const SSL *>(session));
for (int i = 1; sk && i < sk_X509_num(sk); i++) {
auto store_guard = scope_exit([&] {
for (auto cert : peer_certs) {
tls::free_cert(cert);
}
if (store) { CertCloseStore(store, 0); }
});
for (auto cert : peer_certs) {
std::vector<unsigned char> der;
tls::get_cert_der(sk_X509_value(sk, i), der);
CertAddEncodedCertificateToStore(store, X509_ASN_ENCODING, der.data(),
static_cast<DWORD>(der.size()),
CERT_STORE_ADD_USE_EXISTING, nullptr);
if (store && tls::get_cert_der(cert, der)) {
CertAddEncodedCertificateToStore(store, X509_ASN_ENCODING, der.data(),
static_cast<DWORD>(der.size()),
CERT_STORE_ADD_USE_EXISTING, nullptr);
}
}
#else
(void)session;
auto store = cert_context->hCertStore;
#endif
// Setup chain parameters
CERT_CHAIN_PARA chain_para = {};
chain_para.cbSize = sizeof(chain_para);
// Require the server authentication usage along the chain, which also
// rejects roots that Windows trusts only for other purposes.
LPSTR server_auth = const_cast<LPSTR>(szOID_PKIX_KP_SERVER_AUTH);
chain_para.RequestedUsage.dwType = USAGE_MATCH_TYPE_AND;
chain_para.RequestedUsage.Usage.cUsageIdentifier = 1;
chain_para.RequestedUsage.Usage.rgpszUsageIdentifier = &server_auth;
// Build certificate chain with revocation checking
PCCERT_CHAIN_CONTEXT chain_context = nullptr;
auto chain_result = CertGetCertificateChain(
@@ -10851,6 +10921,9 @@ struct ClientTlsSessionOptions {
// The caller decides whether Schannel has anything to say about this
// connection; see SSLClient::initialize_ssl().
bool windows_cert_verification = false;
// A server certificate verifier works on the backend's chain verification,
// so the backend keeps deciding and Schannel only adds its own check.
bool server_certificate_verifier_set = false;
#endif
};
@@ -10885,12 +10958,24 @@ inline bool setup_client_tls_session(
return fail(Error::SSLConnection, 0, 0);
}
// With Windows verification on and no server certificate verifier set,
// Schannel is the only chain verifier. The backend's trust store is a
// snapshot of the Windows stores that lacks the roots Windows fetches on
// demand, so the backend's verdict is not used.
auto windows_verifies_chain = false;
#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE
windows_verifies_chain = options.windows_cert_verification &&
!options.server_certificate_verifier_set;
#endif
#if defined(CPPHTTPLIB_MBEDTLS_SUPPORT) || defined(CPPHTTPLIB_WOLFSSL_SUPPORT)
// Mbed TLS and wolfSSL need the verification mode set explicitly; OpenSSL
// uses SSL_VERIFY_NONE and does all verification post-handshake. Chain
// verification happens during the handshake even for IP hosts; the
// certificate identity is verified post-handshake via verify_hostname().
set_verify_client(ctx, server_certificate_verification);
// uses SSL_VERIFY_NONE and does all verification post-handshake. Unless
// Schannel verifies the chain instead, chain verification happens during
// the handshake even for IP hosts; the certificate identity is verified
// post-handshake via verify_hostname().
set_verify_client(ctx,
server_certificate_verification && !windows_verifies_chain);
#endif
{
@@ -10935,10 +11020,12 @@ inline bool setup_client_tls_session(
if (verification_status == SSLVerifierResponse::NoDecisionMade &&
server_certificate_verification) {
auto verify_result = get_verify_result(session);
if (verify_result != 0) {
return fail(Error::SSLServerVerification, 0,
static_cast<uint64_t>(verify_result));
if (!windows_verifies_chain) {
auto verify_result = get_verify_result(session);
if (verify_result != 0) {
return fail(Error::SSLServerVerification, 0,
static_cast<uint64_t>(verify_result));
}
}
auto server_cert = get_peer_cert(session);
@@ -10958,18 +11045,17 @@ inline bool setup_client_tls_session(
}
#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE
// Additional Windows Schannel verification.
// This provides real-time certificate validation with Windows Update
// integration, working with both OpenSSL and MbedTLS backends.
// Windows Schannel verification, which lets Windows fetch missing roots
// and intermediates on demand. It must not be skipped: unless a server
// certificate verifier is set, it is the only chain check.
if (options.windows_cert_verification) {
std::vector<unsigned char> der;
if (get_cert_der(server_cert, der)) {
uint64_t wincrypt_error = 0;
if (!verify_cert_with_windows_schannel(
der, host, options.server_hostname_verification, wincrypt_error,
session)) {
return fail(Error::SSLServerVerification, 0, wincrypt_error);
}
uint64_t wincrypt_error = 0;
if (!get_cert_der(server_cert, der) ||
!verify_cert_with_windows_schannel(
der, host, options.server_hostname_verification, wincrypt_error,
session)) {
return fail(Error::SSLServerVerification, 0, wincrypt_error);
}
}
#endif
@@ -12437,14 +12523,10 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) {
struct in_addr v4;
if (inet_pton(AF_INET, addr_part.c_str(), &v4) == 1) {
int prefix = 32;
if (!prefix_part.empty()) {
auto r = from_chars(prefix_part.data(),
prefix_part.data() + prefix_part.size(), prefix);
if (r.ec != std::errc{} ||
r.ptr != prefix_part.data() + prefix_part.size()) {
return false;
}
if (prefix < 0 || prefix > 32) { return false; }
if (!prefix_part.empty() &&
!parse_int_in_range(prefix_part.data(), prefix_part.size(), 0, 32,
prefix)) {
return false;
}
out.kind = NoProxyKind::IPv4Cidr;
std::memcpy(out.net.data(), &v4, sizeof(v4));
@@ -12456,14 +12538,10 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) {
struct in6_addr v6;
if (inet_pton(AF_INET6, addr_part.c_str(), &v6) == 1) {
int prefix = 128;
if (!prefix_part.empty()) {
auto r = from_chars(prefix_part.data(),
prefix_part.data() + prefix_part.size(), prefix);
if (r.ec != std::errc{} ||
r.ptr != prefix_part.data() + prefix_part.size()) {
return false;
}
if (prefix < 0 || prefix > 128) { return false; }
if (!prefix_part.empty() &&
!parse_int_in_range(prefix_part.data(), prefix_part.size(), 0, 128,
prefix)) {
return false;
}
out.kind = NoProxyKind::IPv6Cidr;
std::memcpy(out.net.data(), &v6, sizeof(v6));
@@ -14386,6 +14464,13 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
// Connection has been closed on client
if (!line_reader.getline()) { return false; }
// RFC 9112 2.2: ignore an empty line received before the request-line. Some
// clients send an extra CRLF after a request body, which would otherwise be
// parsed as the next request on a persistent connection.
if (strcmp(line_reader.ptr(), "\r\n") == 0 && !line_reader.getline()) {
return false;
}
Request req;
req.start_time_ = std::chrono::steady_clock::now();
req.remote_addr = remote_addr;
@@ -18510,6 +18595,9 @@ inline void SSLClient::set_ca_cert_store(tls::ca_store_t ca_cert_store) {
inline void
SSLClient::set_server_certificate_verifier(tls::VerifyCallback verifier) {
if (!ctx_) { return; }
#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE
server_certificate_verifier_set_ = static_cast<bool>(verifier);
#endif
tls::set_verify_callback(ctx_, verifier);
}
@@ -18571,6 +18659,9 @@ inline bool SSLClient::initialize_ssl(Socket &socket, Error &error) {
enable_windows_cert_verification_ &&
system_ca_mode_ != SystemCAMode::Disabled && ca_cert_file_path_.empty() &&
ca_cert_dir_path_.empty() && ca_cert_pem_.empty() && !ca_cert_store_set_;
// Only a verifier set through set_server_certificate_verifier() is seen
// here, not one installed with tls::set_verify_callback() directly.
options.server_certificate_verifier_set = server_certificate_verifier_set_;
#endif
tls::session_t session = nullptr;
@@ -19540,6 +19631,24 @@ inline cert_t get_peer_cert(const_session_t session) {
static_cast<SSL *>(const_cast<void *>(session))));
}
inline size_t get_peer_certs(const_session_t session,
std::vector<cert_t> &certs) {
certs.clear();
if (!session) { return 0; }
auto ssl = static_cast<const SSL *>(session);
// On the server side, the chain leaves out the peer's own certificate
if (SSL_is_server(ssl)) {
if (auto leaf = get_peer_cert(session)) { certs.push_back(leaf); }
}
auto sk = SSL_get_peer_cert_chain(ssl);
for (int i = 0; sk && i < sk_X509_num(sk); i++) {
auto x509 = sk_X509_value(sk, i);
X509_up_ref(x509);
certs.push_back(static_cast<cert_t>(x509));
}
return certs.size();
}
inline void free_cert(cert_t cert) {
if (cert) { X509_free(static_cast<X509 *>(cert)); }
}
@@ -20884,6 +20993,18 @@ inline cert_t get_peer_cert(const_session_t session) {
return const_cast<mbedtls_x509_crt *>(cert);
}
inline size_t get_peer_certs(const_session_t session,
std::vector<cert_t> &certs) {
certs.clear();
// Mbed TLS parses the whole received chain into a list headed by the peer
// certificate, owned by the session like get_peer_cert()'s result
for (auto crt = static_cast<mbedtls_x509_crt *>(get_peer_cert(session));
crt && crt->raw.len > 0; crt = crt->next) {
certs.push_back(static_cast<cert_t>(crt));
}
return certs.size();
}
inline void free_cert(cert_t cert) {
// Mbed TLS: peer certificate is owned by the SSL context.
// No-op here, but callers should still call this for cross-backend
@@ -22041,6 +22162,24 @@ inline cert_t get_peer_cert(const_session_t session) {
return static_cast<cert_t>(cert);
}
inline size_t get_peer_certs(const_session_t session,
std::vector<cert_t> &certs) {
certs.clear();
if (!session) { return 0; }
// wolfSSL keeps the received chain only when built with SESSION_CERTS
#ifdef SESSION_CERTS
auto wsession =
static_cast<impl::WolfSSLSession *>(const_cast<void *>(session));
auto chain = wolfSSL_get_peer_chain(wsession->ssl);
auto count = chain ? wolfSSL_get_chain_count(chain) : 0;
for (int i = 0; i < count; i++) {
auto x509 = wolfSSL_get_chain_X509(chain, i);
if (x509) { certs.push_back(static_cast<cert_t>(x509)); }
}
#endif
return certs.size();
}
inline void free_cert(cert_t cert) {
if (cert) { wolfSSL_X509_free(static_cast<WOLFSSL_X509 *>(cert)); }
}
+459 -225
View File
@@ -3247,6 +3247,53 @@ TEST(DigestAuthTest, ChallengeMissingRealmDoesNotCrash) {
run_digest_challenge_missing_field_test("Digest nonce=\"n\", qop=\"auth\"");
}
// A hostile server can put a '"' in realm/nonce/opaque, or a non-token
// algorithm, in its challenge. parse_www_authenticate() de-escapes quoted-pairs
// when storing the values, so the header builder has to re-escape them (and
// keep algorithm a bare token); otherwise the value breaks out of its
// quoted-string and injects extra auth-params into the client's Authorization.
TEST(DigestAuthTest, EscapesInjectedAuthParams) {
std::atomic<int> hits{0};
std::string authorization;
Server svr;
svr.Get("/x", [&](const Request &req, Response &res) {
if (++hits == 1) {
res.status = StatusCode::Unauthorized_401;
// On the wire the quotes embedded in the values are backslash-escaped.
res.set_header("WWW-Authenticate",
"Digest realm=\"testrealm\", "
"nonce=\"n\\\"; injected=\\\"x\", "
"algorithm=\"MD5, injected2=\\\"y\\\"\", qop=\"auth\"");
} else {
authorization = req.get_header_value("Authorization");
res.set_content("ok", "text/plain");
}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_digest_auth("hello", "world");
auto res = cli.Get("/x");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(2, hits.load());
EXPECT_EQ(0u, authorization.rfind("Digest ", 0));
// The nonce (de-escaped to n"; injected="x ) must be re-escaped so it stays
// inside its quoted-string rather than starting an "injected" auth-param.
EXPECT_NE(std::string::npos,
authorization.find("nonce=\"n\\\"; injected=\\\"x\""));
// A non-token algorithm falls back to a bare MD5 token, dropping the payload.
EXPECT_EQ(std::string::npos, authorization.find("injected2"));
}
#endif
TEST(SpecifyServerIPAddressTest, AnotherHostname_Online) {
@@ -3754,6 +3801,34 @@ TEST(RedirectToDifferentPort, OverflowPortNumber) {
EXPECT_FALSE(res);
}
TEST(RedirectToDifferentPort, TrailingCharactersInPort) {
Server svr;
auto port = svr.bind_to_any_port(HOST);
svr.Get("/redir", [&](const Request & /*req*/, Response &res) {
// The server's own port followed by junk must not be followed
res.set_redirect("http://" + std::string(HOST) + ":" +
std::to_string(port) + "junk/target");
});
svr.Get("/target", [&](const Request & /*req*/, Response &res) {
res.set_content("target", "text/plain");
});
auto thread = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_follow_location(true);
auto res = cli.Get("/redir");
EXPECT_FALSE(res);
}
TEST(RedirectFromPageWithContent, Redirect) {
Server svr;
@@ -11406,6 +11481,134 @@ TEST(KeepAliveTest, MaxCount) {
}
}
// A client may pipeline its requests (RFC 9112 9.3.2), so reading one request
// can pull the next one into the server's buffer. The server must serve it
// without waiting for the socket to become readable again. The keep-alive
// timeout outlasts the client's read timeout, so a request left waiting for it
// shows up as a missing response.
static void serve_pipelining_routes(Server &svr,
const std::function<void(int)> &client) {
svr.set_keep_alive_timeout(5);
svr.Get("/hi/(\\d+)", [](const Request &req, Response &res) {
res.set_content("hi " + req.matches[1].str(), "text/plain");
});
svr.Post("/echo", [](const Request &req, Response &res) {
res.set_content("echo " + req.body, "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();
});
svr.wait_until_ready();
client(port);
}
static std::string
send_pipelined_requests(const std::vector<std::string> &parts) {
Server svr;
std::string res;
serve_pipelining_routes(svr, [&](int port) {
EXPECT_TRUE(send_request_in_parts(2, parts, &res, port));
});
return res;
}
static void expect_in_order(const std::string &res,
const std::vector<std::string> &bodies) {
size_t pos = 0;
for (const auto &body : bodies) {
pos = res.find(body, pos);
ASSERT_NE(std::string::npos, pos) << "missing or out of order: " << body;
pos += body.size();
}
}
TEST(KeepAliveTest, PipelinedRequests) {
auto res = send_pipelined_requests(
{"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"POST /echo HTTP/1.1\r\nHost: localhost\r\n"
"Content-Length: 4\r\n\r\nbody"
"GET /hi/3 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"});
expect_in_order(res, {"hi 1", "echo body", "hi 3"});
}
// The second request starts in the server's buffer and ends on the socket.
TEST(KeepAliveTest, PipelinedRequestSplitAcrossReads) {
auto res =
send_pipelined_requests({"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"GET /hi/2 HTTP/1.1\r\nHo",
"st: localhost\r\nConnection: close\r\n\r\n"});
expect_in_order(res, {"hi 1", "hi 2"});
}
// RFC 9112 2.2: an empty line before the request-line is ignored, so an extra
// CRLF after a body does not turn into a 400 for the next request.
TEST(KeepAliveTest, EmptyLineBeforeRequestLineIsIgnored) {
auto res = send_pipelined_requests(
{"POST /echo HTTP/1.1\r\nHost: localhost\r\n"
"Content-Length: 4\r\n\r\nbody\r\n"
"GET /hi/2 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"});
EXPECT_EQ(std::string::npos, res.find("400 Bad Request"));
expect_in_order(res, {"echo body", "hi 2"});
}
// Where an unparsable request ends is unknown, so what follows it in the buffer
// must not be served as the next request.
TEST(KeepAliveTest, PipelinedRequestAfterInvalidRequestIsNotServed) {
auto res = send_pipelined_requests(
{"INVALID REQUEST LINE\r\n\r\n"
"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"});
EXPECT_EQ("HTTP/1.1 400 Bad Request", res.substr(0, 24));
EXPECT_EQ(std::string::npos, res.find("hi 1"));
}
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
// Over TLS, the next request is held by the TLS library as already decrypted
// data rather than on the socket.
TEST(KeepAliveTest, SSLPipelinedRequests) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
std::string res;
serve_pipelining_routes(svr, [&](int port) {
auto error = Error::Success;
auto sock = detail::create_client_socket(
HOST, "", port, AF_UNSPEC, false, false, nullptr,
/*connection_timeout_sec=*/5, 0,
/*read_timeout_sec=*/2, 0,
/*write_timeout_sec=*/5, 0, std::string(), error);
ASSERT_NE(INVALID_SOCKET, sock);
auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
auto ctx = SSL_CTX_new(TLS_client_method());
auto ssl = SSL_new(ctx);
auto ssl_se = detail::scope_exit([&] {
SSL_free(ssl);
SSL_CTX_free(ctx);
});
SSL_set_fd(ssl, static_cast<int>(sock));
ASSERT_EQ(1, SSL_connect(ssl));
// One write, so all three requests arrive in one TLS record
const std::string req =
"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"GET /hi/2 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"GET /hi/3 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
ASSERT_EQ(static_cast<int>(req.size()),
SSL_write(ssl, req.data(), static_cast<int>(req.size())));
char buf[512];
int n;
while ((n = SSL_read(ssl, buf, sizeof(buf))) > 0) {
res.append(buf, static_cast<size_t>(n));
}
});
expect_in_order(res, {"hi 1", "hi 2", "hi 3"});
}
#endif
TEST(KeepAliveTest, Issue1041) {
Server svr;
svr.set_keep_alive_timeout(3);
@@ -13848,6 +14051,11 @@ TEST(HostAndPortPropertiesTest, PortOutOfRange) {
ASSERT_FALSE(cli.is_valid());
}
TEST(HostAndPortPropertiesTest, TrailingCharactersInPort) {
httplib::Client cli("http://www.google.com:80abc");
ASSERT_FALSE(cli.is_valid());
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(HostAndPortPropertiesTest, SSL) {
httplib::SSLClient cli("www.google.com");
@@ -13967,6 +14175,32 @@ TEST(SSLClientTest, WindowsCertificateVerification_ServerIntermediates_Online) {
ASSERT_TRUE(res) << "Error: " << to_string(res.error())
<< " ssl_backend_error=" << res.ssl_backend_error();
}
// Windows, not the backend, decides on the chain: the error carries a
// CryptoAPI trust status, which no backend error for a self-signed
// certificate has.
TEST(SSLClientTest, WindowsCertificateVerification_RejectsUntrustedRoot) {
SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli("127.0.0.1", PORT);
cli.set_connection_timeout(30);
auto res = cli.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLServerVerification, res.error());
EXPECT_NE(0u, res.ssl_backend_error() & CERT_TRUST_IS_UNTRUSTED_ROOT)
<< "ssl_backend_error=" << res.ssl_backend_error();
}
#endif
TEST(SSLClientTest, ServerCertificateVerification1_Online) {
@@ -14335,6 +14569,9 @@ TEST(SSLClientServerTest, ClientCertMissing) {
SSLClient cli(HOST, PORT);
cli.set_connection_timeout(30);
// cert.pem does not match HOST. Skip the hostname check, which runs before
// the chain check on Windows, so the result does not depend on the order.
cli.enable_server_hostname_verification(false);
auto res = cli.Get("/test");
ASSERT_TRUE(!res);
@@ -21982,231 +22219,6 @@ TEST(ETagTest, NegativeFileModificationTime) {
std::remove(fname);
}
//==============================================================================
// SSE Parsing Tests
//==============================================================================
class SSEParsingTest : public ::testing::Test {
protected:
// Test helper that mimics SSE parsing behavior
static bool parse_sse_line(const std::string &line, sse::SSEMessage &msg,
int &retry_ms) {
// Blank line signals end of event
if (line.empty() || line == "\r") { return true; }
// Lines starting with ':' are comments (ignored)
if (!line.empty() && line[0] == ':') { return false; }
// Find the colon separator
auto colon_pos = line.find(':');
if (colon_pos == std::string::npos) {
// Line with no colon is treated as field name with empty value
return false;
}
std::string field = line.substr(0, colon_pos);
std::string value;
// Value starts after colon, skip optional single space
if (colon_pos + 1 < line.size()) {
size_t value_start = colon_pos + 1;
if (line[value_start] == ' ') { value_start++; }
value = line.substr(value_start);
// Remove trailing \r if present
if (!value.empty() && value.back() == '\r') { value.pop_back(); }
}
// Handle known fields
if (field == "event") {
msg.event = value;
} else if (field == "data") {
// Multiple data lines are concatenated with newlines
if (!msg.data.empty()) { msg.data += "\n"; }
msg.data += value;
} else if (field == "id") {
// Empty id is valid (clears the last event ID)
msg.id = value;
} else if (field == "retry") {
// Parse retry interval in milliseconds
{
int v = 0;
auto res =
detail::from_chars(value.data(), value.data() + value.size(), v);
if (res.ec == std::errc{}) { retry_ms = v; }
}
}
// Unknown fields are ignored per SSE spec
return false;
}
};
// Test: Single-line data
TEST_F(SSEParsingTest, SingleLineData) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("data: hello", msg, retry_ms));
EXPECT_EQ(msg.data, "hello");
EXPECT_EQ(msg.event, "message");
// Blank line ends event
EXPECT_TRUE(parse_sse_line("", msg, retry_ms));
}
// Test: Multi-line data
TEST_F(SSEParsingTest, MultiLineData) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("data: line1", msg, retry_ms));
EXPECT_FALSE(parse_sse_line("data: line2", msg, retry_ms));
EXPECT_FALSE(parse_sse_line("data: line3", msg, retry_ms));
EXPECT_EQ(msg.data, "line1\nline2\nline3");
}
// Test: Custom event types
TEST_F(SSEParsingTest, CustomEventType) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("event: update", msg, retry_ms));
EXPECT_FALSE(parse_sse_line("data: payload", msg, retry_ms));
EXPECT_EQ(msg.event, "update");
EXPECT_EQ(msg.data, "payload");
}
// Test: Event ID handling
TEST_F(SSEParsingTest, EventIdHandling) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("id: 12345", msg, retry_ms));
EXPECT_FALSE(parse_sse_line("data: test", msg, retry_ms));
EXPECT_EQ(msg.id, "12345");
}
// Test: Empty event ID (clears last event ID)
TEST_F(SSEParsingTest, EmptyEventId) {
sse::SSEMessage msg;
msg.id = "previous";
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("id:", msg, retry_ms));
EXPECT_EQ(msg.id, "");
}
// Test: Retry field parsing
TEST_F(SSEParsingTest, RetryFieldParsing) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("retry: 5000", msg, retry_ms));
EXPECT_EQ(retry_ms, 5000);
}
// Test: Invalid retry value
TEST_F(SSEParsingTest, InvalidRetryValue) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("retry: invalid", msg, retry_ms));
EXPECT_EQ(retry_ms, 3000); // Unchanged
}
// Test: Comments (lines starting with :)
TEST_F(SSEParsingTest, CommentsIgnored) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line(": this is a comment", msg, retry_ms));
EXPECT_EQ(msg.data, "");
EXPECT_EQ(msg.event, "message");
}
// Test: Colon in value
TEST_F(SSEParsingTest, ColonInValue) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("data: hello:world:test", msg, retry_ms));
EXPECT_EQ(msg.data, "hello:world:test");
}
// Test: Line with no colon (field name only)
TEST_F(SSEParsingTest, FieldNameOnly) {
sse::SSEMessage msg;
int retry_ms = 3000;
// According to SSE spec, this is treated as field name with empty value
EXPECT_FALSE(parse_sse_line("data", msg, retry_ms));
// Since we don't recognize "data" without colon, data should be empty
EXPECT_EQ(msg.data, "");
}
// Test: Trailing \r handling
TEST_F(SSEParsingTest, TrailingCarriageReturn) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("data: hello\r", msg, retry_ms));
EXPECT_EQ(msg.data, "hello");
}
// Test: Unknown fields ignored
TEST_F(SSEParsingTest, UnknownFieldsIgnored) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("unknown: value", msg, retry_ms));
EXPECT_EQ(msg.data, "");
EXPECT_EQ(msg.event, "message");
}
// Test: Space after colon is optional
TEST_F(SSEParsingTest, SpaceAfterColonOptional) {
sse::SSEMessage msg1, msg2;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("data: hello", msg1, retry_ms));
EXPECT_FALSE(parse_sse_line("data:hello", msg2, retry_ms));
EXPECT_EQ(msg1.data, "hello");
EXPECT_EQ(msg2.data, "hello");
}
// Test: SSEMessage clear
TEST_F(SSEParsingTest, MessageClear) {
sse::SSEMessage msg;
msg.event = "custom";
msg.data = "some data";
msg.id = "123";
msg.clear();
EXPECT_EQ(msg.event, "message");
EXPECT_EQ(msg.data, "");
EXPECT_EQ(msg.id, "");
}
// Test: Complete event parsing
TEST_F(SSEParsingTest, CompleteEventParsing) {
sse::SSEMessage msg;
int retry_ms = 3000;
EXPECT_FALSE(parse_sse_line("event: notification", msg, retry_ms));
EXPECT_FALSE(parse_sse_line("id: evt-42", msg, retry_ms));
EXPECT_FALSE(parse_sse_line("data: {\"type\":\"alert\"}", msg, retry_ms));
EXPECT_FALSE(parse_sse_line("retry: 1000", msg, retry_ms));
// Blank line ends event
EXPECT_TRUE(parse_sse_line("", msg, retry_ms));
EXPECT_EQ(msg.event, "notification");
EXPECT_EQ(msg.id, "evt-42");
EXPECT_EQ(msg.data, "{\"type\":\"alert\"}");
EXPECT_EQ(retry_ms, 1000);
}
//==============================================================================
// Integration Tests with Server
//==============================================================================
@@ -22301,6 +22313,189 @@ protected:
int port_ = 0;
};
//==============================================================================
// SSE Parsing Tests
//==============================================================================
class SSEParsingTest : public SSEIntegrationTest {
protected:
// Feeds a raw event stream to SSEClient and returns the dispatched messages.
// A trailing sentinel event tells when the whole stream has been parsed.
std::vector<sse::SSEMessage> parse(const std::string &stream) {
auto body = stream + "event: end\ndata: end\n\n";
server_->Get("/parse", [body](const Request &, Response &res) {
res.set_content(body, "text/event-stream");
});
return collect("/parse");
}
// Runs SSEClient against path until an "end" event arrives and returns the
// messages dispatched before it
std::vector<sse::SSEMessage> collect(const std::string &path) {
Client client(HOST, get_port());
sse::SSEClient sse(client, path);
std::mutex mutex;
std::condition_variable cv;
std::vector<sse::SSEMessage> messages;
auto done = false;
sse.on_message([&](const sse::SSEMessage &msg) {
std::lock_guard<std::mutex> lock(mutex);
// Ignore a replay from a reconnect that races with stop()
if (!done) { messages.push_back(msg); }
});
sse.on_event("end", [&](const sse::SSEMessage &) {
std::lock_guard<std::mutex> lock(mutex);
done = true;
cv.notify_all();
});
sse.set_reconnect_interval(100);
sse.start_async();
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait_for(lock, std::chrono::seconds(5), [&] { return done; });
}
sse.stop();
EXPECT_TRUE(done);
return messages;
}
};
TEST_F(SSEParsingTest, SingleLineData) {
auto msgs = parse("data: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[0].event, "message");
}
TEST_F(SSEParsingTest, MultiLineData) {
auto msgs = parse("data: line1\ndata: line2\ndata: line3\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "line1\nline2\nline3");
}
TEST_F(SSEParsingTest, CustomEventType) {
auto msgs = parse("event: update\ndata: payload\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "payload");
}
TEST_F(SSEParsingTest, EventIdHandling) {
auto msgs = parse("id: 12345\ndata: test\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].id, "12345");
}
TEST_F(SSEParsingTest, EmptyEventId) {
auto msgs = parse("id:\ndata: test\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].id, "");
}
TEST_F(SSEParsingTest, CommentsIgnored) {
auto msgs = parse(": this is a comment\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[0].event, "message");
}
TEST_F(SSEParsingTest, ColonInValue) {
auto msgs = parse("data: hello:world:test\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello:world:test");
}
TEST_F(SSEParsingTest, FieldNameOnly) {
// A line without a colon is a field name with an empty value
auto msgs = parse("data\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "");
}
TEST_F(SSEParsingTest, TrailingCarriageReturn) {
auto msgs = parse("event: update\r\ndata: hello\r\n\r\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, FieldNameOnlyWithCarriageReturn) {
auto msgs = parse("data\r\n\r\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "");
}
TEST_F(SSEParsingTest, UnknownFieldsIgnored) {
auto msgs = parse("unknown: value\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[0].event, "message");
}
TEST_F(SSEParsingTest, SpaceAfterColonOptional) {
auto msgs = parse("data: hello\n\ndata:hello\n\n");
ASSERT_EQ(msgs.size(), 2u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[1].data, "hello");
}
TEST_F(SSEParsingTest, EventWithoutDataResetsEventType) {
// An event without data is not dispatched, and its type must not leak
// into the next event
auto msgs = parse("event: update\n\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "message");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, EventWithoutDataUpdatesLastEventId) {
// The first connection sends only an id; the second echoes back the
// Last-Event-ID it was reconnected with
std::atomic<int> connection_count{0};
server_->Get("/id-only", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content("id: 42\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-only");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "42");
}
TEST_F(SSEParsingTest, CompleteEventParsing) {
auto msgs = parse("event: notification\nid: evt-42\n"
"data: {\"type\":\"alert\"}\nretry: 1000\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "notification");
EXPECT_EQ(msgs[0].id, "evt-42");
EXPECT_EQ(msgs[0].data, "{\"type\":\"alert\"}");
}
TEST(SSEMessageTest, Clear) {
sse::SSEMessage msg;
msg.event = "custom";
msg.data = "some data";
msg.id = "123";
msg.clear();
EXPECT_EQ(msg.event, "message");
EXPECT_EQ(msg.data, "");
EXPECT_EQ(msg.id, "");
}
//==============================================================================
// SSE Integration Tests
//==============================================================================
// Test: Successful connection and on_open callback
TEST_F(SSEIntegrationTest, SuccessfulConnection) {
// Add a simple endpoint that sends one event and closes
@@ -22616,6 +22811,45 @@ TEST_F(SSEIntegrationTest, MultiLineDataIntegration) {
EXPECT_EQ(received_data, "line1\nline2\nline3");
}
TEST_F(SSEIntegrationTest, EmptyDataLines) {
server_->Get("/empty-data-lines", [](const Request &, Response &res) {
res.set_chunked_content_provider("text/event-stream", [](size_t offset,
DataSink &sink) {
if (offset == 0) {
const std::string events = "data:\n\ndata:\ndata: hello\n\ndata\n\n";
sink.write(events.data(), events.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/empty-data-lines");
std::mutex mutex;
std::condition_variable cv;
std::vector<std::string> received;
sse.on_message([&](const sse::SSEMessage &msg) {
std::lock_guard<std::mutex> lock(mutex);
received.push_back(msg.data);
cv.notify_all();
});
sse.set_max_reconnect_attempts(1);
sse.start_async();
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait_for(lock, std::chrono::seconds(2),
[&] { return received.size() >= 3; });
}
sse.stop();
ASSERT_GE(received.size(), 3u);
EXPECT_EQ(received[0], "");
EXPECT_EQ(received[1], "\nhello");
EXPECT_EQ(received[2], "");
}
// Test: Auto-reconnect after server disconnection
TEST_F(SSEIntegrationTest, AutoReconnectAfterDisconnect) {
std::atomic<int> connection_count{0};