Bladeren bron

Fix #2339 (#2344)

* Fix #2339

* Fix CI errors

* Fix Windows build error

* Fix CI errors on Windows

* Fix payload_max_length initialization in BodyReader

* Initialize payload_max_length with CPPHTTPLIB_PAYLOAD_MAX_LENGTH in BodyReader

* Update README and tests to clarify payload_max_length behavior and add no limit case

* Fix server thread lambda capture in ClientVulnerabilityTest
yhirose 1 dag geleden
bovenliggende
commit
4639b696ab
3 gewijzigde bestanden met toevoegingen van 463 en 46 verwijderingen
  1. 11 0
      README.md
  2. 89 44
      httplib.h
  3. 363 2
      test/test.cc

+ 11 - 0
README.md

@@ -958,6 +958,12 @@ cli.set_write_timeout(5, 0); // 5 seconds
 cli.set_max_timeout(5000); // 5 seconds
 ```
 
+### Set maximum payload length for reading a response body
+
+```c++
+cli.set_payload_max_length(1024 * 1024 * 512); // 512MB
+```
+
 ### Receive content with a content receiver
 
 ```c++
@@ -1158,6 +1164,11 @@ httplib::Server svr;
 svr.listen("127.0.0.1", 8080);
 ```
 
+Payload Limit
+-------------
+
+The maximum payload body size is limited to 100MB by default for both server and client. You can change it with `set_payload_max_length()` or by defining `CPPHTTPLIB_PAYLOAD_MAX_LENGTH` at compile time. Setting it to `0` disables the limit entirely.
+
 Compression
 -----------
 

+ 89 - 44
httplib.h

@@ -147,7 +147,7 @@
 #endif
 
 #ifndef CPPHTTPLIB_PAYLOAD_MAX_LENGTH
-#define CPPHTTPLIB_PAYLOAD_MAX_LENGTH ((std::numeric_limits<size_t>::max)())
+#define CPPHTTPLIB_PAYLOAD_MAX_LENGTH (100 * 1024 * 1024) // 100MB
 #endif
 
 #ifndef CPPHTTPLIB_FORM_URL_ENCODED_PAYLOAD_MAX_LENGTH
@@ -1622,7 +1622,9 @@ struct ChunkedDecoder;
 
 struct BodyReader {
   Stream *stream = nullptr;
+  bool has_content_length = false;
   size_t content_length = 0;
+  size_t payload_max_length = CPPHTTPLIB_PAYLOAD_MAX_LENGTH;
   size_t bytes_read = 0;
   bool chunked = false;
   bool eof = false;
@@ -1692,6 +1694,7 @@ public:
     std::unique_ptr<detail::decompressor> decompressor_;
     std::string decompress_buffer_;
     size_t decompress_offset_ = 0;
+    size_t decompressed_bytes_read_ = 0;
   };
 
   // clang-format off
@@ -1848,6 +1851,8 @@ public:
 
   void set_decompress(bool on);
 
+  void set_payload_max_length(size_t length);
+
   void set_interface(const std::string &intf);
 
   void set_proxy(const std::string &host, int port);
@@ -1950,6 +1955,8 @@ protected:
   bool compress_ = false;
   bool decompress_ = true;
 
+  size_t payload_max_length_ = CPPHTTPLIB_PAYLOAD_MAX_LENGTH;
+
   std::string interface_;
 
   std::string proxy_host_;
@@ -2225,6 +2232,8 @@ public:
 
   void set_decompress(bool on);
 
+  void set_payload_max_length(size_t length);
+
   void set_interface(const std::string &intf);
 
   void set_proxy(const std::string &host, int port);
@@ -5960,14 +5969,23 @@ inline bool read_headers(Stream &strm, Headers &headers) {
   return true;
 }
 
-inline bool read_content_with_length(Stream &strm, size_t len,
-                                     DownloadProgress progress,
-                                     ContentReceiverWithProgress out) {
+enum class ReadContentResult {
+  Success,         // Successfully read the content
+  PayloadTooLarge, // The content exceeds the specified payload limit
+  Error            // An error occurred while reading the content
+};
+
+inline ReadContentResult read_content_with_length(
+    Stream &strm, size_t len, DownloadProgress progress,
+    ContentReceiverWithProgress out,
+    size_t payload_max_length = (std::numeric_limits<size_t>::max)()) {
   char buf[CPPHTTPLIB_RECV_BUFSIZ];
 
   detail::BodyReader br;
   br.stream = &strm;
+  br.has_content_length = true;
   br.content_length = len;
+  br.payload_max_length = payload_max_length;
   br.chunked = false;
   br.bytes_read = 0;
   br.last_error = Error::Success;
@@ -5977,36 +5995,27 @@ inline bool read_content_with_length(Stream &strm, size_t len,
     auto read_len = static_cast<size_t>(len - r);
     auto to_read = (std::min)(read_len, CPPHTTPLIB_RECV_BUFSIZ);
     auto n = detail::read_body_content(&strm, br, buf, to_read);
-    if (n <= 0) { return false; }
+    if (n <= 0) {
+      // Check if it was a payload size error
+      if (br.last_error == Error::ExceedMaxPayloadSize) {
+        return ReadContentResult::PayloadTooLarge;
+      }
+      return ReadContentResult::Error;
+    }
 
-    if (!out(buf, static_cast<size_t>(n), r, len)) { return false; }
+    if (!out(buf, static_cast<size_t>(n), r, len)) {
+      return ReadContentResult::Error;
+    }
     r += static_cast<size_t>(n);
 
     if (progress) {
-      if (!progress(r, len)) { return false; }
+      if (!progress(r, len)) { return ReadContentResult::Error; }
     }
   }
 
-  return true;
-}
-
-inline void skip_content_with_length(Stream &strm, size_t len) {
-  char buf[CPPHTTPLIB_RECV_BUFSIZ];
-  size_t r = 0;
-  while (r < len) {
-    auto read_len = static_cast<size_t>(len - r);
-    auto n = strm.read(buf, (std::min)(read_len, CPPHTTPLIB_RECV_BUFSIZ));
-    if (n <= 0) { return; }
-    r += static_cast<size_t>(n);
-  }
+  return ReadContentResult::Success;
 }
 
-enum class ReadContentResult {
-  Success,         // Successfully read the content
-  PayloadTooLarge, // The content exceeds the specified payload limit
-  Error            // An error occurred while reading the content
-};
-
 inline ReadContentResult
 read_content_without_length(Stream &strm, size_t payload_max_length,
                             ContentReceiverWithProgress out) {
@@ -6152,12 +6161,13 @@ bool read_content(Stream &strm, T &x, size_t payload_max_length, int &status,
 
           if (is_invalid_value) {
             ret = false;
-          } else if (len > payload_max_length) {
-            exceed_payload_max_length = true;
-            skip_content_with_length(strm, len);
-            ret = false;
           } else if (len > 0) {
-            ret = read_content_with_length(strm, len, std::move(progress), out);
+            auto result = read_content_with_length(
+                strm, len, std::move(progress), out, payload_max_length);
+            ret = (result == ReadContentResult::Success);
+            if (result == ReadContentResult::PayloadTooLarge) {
+              exceed_payload_max_length = true;
+            }
           }
         }
 
@@ -8478,13 +8488,16 @@ inline ssize_t detail::BodyReader::read(char *buf, size_t len) {
 
   if (!chunked) {
     // Content-Length based reading
-    if (bytes_read >= content_length) {
+    if (has_content_length && bytes_read >= content_length) {
       eof = true;
       return 0;
     }
 
-    auto remaining = content_length - bytes_read;
-    auto to_read = (std::min)(len, remaining);
+    auto to_read = len;
+    if (has_content_length) {
+      auto remaining = content_length - bytes_read;
+      to_read = (std::min)(len, remaining);
+    }
     auto n = stream->read(buf, to_read);
 
     if (n < 0) {
@@ -8502,7 +8515,12 @@ inline ssize_t detail::BodyReader::read(char *buf, size_t len) {
     }
 
     bytes_read += static_cast<size_t>(n);
-    if (bytes_read >= content_length) { eof = true; }
+    if (has_content_length && bytes_read >= content_length) { eof = true; }
+    if (payload_max_length > 0 && bytes_read > payload_max_length) {
+      last_error = Error::ExceedMaxPayloadSize;
+      eof = true;
+      return -1;
+    }
     return n;
   }
 
@@ -8526,6 +8544,11 @@ inline ssize_t detail::BodyReader::read(char *buf, size_t len) {
   }
 
   bytes_read += static_cast<size_t>(n);
+  if (payload_max_length > 0 && bytes_read > payload_max_length) {
+    last_error = Error::ExceedMaxPayloadSize;
+    eof = true;
+    return -1;
+  }
   return n;
 }
 
@@ -9682,7 +9705,7 @@ inline bool Server::read_content_core(
   // oversized request and fail early (causing connection close). For SSL
   // builds we cannot reliably peek the decrypted application bytes, so keep
   // the original behaviour.
-#if !defined(CPPHTTPLIB_TLS_ENABLED)
+#if !defined(CPPHTTPLIB_SSL_ENABLED)
   if (!req.has_header("Content-Length") &&
       !detail::is_chunked_transfer_encoding(req.headers)) {
     // Only peek if payload_max_length is set to a finite value
@@ -10572,6 +10595,7 @@ inline void ClientImpl::copy_settings(const ClientImpl &rhs) {
   socket_options_ = rhs.socket_options_;
   compress_ = rhs.compress_;
   decompress_ = rhs.decompress_;
+  payload_max_length_ = rhs.payload_max_length_;
   interface_ = rhs.interface_;
   proxy_host_ = rhs.proxy_host_;
   proxy_port_ = rhs.proxy_port_;
@@ -10999,9 +11023,11 @@ ClientImpl::open_stream(const std::string &method, const std::string &path,
   }
 
   handle.body_reader_.stream = handle.stream_;
+  handle.body_reader_.payload_max_length = payload_max_length_;
 
   auto content_length_str = handle.response->get_header_value("Content-Length");
   if (!content_length_str.empty()) {
+    handle.body_reader_.has_content_length = true;
     handle.body_reader_.content_length =
         static_cast<size_t>(std::stoull(content_length_str));
   }
@@ -11049,6 +11075,7 @@ inline ssize_t ClientImpl::StreamHandle::read_with_decompression(char *buf,
     auto to_copy = (std::min)(len, available);
     std::memcpy(buf, decompress_buffer_.data() + decompress_offset_, to_copy);
     decompress_offset_ += to_copy;
+    decompressed_bytes_read_ += to_copy;
     return static_cast<ssize_t>(to_copy);
   }
 
@@ -11064,12 +11091,16 @@ inline ssize_t ClientImpl::StreamHandle::read_with_decompression(char *buf,
 
     if (n <= 0) { return n; }
 
-    bool decompress_ok =
-        decompressor_->decompress(compressed_buf, static_cast<size_t>(n),
-                                  [this](const char *data, size_t data_len) {
-                                    decompress_buffer_.append(data, data_len);
-                                    return true;
-                                  });
+    bool decompress_ok = decompressor_->decompress(
+        compressed_buf, static_cast<size_t>(n),
+        [this](const char *data, size_t data_len) {
+          decompress_buffer_.append(data, data_len);
+          auto limit = body_reader_.payload_max_length;
+          if (decompressed_bytes_read_ + decompress_buffer_.size() > limit) {
+            return false;
+          }
+          return true;
+        });
 
     if (!decompress_ok) {
       body_reader_.last_error = Error::Read;
@@ -11082,6 +11113,7 @@ inline ssize_t ClientImpl::StreamHandle::read_with_decompression(char *buf,
   auto to_copy = (std::min)(len, decompress_buffer_.size());
   std::memcpy(buf, decompress_buffer_.data(), to_copy);
   decompress_offset_ = to_copy;
+  decompressed_bytes_read_ += to_copy;
   return static_cast<ssize_t>(to_copy);
 }
 
@@ -11920,6 +11952,11 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
                   [&](const char *buf, size_t n, size_t /*off*/,
                       size_t /*len*/) {
                     assert(res.body.size() + n <= res.body.max_size());
+                    if (payload_max_length_ > 0 &&
+                        (res.body.size() >= payload_max_length_ ||
+                         n > payload_max_length_ - res.body.size())) {
+                      return false;
+                    }
                     res.body.append(buf, n);
                     return true;
                   });
@@ -11948,9 +11985,9 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
 
     if (res.status != StatusCode::NotModified_304) {
       int dummy_status;
-      if (!detail::read_content(strm, res, (std::numeric_limits<size_t>::max)(),
-                                dummy_status, std::move(progress),
-                                std::move(out), decompress_)) {
+      if (!detail::read_content(strm, res, payload_max_length_, dummy_status,
+                                std::move(progress), std::move(out),
+                                decompress_)) {
         if (error != Error::Canceled) { error = Error::Read; }
         output_error_log(error, &req);
         return false;
@@ -12897,6 +12934,10 @@ inline void ClientImpl::set_compress(bool on) { compress_ = on; }
 
 inline void ClientImpl::set_decompress(bool on) { decompress_ = on; }
 
+inline void ClientImpl::set_payload_max_length(size_t length) {
+  payload_max_length_ = length;
+}
+
 inline void ClientImpl::set_interface(const std::string &intf) {
   interface_ = intf;
 }
@@ -13640,6 +13681,10 @@ inline void Client::set_compress(bool on) { cli_->set_compress(on); }
 
 inline void Client::set_decompress(bool on) { cli_->set_decompress(on); }
 
+inline void Client::set_payload_max_length(size_t length) {
+  cli_->set_payload_max_length(length);
+}
+
 inline void Client::set_interface(const std::string &intf) {
   cli_->set_interface(intf);
 }

+ 363 - 2
test/test.cc

@@ -1683,6 +1683,7 @@ TEST(CancelTest, WithCancelSmallPayloadPost) {
 
 TEST(CancelTest, WithCancelLargePayloadPost) {
   Server svr;
+  svr.set_payload_max_length(200 * 1024 * 1024);
 
   svr.Post("/", [&](const Request & /*req*/, Response &res) {
     res.set_content(LARGE_DATA, "text/plain");
@@ -1698,6 +1699,7 @@ TEST(CancelTest, WithCancelLargePayloadPost) {
   svr.wait_until_ready();
 
   Client cli(HOST, PORT);
+  cli.set_payload_max_length(200 * 1024 * 1024);
   cli.set_connection_timeout(std::chrono::seconds(5));
 
   auto res =
@@ -1762,6 +1764,7 @@ TEST(CancelTest, WithCancelSmallPayloadPut) {
 
 TEST(CancelTest, WithCancelLargePayloadPut) {
   Server svr;
+  svr.set_payload_max_length(200 * 1024 * 1024);
 
   svr.Put("/", [&](const Request & /*req*/, Response &res) {
     res.set_content(LARGE_DATA, "text/plain");
@@ -1777,6 +1780,7 @@ TEST(CancelTest, WithCancelLargePayloadPut) {
   svr.wait_until_ready();
 
   Client cli(HOST, PORT);
+  cli.set_payload_max_length(200 * 1024 * 1024);
   cli.set_connection_timeout(std::chrono::seconds(5));
 
   auto res =
@@ -1841,6 +1845,7 @@ TEST(CancelTest, WithCancelSmallPayloadPatch) {
 
 TEST(CancelTest, WithCancelLargePayloadPatch) {
   Server svr;
+  svr.set_payload_max_length(200 * 1024 * 1024);
 
   svr.Patch("/", [&](const Request & /*req*/, Response &res) {
     res.set_content(LARGE_DATA, "text/plain");
@@ -1856,6 +1861,7 @@ TEST(CancelTest, WithCancelLargePayloadPatch) {
   svr.wait_until_ready();
 
   Client cli(HOST, PORT);
+  cli.set_payload_max_length(200 * 1024 * 1024);
   cli.set_connection_timeout(std::chrono::seconds(5));
 
   auto res =
@@ -1920,6 +1926,7 @@ TEST(CancelTest, WithCancelSmallPayloadDelete) {
 
 TEST(CancelTest, WithCancelLargePayloadDelete) {
   Server svr;
+  svr.set_payload_max_length(200 * 1024 * 1024);
 
   svr.Delete("/", [&](const Request & /*req*/, Response &res) {
     res.set_content(LARGE_DATA, "text/plain");
@@ -1935,6 +1942,7 @@ TEST(CancelTest, WithCancelLargePayloadDelete) {
   svr.wait_until_ready();
 
   Client cli(HOST, PORT);
+  cli.set_payload_max_length(200 * 1024 * 1024);
   cli.set_connection_timeout(std::chrono::seconds(5));
 
   auto res =
@@ -3083,9 +3091,14 @@ protected:
 #ifdef CPPHTTPLIB_SSL_ENABLED
     cli_.enable_server_certificate_verification(false);
 #endif
+    // Allow LARGE_DATA (100MB) responses
+    cli_.set_payload_max_length(200 * 1024 * 1024);
   }
 
   virtual void SetUp() {
+    // Allow LARGE_DATA (100MB) tests to pass with new 100MB default limit
+    svr_.set_payload_max_length(200 * 1024 * 1024);
+
     svr_.set_mount_point("/", "./www");
     svr_.set_mount_point("/mount", "./www2");
     svr_.set_file_extension_and_mimetype_mapping("abcde", "text/abcde");
@@ -8447,8 +8460,12 @@ TEST_F(LargePayloadMaxLengthTest, ChunkedEncodingExceeds10MB) {
                             'B'); // 12MB payload, exceeds 10MB limit
 
   auto res = cli_.Post("/test", large_payload, "application/octet-stream");
-  ASSERT_TRUE(res);
-  EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
+  // Server may either return 413 or close the connection
+  if (res) {
+    EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
+  } else {
+    SUCCEED() << "Server closed connection for payload exceeding 10MB limit";
+  }
 }
 
 TEST_F(LargePayloadMaxLengthTest, NoContentLengthWithin10MB) {
@@ -8516,6 +8533,348 @@ TEST_F(LargePayloadMaxLengthTest, NoContentLengthExceeds10MB) {
   }
 }
 
+// Regression test for DoS vulnerability: a malicious server sending a response
+// without Content-Length header must not cause unbounded memory consumption on
+// the client side. The client should stop reading after a reasonable limit,
+// similar to the server-side set_payload_max_length protection.
+TEST(ClientVulnerabilityTest, UnboundedReadWithoutContentLength) {
+  constexpr size_t CLIENT_READ_LIMIT = 2 * 1024 * 1024; // 2MB safety limit
+
+#ifndef _WIN32
+  signal(SIGPIPE, SIG_IGN);
+#endif
+
+  auto server_thread = std::thread([] {
+    constexpr size_t MALICIOUS_DATA_SIZE = 10 * 1024 * 1024; // 10MB from server
+    auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
+    default_socket_options(srv);
+    detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
+    detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
+
+    sockaddr_in addr{};
+    addr.sin_family = AF_INET;
+    addr.sin_port = htons(PORT + 2);
+    ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
+
+    int opt = 1;
+    ::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
+#ifdef _WIN32
+                 reinterpret_cast<const char *>(&opt),
+#else
+                 &opt,
+#endif
+                 sizeof(opt));
+
+    ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
+    ::listen(srv, 1);
+
+    sockaddr_in cli_addr{};
+    socklen_t cli_len = sizeof(cli_addr);
+    auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
+
+    if (cli != INVALID_SOCKET) {
+      char buf[4096];
+      ::recv(cli, buf, sizeof(buf), 0);
+
+      // Malicious response: no Content-Length, no chunked encoding
+      std::string response_header = "HTTP/1.1 200 OK\r\n"
+                                    "Connection: close\r\n"
+                                    "\r\n";
+
+      ::send(cli,
+#ifdef _WIN32
+             static_cast<const char *>(response_header.c_str()),
+             static_cast<int>(response_header.size()),
+#else
+             response_header.c_str(), response_header.size(),
+#endif
+             0);
+
+      // Send 10MB of data
+      std::string chunk(64 * 1024, 'A');
+      size_t total_sent = 0;
+
+      while (total_sent < MALICIOUS_DATA_SIZE) {
+        auto to_send = std::min(chunk.size(), MALICIOUS_DATA_SIZE - total_sent);
+        auto sent = ::send(cli,
+#ifdef _WIN32
+                           static_cast<const char *>(chunk.c_str()),
+                           static_cast<int>(to_send),
+#else
+                           chunk.c_str(), to_send,
+#endif
+                           0);
+        if (sent <= 0) break;
+        total_sent += static_cast<size_t>(sent);
+      }
+
+      detail::close_socket(cli);
+    }
+    detail::close_socket(srv);
+  });
+
+  std::this_thread::sleep_for(std::chrono::milliseconds(200));
+
+  size_t total_read = 0;
+
+  {
+    Client cli("127.0.0.1", PORT + 2);
+    cli.set_read_timeout(5, 0);
+    cli.set_payload_max_length(CLIENT_READ_LIMIT);
+
+    auto stream = cli.open_stream("GET", "/malicious");
+    ASSERT_TRUE(stream.is_valid());
+
+    char buffer[64 * 1024];
+    ssize_t n;
+
+    while ((n = stream.read(buffer, sizeof(buffer))) > 0) {
+      total_read += static_cast<size_t>(n);
+    }
+  } // StreamHandle and Client destroyed here, closing the socket
+
+  server_thread.join();
+
+  // With set_payload_max_length, the client must stop reading before consuming
+  // all 10MB. The read loop should be cut off at or near the configured limit.
+  EXPECT_LE(total_read, CLIENT_READ_LIMIT)
+      << "Client read " << total_read << " bytes, exceeding the configured "
+      << "payload_max_length of " << CLIENT_READ_LIMIT << " bytes.";
+}
+
+// Verify that set_payload_max_length(0) means "no limit" and allows reading
+// the entire response body without truncation.
+TEST(ClientVulnerabilityTest, PayloadMaxLengthZeroMeansNoLimit) {
+  constexpr size_t DATA_SIZE = 4 * 1024 * 1024; // 4MB from server
+
+#ifndef _WIN32
+  signal(SIGPIPE, SIG_IGN);
+#endif
+
+  auto server_thread = std::thread([DATA_SIZE] {
+    auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
+    default_socket_options(srv);
+    detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
+    detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
+
+    sockaddr_in addr{};
+    addr.sin_family = AF_INET;
+    addr.sin_port = htons(PORT + 2);
+    ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
+
+    int opt = 1;
+    ::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
+#ifdef _WIN32
+                 reinterpret_cast<const char *>(&opt),
+#else
+                 &opt,
+#endif
+                 sizeof(opt));
+
+    ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
+    ::listen(srv, 1);
+
+    sockaddr_in cli_addr{};
+    socklen_t cli_len = sizeof(cli_addr);
+    auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
+
+    if (cli != INVALID_SOCKET) {
+      char buf[4096];
+      ::recv(cli, buf, sizeof(buf), 0);
+
+      std::string response_header = "HTTP/1.1 200 OK\r\n"
+                                    "Connection: close\r\n"
+                                    "\r\n";
+
+      ::send(cli,
+#ifdef _WIN32
+             static_cast<const char *>(response_header.c_str()),
+             static_cast<int>(response_header.size()),
+#else
+             response_header.c_str(), response_header.size(),
+#endif
+             0);
+
+      std::string chunk(64 * 1024, 'A');
+      size_t total_sent = 0;
+
+      while (total_sent < DATA_SIZE) {
+        auto to_send = std::min(chunk.size(), DATA_SIZE - total_sent);
+        auto sent = ::send(cli,
+#ifdef _WIN32
+                           static_cast<const char *>(chunk.c_str()),
+                           static_cast<int>(to_send),
+#else
+                           chunk.c_str(), to_send,
+#endif
+                           0);
+        if (sent <= 0) break;
+        total_sent += static_cast<size_t>(sent);
+      }
+
+      detail::close_socket(cli);
+    }
+    detail::close_socket(srv);
+  });
+
+  std::this_thread::sleep_for(std::chrono::milliseconds(200));
+
+  size_t total_read = 0;
+
+  {
+    Client cli("127.0.0.1", PORT + 2);
+    cli.set_read_timeout(5, 0);
+    cli.set_payload_max_length(0); // 0 means no limit
+
+    auto stream = cli.open_stream("GET", "/data");
+    ASSERT_TRUE(stream.is_valid());
+
+    char buffer[64 * 1024];
+    ssize_t n;
+
+    while ((n = stream.read(buffer, sizeof(buffer))) > 0) {
+      total_read += static_cast<size_t>(n);
+    }
+  }
+
+  server_thread.join();
+
+  EXPECT_EQ(total_read, DATA_SIZE)
+      << "With payload_max_length(0), the client should read all " << DATA_SIZE
+      << " bytes without truncation, but only read " << total_read << " bytes.";
+}
+
+#if defined(CPPHTTPLIB_ZLIB_SUPPORT) && !defined(_WIN32)
+// Regression test for "zip bomb" attack on the client side: a malicious server
+// sends a small gzip-compressed response that decompresses to a huge payload.
+// The client must enforce payload_max_length on the decompressed size.
+TEST(ClientVulnerabilityTest, ZipBombWithoutContentLength) {
+  constexpr size_t DECOMPRESSED_SIZE =
+      10 * 1024 * 1024; // 10MB after decompression
+  constexpr size_t CLIENT_READ_LIMIT = 2 * 1024 * 1024; // 2MB safety limit
+
+  // Prepare gzip-compressed data: 10MB of zeros compresses to a few KB
+  std::string uncompressed(DECOMPRESSED_SIZE, '\0');
+  std::string compressed;
+  {
+    httplib::detail::gzip_compressor compressor;
+    bool ok =
+        compressor.compress(uncompressed.data(), uncompressed.size(),
+                            /*last=*/true, [&](const char *buf, size_t len) {
+                              compressed.append(buf, len);
+                              return true;
+                            });
+    ASSERT_TRUE(ok);
+  }
+  // Sanity: compressed data should be much smaller than the decompressed size
+  ASSERT_LT(compressed.size(), DECOMPRESSED_SIZE / 10);
+
+#ifndef _WIN32
+  signal(SIGPIPE, SIG_IGN);
+#endif
+
+  auto server_thread = std::thread([&compressed] {
+    auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
+    default_socket_options(srv);
+    detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
+    detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
+
+    sockaddr_in addr{};
+    addr.sin_family = AF_INET;
+    addr.sin_port = htons(PORT + 3);
+    ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
+
+    int opt = 1;
+    ::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
+#ifdef _WIN32
+                 reinterpret_cast<const char *>(&opt),
+#else
+                 &opt,
+#endif
+                 sizeof(opt));
+
+    ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
+    ::listen(srv, 1);
+
+    sockaddr_in cli_addr{};
+    socklen_t cli_len = sizeof(cli_addr);
+    auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
+
+    if (cli != INVALID_SOCKET) {
+      char buf[4096];
+      ::recv(cli, buf, sizeof(buf), 0);
+
+      // Malicious response: gzip-compressed body, no Content-Length
+      std::string response_header = "HTTP/1.1 200 OK\r\n"
+                                    "Content-Encoding: gzip\r\n"
+                                    "Connection: close\r\n"
+                                    "\r\n";
+
+      ::send(cli,
+#ifdef _WIN32
+             static_cast<const char *>(response_header.c_str()),
+             static_cast<int>(response_header.size()),
+#else
+             response_header.c_str(), response_header.size(),
+#endif
+             0);
+
+      // Send the compressed payload (small on the wire, huge when decompressed)
+      size_t total_sent = 0;
+      while (total_sent < compressed.size()) {
+        auto to_send = std::min(compressed.size() - total_sent,
+                                static_cast<size_t>(64 * 1024));
+        auto sent =
+            ::send(cli,
+#ifdef _WIN32
+                   static_cast<const char *>(compressed.c_str() + total_sent),
+                   static_cast<int>(to_send),
+#else
+                   compressed.c_str() + total_sent, to_send,
+#endif
+                   0);
+        if (sent <= 0) break;
+        total_sent += static_cast<size_t>(sent);
+      }
+
+      detail::close_socket(cli);
+    }
+    detail::close_socket(srv);
+  });
+
+  std::this_thread::sleep_for(std::chrono::milliseconds(200));
+
+  size_t total_decompressed = 0;
+
+  {
+    Client cli("127.0.0.1", PORT + 3);
+    cli.set_read_timeout(5, 0);
+    cli.set_decompress(true);
+    cli.set_payload_max_length(CLIENT_READ_LIMIT);
+
+    auto stream = cli.open_stream("GET", "/zipbomb");
+    ASSERT_TRUE(stream.is_valid());
+
+    char buffer[64 * 1024];
+    ssize_t n;
+
+    while ((n = stream.read(buffer, sizeof(buffer))) > 0) {
+      total_decompressed += static_cast<size_t>(n);
+    }
+  }
+
+  server_thread.join();
+
+  // The decompressed size must be capped by payload_max_length. Without
+  // protection, the client would decompress the full 10MB from a tiny
+  // compressed payload, enabling a zip bomb DoS attack.
+  EXPECT_LE(total_decompressed, CLIENT_READ_LIMIT)
+      << "Client decompressed " << total_decompressed
+      << " bytes from a gzip response. The decompressed size should be "
+      << "limited by set_payload_max_length to prevent zip bomb attacks.";
+}
+#endif
+
 TEST(HostAndPortPropertiesTest, NoSSL) {
   httplib::Client cli("www.google.com", 1234);
   ASSERT_EQ("www.google.com", cli.host());
@@ -12204,6 +12563,7 @@ TEST(ForwardedHeadersTest, HandlesWhitespaceAroundIPs) {
   EXPECT_EQ(observed_remote_addr, "203.0.113.66");
 }
 
+#ifndef _WIN32
 TEST(ServerRequestParsingTest, RequestWithoutContentLengthOrTransferEncoding) {
   Server svr;
 
@@ -12273,6 +12633,7 @@ TEST(ServerRequestParsingTest, RequestWithoutContentLengthOrTransferEncoding) {
                            &resp));
   EXPECT_TRUE(resp.find("HTTP/1.1 200 OK") == 0);
 }
+#endif
 
 //==============================================================================
 // open_stream() Tests