9 Commits 639391ad7f ... cf3693cb5c

Auteur SHA1 Bericht Datum
  yhirose cf3693cb5c Release v0.59.0 5 dagen geleden
  yhirose 4fd9ae8f42 Serve pipelined requests without waiting for the keep-alive timeout 5 dagen geleden
  yhirose 3d40dfc727 Fix SSE parsing of CRLF field names and data-less events 5 dagen geleden
  metsw24-max dd71728110 escape quoted-string auth-params in make_digest_authentication_header (#2597) 5 dagen geleden
  DosX 7255a7e979 Preserve empty SSE data fields (#2594) 5 dagen geleden
  yhirose 8a3abfb597 Extract parse_int_in_range from parse_port 5 dagen geleden
  KBS 10aadd57f7 Reject trailing characters in URL port numbers (#2593) 5 dagen geleden
  yhirose 43863e1f67 Make CryptoAPI the only chain verifier when Windows verification is on (#2604) 5 dagen geleden
  yhirose 0db1df7cf2 Pass the server's intermediates to Windows certificate verification (#2602) 5 dagen geleden
4 gewijzigde bestanden met toevoegingen van 716 en 317 verwijderingen
  1. 1 1
      README.md
  2. 1 1
      docs-src/config.toml
  3. 246 90
      httplib.h
  4. 468 225
      test/test.cc

+ 1 - 1
README.md

@@ -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
docs-src/config.toml

@@ -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."

+ 246 - 90
httplib.h

@@ -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,11 +10758,10 @@ 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).
-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) {
+// 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) {
   if (der_cert.empty()) { return false; }
 
   out_error = 0;
@@ -10719,14 +10779,41 @@ verify_cert_with_windows_schannel(const std::vector<unsigned char> &der_cert,
   auto cert_guard =
       scope_exit([&] { CertFreeCertificateContext(cert_context); });
 
+  // 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([&] {
+    for (auto cert : peer_certs) {
+      tls::free_cert(cert);
+    }
+    if (store) { CertCloseStore(store, 0); }
+  });
+  for (auto cert : peer_certs) {
+    std::vector<unsigned char> der;
+    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);
+    }
+  }
+
   // 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(
-      nullptr, cert_context, nullptr, cert_context->hCertStore, &chain_para,
+      nullptr, cert_context, nullptr, store, &chain_para,
       CERT_CHAIN_CACHE_END_CERT | CERT_CHAIN_REVOCATION_CHECK_END_CERT |
           CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT,
       nullptr, &chain_context);
@@ -10834,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
 };
 
@@ -10868,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
 
   {
@@ -10918,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);
@@ -10941,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)) {
-          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
@@ -12420,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));
@@ -12439,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));
@@ -14369,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;
@@ -18493,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);
 }
 
@@ -18554,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;
@@ -19523,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)); }
 }
@@ -20867,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
@@ -22024,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)); }
 }

+ 468 - 225
test/test.cc

@@ -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");
@@ -13958,6 +14166,41 @@ TEST(SSLClientTest, WindowsCertificateVerification_Disabled) {
   auto res = cli.Get("/");
   if (res) { EXPECT_NE(StatusCode::InternalServerError_500, res->status); }
 }
+
+// The server sends an intermediate cross-signed by a root Windows trusts,
+// while the leaf's AIA URL leads to one under a root Windows does not trust.
+TEST(SSLClientTest, WindowsCertificateVerification_ServerIntermediates_Online) {
+  SSLClient cli("accounts.spotify.com", 443);
+  auto res = cli.Get("/");
+  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) {
@@ -14326,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);
@@ -21973,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
 //==============================================================================
@@ -22292,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
@@ -22607,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};