1
0

5 Коммитууд a77284a634 ... 4fa68af2e3

Эзэн SHA1 Мессеж Огноо
  yhirose 4fa68af2e3 Optimize multipart content provider to coalesce small writes and reduce TCP packet fragmentation (Fix #2410) 4 өдөр өмнө
  yhirose 6532464de6 Add set_socket_opt function and corresponding test for TCP_NODELAY option (Resolve #2411) 4 өдөр өмнө
  yhirose 6fd97aeca0 Implement request body consumption and reject invalid Content-Length with Transfer-Encoding to prevent request smuggling 4 өдөр өмнө
  yhirose 05540e4d50 Fixed warnings 5 өдөр өмнө
  yhirose ceefc14e7d Use go-httplibbin 5 өдөр өмнө
3 өөрчлөгдсөн 434 нэмэгдсэн , 161 устгасан
  1. 106 41
      httplib.h
  2. 301 92
      test/test.cc
  3. 27 28
      test/test_proxy.cc

+ 106 - 41
httplib.h

@@ -1270,6 +1270,7 @@ struct Request {
   bool is_multipart_form_data() const;
 
   // private members...
+  bool body_consumed_ = false;
   size_t redirect_count_ = CPPHTTPLIB_REDIRECT_MAX_COUNT;
   size_t content_length_ = 0;
   ContentProvider content_provider_;
@@ -1479,6 +1480,8 @@ using SocketOptions = std::function<void(socket_t sock)>;
 
 void default_socket_options(socket_t sock);
 
+bool set_socket_opt(socket_t sock, int level, int optname, int optval);
+
 const char *status_message(int status);
 
 std::string to_string(Error error);
@@ -4337,10 +4340,6 @@ inline bool set_socket_opt_impl(socket_t sock, int level, int optname,
                     optlen) == 0;
 }
 
-inline bool set_socket_opt(socket_t sock, int level, int optname, int optval) {
-  return set_socket_opt_impl(sock, level, optname, &optval, sizeof(optval));
-}
-
 inline bool set_socket_opt_time(socket_t sock, int level, int optname,
                                 time_t sec, time_t usec) {
 #ifdef _WIN32
@@ -6088,7 +6087,7 @@ socket_t create_socket(const std::string &host, const std::string &ip, int port,
 #ifdef _WIN32
       // Setting SO_REUSEADDR seems not to work well with AF_UNIX on windows, so
       // remove the option.
-      detail::set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 0);
+      set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 0);
 #endif
 
       bool dummy;
@@ -8251,19 +8250,49 @@ make_multipart_content_provider(const UploadFormDataItems &items,
   state->segs = std::move(segs);
 
   return [state](size_t offset, size_t length, DataSink &sink) -> bool {
+    // Coalesce multiple small segments into fewer, larger writes to avoid
+    // excessive TCP packets when there are many form data items (#2410)
+    constexpr size_t kBufSize = 65536;
+    char buf[kBufSize];
+    size_t buf_len = 0;
+    size_t remaining = length;
+
+    // Find the first segment containing 'offset'
     size_t pos = 0;
-    for (const auto &seg : state->segs) {
-      // Loop invariant: pos <= offset (proven by advancing pos only when
-      // offset - pos >= seg.size, i.e., the segment doesn't contain offset)
-      if (seg.size > 0 && offset - pos < seg.size) {
-        size_t seg_offset = offset - pos;
-        size_t available = seg.size - seg_offset;
-        size_t to_write = (std::min)(available, length);
-        return sink.write(seg.data + seg_offset, to_write);
-      }
+    size_t seg_idx = 0;
+    for (; seg_idx < state->segs.size(); seg_idx++) {
+      const auto &seg = state->segs[seg_idx];
+      if (seg.size > 0 && offset - pos < seg.size) { break; }
       pos += seg.size;
     }
-    return true; // past end (shouldn't be reached when content_length is exact)
+
+    size_t seg_offset = (seg_idx < state->segs.size()) ? offset - pos : 0;
+
+    for (; seg_idx < state->segs.size() && remaining > 0; seg_idx++) {
+      const auto &seg = state->segs[seg_idx];
+      size_t available = seg.size - seg_offset;
+      size_t to_copy = (std::min)(available, remaining);
+      const char *src = seg.data + seg_offset;
+      seg_offset = 0; // only the first segment has a non-zero offset
+
+      while (to_copy > 0) {
+        size_t space = kBufSize - buf_len;
+        size_t chunk = (std::min)(to_copy, space);
+        std::memcpy(buf + buf_len, src, chunk);
+        buf_len += chunk;
+        src += chunk;
+        to_copy -= chunk;
+        remaining -= chunk;
+
+        if (buf_len == kBufSize) {
+          if (!sink.write(buf, buf_len)) { return false; }
+          buf_len = 0;
+        }
+      }
+    }
+
+    if (buf_len > 0) { return sink.write(buf, buf_len); }
+    return true;
   };
 }
 
@@ -9134,13 +9163,18 @@ inline bool setup_client_tls_session(const std::string &host, tls::ctx_t &ctx,
  */
 
 inline void default_socket_options(socket_t sock) {
-  detail::set_socket_opt(sock, SOL_SOCKET,
+  set_socket_opt(sock, SOL_SOCKET,
 #ifdef SO_REUSEPORT
-                         SO_REUSEPORT,
+                 SO_REUSEPORT,
 #else
-                         SO_REUSEADDR,
+                 SO_REUSEADDR,
 #endif
-                         1);
+                 1);
+}
+
+inline bool set_socket_opt(socket_t sock, int level, int optname, int optval) {
+  return detail::set_socket_opt_impl(sock, level, optname, &optval,
+                                     sizeof(optval));
 }
 
 inline std::string get_bearer_token_auth(const Request &req) {
@@ -11288,6 +11322,8 @@ inline bool Server::read_content_core(
     return false;
   }
 
+  req.body_consumed_ = true;
+
   if (req.is_multipart_form_data()) {
     if (!multipart_form_data_parser.is_valid()) {
       res.status = StatusCode::BadRequest_400;
@@ -11558,9 +11594,7 @@ inline bool Server::listen_internal() {
       detail::set_socket_opt_time(sock, SOL_SOCKET, SO_SNDTIMEO,
                                   write_timeout_sec_, write_timeout_usec_);
 
-      if (tcp_nodelay_) {
-        detail::set_socket_opt(sock, IPPROTO_TCP, TCP_NODELAY, 1);
-      }
+      if (tcp_nodelay_) { set_socket_opt(sock, IPPROTO_TCP, TCP_NODELAY, 1); }
 
       if (!task_queue->enqueue(
               [this, sock]() { process_and_close_socket(sock); })) {
@@ -11906,8 +11940,19 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
     return write_response(strm, close_connection, req, res);
   }
 
+  // RFC 9112 §6.3: Reject requests with both a non-zero Content-Length and
+  // any Transfer-Encoding to prevent request smuggling. Content-Length: 0 is
+  // tolerated for compatibility with existing clients.
+  if (req.get_header_value_u64("Content-Length") > 0 &&
+      req.has_header("Transfer-Encoding")) {
+    connection_closed = true;
+    res.status = StatusCode::BadRequest_400;
+    return write_response(strm, close_connection, req, res);
+  }
+
   // Check if the request URI doesn't exceed the limit
   if (req.target.size() > CPPHTTPLIB_REQUEST_URI_MAX_LENGTH) {
+    connection_closed = true;
     res.status = StatusCode::UriTooLong_414;
     output_error_log(Error::ExceedUriMaxLength, &req);
     return write_response(strm, close_connection, req, res);
@@ -11936,6 +11981,7 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
   if (req.has_header("Accept")) {
     const auto &accept_header = req.get_header_value("Accept");
     if (!detail::parse_accept_header(accept_header, req.accept_content_types)) {
+      connection_closed = true;
       res.status = StatusCode::BadRequest_400;
       output_error_log(Error::HTTPParsing, &req);
       return write_response(strm, close_connection, req, res);
@@ -11945,6 +11991,7 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
   if (req.has_header("Range")) {
     const auto &range_header_value = req.get_header_value("Range");
     if (!detail::parse_range_header(range_header_value, req.ranges)) {
+      connection_closed = true;
       res.status = StatusCode::RangeNotSatisfiable_416;
       output_error_log(Error::InvalidRangeHeader, &req);
       return write_response(strm, close_connection, req, res);
@@ -12072,6 +12119,7 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
     }
   }
 #endif
+  auto ret = false;
   if (routed) {
     if (res.status == -1) {
       res.status = req.ranges.empty() ? StatusCode::OK_200
@@ -12079,6 +12127,7 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
     }
 
     // Serve file content by using a content provider
+    auto file_open_error = false;
     if (!res.file_content_path_.empty()) {
       const auto &path = res.file_content_path_;
       auto mm = std::make_shared<detail::mmap>(path.c_str());
@@ -12088,37 +12137,53 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
         res.content_provider_ = nullptr;
         res.status = StatusCode::NotFound_404;
         output_error_log(Error::OpenFile, &req);
-        return write_response(strm, close_connection, req, res);
-      }
+        file_open_error = true;
+      } else {
+        auto content_type = res.file_content_content_type_;
+        if (content_type.empty()) {
+          content_type = detail::find_content_type(
+              path, file_extension_and_mimetype_map_, default_file_mimetype_);
+        }
 
-      auto content_type = res.file_content_content_type_;
-      if (content_type.empty()) {
-        content_type = detail::find_content_type(
-            path, file_extension_and_mimetype_map_, default_file_mimetype_);
+        res.set_content_provider(
+            mm->size(), content_type,
+            [mm](size_t offset, size_t length, DataSink &sink) -> bool {
+              sink.write(mm->data() + offset, length);
+              return true;
+            });
       }
-
-      res.set_content_provider(
-          mm->size(), content_type,
-          [mm](size_t offset, size_t length, DataSink &sink) -> bool {
-            sink.write(mm->data() + offset, length);
-            return true;
-          });
     }
 
-    if (detail::range_error(req, res)) {
+    if (file_open_error) {
+      ret = write_response(strm, close_connection, req, res);
+    } else if (detail::range_error(req, res)) {
       res.body.clear();
       res.content_length_ = 0;
       res.content_provider_ = nullptr;
       res.status = StatusCode::RangeNotSatisfiable_416;
-      return write_response(strm, close_connection, req, res);
+      ret = write_response(strm, close_connection, req, res);
+    } else {
+      ret = write_response_with_content(strm, close_connection, req, res);
     }
-
-    return write_response_with_content(strm, close_connection, req, res);
   } else {
     if (res.status == -1) { res.status = StatusCode::NotFound_404; }
-
-    return write_response(strm, close_connection, req, res);
+    ret = write_response(strm, close_connection, req, res);
+  }
+
+  // Drain any unconsumed request body to prevent request smuggling on
+  // keep-alive connections.
+  if (!req.body_consumed_ && detail::expect_content(req)) {
+    int drain_status = 200; // required by read_content signature
+    if (!detail::read_content(
+            strm, req, payload_max_length_, drain_status, nullptr,
+            [](const char *, size_t, size_t, size_t) { return true; }, false)) {
+      // Body exceeds payload limit or read error — close the connection
+      // to prevent leftover bytes from being misinterpreted.
+      connection_closed = true;
+    }
   }
+
+  return ret;
 }
 
 inline bool Server::is_valid() const { return true; }

+ 301 - 92
test/test.cc

@@ -274,7 +274,7 @@ TEST(SocketStream, wait_writable_INET) {
   sockaddr_in addr;
   memset(&addr, 0, sizeof(addr));
   addr.sin_family = AF_INET;
-  addr.sin_port = htons(PORT + 1);
+  addr.sin_port = htons(static_cast<uint16_t>(PORT + 1));
   addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
 
   int disconnected_svr_sock = -1;
@@ -316,6 +316,19 @@ TEST(SocketStream, wait_writable_INET) {
 }
 #endif // #ifndef _WIN32
 
+TEST(SetSocketOptTest, TcpNoDelay) {
+  auto sock = ::socket(AF_INET, SOCK_STREAM, 0);
+  ASSERT_NE(sock, INVALID_SOCKET);
+  EXPECT_TRUE(set_socket_opt(sock, IPPROTO_TCP, TCP_NODELAY, 1));
+
+  int val = 0;
+  socklen_t len = sizeof(val);
+  ASSERT_EQ(0, ::getsockopt(sock, IPPROTO_TCP, TCP_NODELAY,
+                            reinterpret_cast<char *>(&val), &len));
+  EXPECT_NE(val, 0);
+  detail::close_socket(sock);
+}
+
 TEST(ClientTest, MoveConstructible) {
   EXPECT_FALSE(std::is_copy_constructible<Client>::value);
   EXPECT_TRUE(std::is_nothrow_move_constructible<Client>::value);
@@ -1449,13 +1462,8 @@ TEST_F(ChunkedEncodingTest, WithResponseHandlerAndContentReceiver) {
 }
 
 TEST(RangeTest, FromHTTPBin_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
+  auto host = "httpbingo.org";
   auto path = std::string{"/range/32"};
-#else
-  auto host = "nghttp2.org";
-  auto path = std::string{"/httpbin/range/32"};
-#endif
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   auto port = 443;
@@ -1489,12 +1497,16 @@ TEST(RangeTest, FromHTTPBin_Online) {
     EXPECT_EQ(StatusCode::PartialContent_206, res->status);
   }
 
+  // go-httpbin (httpbingo.org) returns 206 even when the range covers the
+  // entire resource, while the original httpbin returned 200. Both are
+  // acceptable per RFC 9110 §15.3.7, so we accept either status code.
   {
     Headers headers = {make_range_header({{0, 31}})};
     auto res = cli.Get(path, headers);
     ASSERT_TRUE(res);
     EXPECT_EQ("abcdefghijklmnopqrstuvwxyzabcdef", res->body);
-    EXPECT_EQ(StatusCode::OK_200, res->status);
+    EXPECT_TRUE(res->status == StatusCode::OK_200 ||
+                res->status == StatusCode::PartialContent_206);
   }
 
   {
@@ -1502,14 +1514,17 @@ TEST(RangeTest, FromHTTPBin_Online) {
     auto res = cli.Get(path, headers);
     ASSERT_TRUE(res);
     EXPECT_EQ("abcdefghijklmnopqrstuvwxyzabcdef", res->body);
-    EXPECT_EQ(StatusCode::OK_200, res->status);
+    EXPECT_TRUE(res->status == StatusCode::OK_200 ||
+                res->status == StatusCode::PartialContent_206);
   }
 
+  // go-httpbin returns 206 with clamped range for over-range requests,
+  // while the original httpbin returned 416. Both behaviors are observed
+  // in real servers, so we only verify the request succeeds.
   {
     Headers headers = {make_range_header({{0, 32}})};
     auto res = cli.Get(path, headers);
     ASSERT_TRUE(res);
-    EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
   }
 }
 
@@ -1623,13 +1638,8 @@ TEST(ConnectionErrorTest, Timeout_Online) {
 }
 
 TEST(CancelTest, NoCancel_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
+  auto host = "httpbingo.org";
   auto path = std::string{"/range/32"};
-#else
-  auto host = "nghttp2.org";
-  auto path = std::string{"/httpbin/range/32"};
-#endif
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   auto port = 443;
@@ -1647,13 +1657,11 @@ TEST(CancelTest, NoCancel_Online) {
 }
 
 TEST(CancelTest, WithCancelSmallPayload_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
-  auto path = std::string{"/range/32"};
-#else
-  auto host = "nghttp2.org";
-  auto path = std::string{"/httpbin/range/32"};
-#endif
+  // Use /bytes with a large payload so that the DownloadProgress callback
+  // (which only fires for Content-Length responses) is invoked before the
+  // entire body is received, giving cancellation a chance to fire.
+  auto host = "httpbingo.org";
+  auto path = std::string{"/bytes/524288"};
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   auto port = 443;
@@ -1670,13 +1678,8 @@ TEST(CancelTest, WithCancelSmallPayload_Online) {
 }
 
 TEST(CancelTest, WithCancelLargePayload_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
-  auto path = std::string{"/range/65536"};
-#else
-  auto host = "nghttp2.org";
-  auto path = std::string{"/httpbin/range/65536"};
-#endif
+  auto host = "httpbingo.org";
+  auto path = std::string{"/bytes/524288"};
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   auto port = 443;
@@ -2027,13 +2030,8 @@ static std::string remove_whitespace(const std::string &input) {
 }
 
 TEST(BaseAuthTest, FromHTTPWatch_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
+  auto host = "httpbingo.org";
   auto path = std::string{"/basic-auth/hello/world"};
-#else
-  auto host = "nghttp2.org";
-  auto path = std::string{"/httpbin/basic-auth/hello/world"};
-#endif
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   auto port = 443;
@@ -2053,8 +2051,9 @@ TEST(BaseAuthTest, FromHTTPWatch_Online) {
     auto res =
         cli.Get(path, {make_basic_authentication_header("hello", "world")});
     ASSERT_TRUE(res);
-    EXPECT_EQ("{\"authenticated\":true,\"user\":\"hello\"}",
-              remove_whitespace(res->body));
+    auto body = remove_whitespace(res->body);
+    EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
+    EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
     EXPECT_EQ(StatusCode::OK_200, res->status);
   }
 
@@ -2062,8 +2061,9 @@ TEST(BaseAuthTest, FromHTTPWatch_Online) {
     cli.set_basic_auth("hello", "world");
     auto res = cli.Get(path);
     ASSERT_TRUE(res);
-    EXPECT_EQ("{\"authenticated\":true,\"user\":\"hello\"}",
-              remove_whitespace(res->body));
+    auto body = remove_whitespace(res->body);
+    EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
+    EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
     EXPECT_EQ(StatusCode::OK_200, res->status);
   }
 
@@ -2084,24 +2084,12 @@ TEST(BaseAuthTest, FromHTTPWatch_Online) {
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
 TEST(DigestAuthTest, FromHTTPWatch_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
+  auto host = "httpbingo.org";
   auto unauth_path = std::string{"/digest-auth/auth/hello/world"};
   auto paths = std::vector<std::string>{
       "/digest-auth/auth/hello/world/MD5",
       "/digest-auth/auth/hello/world/SHA-256",
-      "/digest-auth/auth/hello/world/SHA-512",
-  };
-#else
-  auto host = "nghttp2.org";
-  auto unauth_path = std::string{"/httpbin/digest-auth/auth/hello/world"};
-  auto paths = std::vector<std::string>{
-      "/httpbin/digest-auth/auth/hello/world/MD5",
-      "/httpbin/digest-auth/auth/hello/world/SHA-256",
-      "/httpbin/digest-auth/auth/hello/world/SHA-512",
-      "/httpbin/digest-auth/auth-int/hello/world/MD5",
   };
-#endif
 
   auto port = 443;
   SSLClient cli(host, port);
@@ -2118,27 +2106,18 @@ TEST(DigestAuthTest, FromHTTPWatch_Online) {
     for (const auto &path : paths) {
       auto res = cli.Get(path.c_str());
       ASSERT_TRUE(res);
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-      std::string algo(path.substr(path.rfind('/') + 1));
-      EXPECT_EQ(
-          remove_whitespace("{\"algorithm\":\"" + algo +
-                            "\",\"authenticated\":true,\"user\":\"hello\"}\n"),
-          remove_whitespace(res->body));
-#else
-      EXPECT_EQ("{\"authenticated\":true,\"user\":\"hello\"}",
-                remove_whitespace(res->body));
-#endif
+      auto body = remove_whitespace(res->body);
+      EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
+      EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
       EXPECT_EQ(StatusCode::OK_200, res->status);
     }
 
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
     cli.set_digest_auth("hello", "bad");
     for (const auto &path : paths) {
       auto res = cli.Get(path.c_str());
       ASSERT_TRUE(res);
       EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
     }
-#endif
   }
 }
 
@@ -2178,7 +2157,8 @@ TEST(SpecifyServerIPAddressTest, RealHostname_Online) {
 }
 
 TEST(AbsoluteRedirectTest, Redirect_Online) {
-  auto host = "nghttp2.org";
+  auto host = "httpbingo.org";
+  auto path = std::string{"/absolute-redirect/3"};
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   SSLClient cli(host);
@@ -2187,13 +2167,14 @@ TEST(AbsoluteRedirectTest, Redirect_Online) {
 #endif
 
   cli.set_follow_location(true);
-  auto res = cli.Get("/httpbin/absolute-redirect/3");
+  auto res = cli.Get(path);
   ASSERT_TRUE(res);
   EXPECT_EQ(StatusCode::OK_200, res->status);
 }
 
 TEST(RedirectTest, Redirect_Online) {
-  auto host = "nghttp2.org";
+  auto host = "httpbingo.org";
+  auto path = std::string{"/redirect/3"};
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   SSLClient cli(host);
@@ -2202,13 +2183,14 @@ TEST(RedirectTest, Redirect_Online) {
 #endif
 
   cli.set_follow_location(true);
-  auto res = cli.Get("/httpbin/redirect/3");
+  auto res = cli.Get(path);
   ASSERT_TRUE(res);
   EXPECT_EQ(StatusCode::OK_200, res->status);
 }
 
 TEST(RelativeRedirectTest, Redirect_Online) {
-  auto host = "nghttp2.org";
+  auto host = "httpbingo.org";
+  auto path = std::string{"/relative-redirect/3"};
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   SSLClient cli(host);
@@ -2217,13 +2199,14 @@ TEST(RelativeRedirectTest, Redirect_Online) {
 #endif
 
   cli.set_follow_location(true);
-  auto res = cli.Get("/httpbin/relative-redirect/3");
+  auto res = cli.Get(path);
   ASSERT_TRUE(res);
   EXPECT_EQ(StatusCode::OK_200, res->status);
 }
 
 TEST(TooManyRedirectTest, Redirect_Online) {
-  auto host = "nghttp2.org";
+  auto host = "httpbingo.org";
+  auto path = std::string{"/redirect/21"};
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   SSLClient cli(host);
@@ -2232,7 +2215,7 @@ TEST(TooManyRedirectTest, Redirect_Online) {
 #endif
 
   cli.set_follow_location(true);
-  auto res = cli.Get("/httpbin/redirect/21");
+  auto res = cli.Get(path);
   ASSERT_TRUE(!res);
   EXPECT_EQ(Error::ExceedRedirectCount, res.error());
 }
@@ -8372,13 +8355,8 @@ TEST(GetWithParametersTest, GetWithParameters2) {
 }
 
 TEST(ClientDefaultHeadersTest, DefaultHeaders_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
+  auto host = "httpbingo.org";
   auto path = std::string{"/range/32"};
-#else
-  auto host = "nghttp2.org";
-  auto path = std::string{"/httpbin/range/32"};
-#endif
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
   SSLClient cli(host);
@@ -8939,7 +8917,7 @@ TEST(ClientVulnerabilityTest, UnboundedReadWithoutContentLength) {
 
     sockaddr_in addr{};
     addr.sin_family = AF_INET;
-    addr.sin_port = htons(PORT + 2);
+    addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
     ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
 
     int opt = 1;
@@ -9045,7 +9023,7 @@ TEST(ClientVulnerabilityTest, PayloadMaxLengthZeroMeansNoLimit) {
 
     sockaddr_in addr{};
     addr.sin_family = AF_INET;
-    addr.sin_port = htons(PORT + 2);
+    addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
     ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
 
     int opt = 1;
@@ -9156,7 +9134,7 @@ TEST(ClientVulnerabilityTest, ContentReceiverBypassesDefaultPayloadMaxLength) {
 
     sockaddr_in addr{};
     addr.sin_family = AF_INET;
-    addr.sin_port = htons(PORT + 2);
+    addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
     ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
 
     int opt = 1;
@@ -9265,7 +9243,7 @@ TEST(ClientVulnerabilityTest,
 
     sockaddr_in addr{};
     addr.sin_family = AF_INET;
-    addr.sin_port = htons(PORT + 2);
+    addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
     ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
 
     int opt = 1;
@@ -9373,7 +9351,7 @@ TEST(ClientVulnerabilityTest,
 
     sockaddr_in addr{};
     addr.sin_family = AF_INET;
-    addr.sin_port = htons(PORT + 2);
+    addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
     ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
 
     int opt = 1;
@@ -9509,7 +9487,7 @@ TEST(ClientVulnerabilityTest, ZipBombWithoutContentLength) {
 
   sockaddr_in addr{};
   addr.sin_family = AF_INET;
-  addr.sin_port = htons(PORT + 3);
+  addr.sin_port = htons(static_cast<uint16_t>(PORT + 3));
   ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
 
   int opt = 1;
@@ -9669,13 +9647,8 @@ TEST(SSLClientTest, UpdateCAStoreWithPem_Online) {
 }
 
 TEST(SSLClientTest, ServerNameIndication_Online) {
-#ifdef CPPHTTPLIB_DEFAULT_HTTPBIN
-  auto host = "httpcan.org";
+  auto host = "httpbingo.org";
   auto path = std::string{"/get"};
-#else
-  auto host = "nghttp2.org";
-  auto path = std::string{"/httpbin/get"};
-#endif
 
   SSLClient cli(host, 443);
   auto res = cli.Get(path);
@@ -12062,6 +12035,99 @@ TEST(MultipartFormDataTest, UploadItemsHasContentLength) {
   EXPECT_EQ(StatusCode::OK_200, res->status);
 }
 
+TEST(MultipartFormDataTest, ContentProviderCoalescesWrites) {
+  // Verify that make_multipart_content_provider coalesces many small segments
+  // into fewer sink.write() calls to avoid TCP packet fragmentation (#2410).
+  constexpr size_t kItemCount = 1000;
+
+  UploadFormDataItems items;
+  items.reserve(kItemCount);
+  for (size_t i = 0; i < kItemCount; i++) {
+    items.push_back(
+        {"field" + std::to_string(i), "value" + std::to_string(i), "", ""});
+  }
+
+  const auto boundary = detail::make_multipart_data_boundary();
+  auto content_length = detail::get_multipart_content_length(items, boundary);
+  auto provider = detail::make_multipart_content_provider(items, boundary);
+
+  // Drive the provider the same way write_content_with_progress does
+  size_t write_count = 0;
+  std::string collected;
+  collected.reserve(content_length);
+
+  DataSink sink;
+  size_t offset = 0;
+  sink.write = [&](const char *d, size_t l) -> bool {
+    write_count++;
+    collected.append(d, l);
+    offset += l;
+    return true;
+  };
+  sink.is_writable = []() -> bool { return true; };
+
+  while (offset < content_length) {
+    ASSERT_TRUE(provider(offset, content_length - offset, sink));
+  }
+
+  // The total number of segments is 3 * kItemCount + 1 = 3001.
+  // With coalescing into 64KB buffers, write_count should be much smaller.
+  auto segment_count = 3 * kItemCount + 1;
+  EXPECT_LT(write_count, segment_count / 10);
+
+  // Verify the collected body matches the single-string serialization
+  auto expected = detail::serialize_multipart_formdata(items, boundary);
+  EXPECT_EQ(expected, collected);
+}
+
+TEST(MultipartFormDataTest, ManyItemsEndToEnd) {
+  // Integration test: send many UploadFormDataItems and verify the server
+  // receives all of them correctly (#2410).
+  constexpr size_t kItemCount = 500;
+
+  auto handled = false;
+
+  Server svr;
+  svr.Post("/upload", [&](const Request &req, Response &res) {
+    EXPECT_EQ(kItemCount, req.form.fields.size());
+    for (size_t i = 0; i < kItemCount; i++) {
+      auto key = "field" + std::to_string(i);
+      auto val = "value" + std::to_string(i);
+      auto it = req.form.fields.find(key);
+      if (it != req.form.fields.end()) {
+        EXPECT_EQ(val, it->second.content);
+      } else {
+        ADD_FAILURE() << "Missing field: " << key;
+      }
+    }
+    res.set_content("ok", "text/plain");
+    handled = true;
+  });
+
+  auto port = svr.bind_to_any_port(HOST);
+  auto t = thread([&] { svr.listen_after_bind(); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+    ASSERT_FALSE(svr.is_running());
+    ASSERT_TRUE(handled);
+  });
+
+  svr.wait_until_ready();
+
+  UploadFormDataItems items;
+  items.reserve(kItemCount);
+  for (size_t i = 0; i < kItemCount; i++) {
+    items.push_back(
+        {"field" + std::to_string(i), "value" + std::to_string(i), "", ""});
+  }
+
+  Client cli(HOST, port);
+  auto res = cli.Post("/upload", items);
+  ASSERT_TRUE(res);
+  EXPECT_EQ(StatusCode::OK_200, res->status);
+}
+
 TEST(MultipartFormDataTest, MakeFileProvider) {
   // Verify make_file_provider sends a file's contents correctly.
   const std::string file_content(4096, 'Z');
@@ -12400,7 +12466,7 @@ TEST(VulnerabilityTest, CRLFInjectionInHeaders) {
 
     sockaddr_in addr{};
     addr.sin_family = AF_INET;
-    addr.sin_port = htons(PORT + 1);
+    addr.sin_port = htons(static_cast<uint16_t>(PORT + 1));
     ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
     ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
     ::listen(srv, 1);
@@ -17306,3 +17372,146 @@ TEST(SymlinkTest, SymlinkEscapeFromBaseDirectory) {
   EXPECT_EQ(StatusCode::Forbidden_403, res->status);
 }
 #endif
+
+TEST(RequestSmugglingTest, UnconsumedGETBodyOnFileHandler) {
+  // A GET request with Content-Length to a static file handler must have its
+  // body drained before the keep-alive connection is reused. Otherwise the
+  // unread body bytes are interpreted as the next HTTP request.
+  //
+  // The body is sent AFTER receiving the first response (as in the original
+  // PoC) so that the stream_line_reader cannot buffer it together with the
+  // headers of the first request.
+  Server svr;
+  svr.set_mount_point("/", "./www");
+
+  std::atomic<int> smuggled_count(0);
+  svr.Get("/smuggled", [&](const Request &, Response &res) {
+    smuggled_count++;
+    res.set_content("oops", "text/plain");
+  });
+
+  auto port = svr.bind_to_any_port("localhost");
+  thread t = thread([&] { svr.listen_after_bind(); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+  });
+  svr.wait_until_ready();
+
+  auto error = Error::Success;
+  auto sock = detail::create_client_socket(
+      "localhost", "", port, AF_UNSPEC, false, false, nullptr,
+      /*connection_timeout_sec=*/2, 0,
+      /*read_timeout_sec=*/2, 0,
+      /*write_timeout_sec=*/2, 0, std::string(), error);
+  ASSERT_NE(INVALID_SOCKET, sock);
+  auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
+
+  // The "smuggled" request will be sent as the body of the outer GET
+  std::string smuggled = "GET /smuggled HTTP/1.1\r\n"
+                         "Host: localhost\r\n"
+                         "Connection: close\r\n"
+                         "\r\n";
+
+  // Step 1: Send only the outer request headers (no body yet)
+  std::string outer_headers = "GET /file HTTP/1.1\r\n"
+                              "Host: localhost\r\n"
+                              "Content-Length: " +
+                              std::to_string(smuggled.size()) +
+                              "\r\n"
+                              "\r\n";
+
+  auto sent =
+      send(sock, outer_headers.data(), outer_headers.size(), MSG_NOSIGNAL);
+  ASSERT_EQ(static_cast<ssize_t>(outer_headers.size()), sent);
+
+  // Step 2: Read the first response (server serves file without reading body)
+  std::string first_response;
+  char buf[4096];
+  for (;;) {
+    auto n = recv(sock, buf, sizeof(buf), 0);
+    if (n <= 0) break;
+    first_response.append(buf, static_cast<size_t>(n));
+    // Stop once we have a complete response (headers + body)
+    auto hdr_end = first_response.find("\r\n\r\n");
+    if (hdr_end != std::string::npos) {
+      // Check for Content-Length to know when the body is complete
+      auto cl_pos = first_response.find("Content-Length:");
+      if (cl_pos != std::string::npos) {
+        auto cl_val_start = cl_pos + 15; // length of "Content-Length:"
+        auto cl_val_end = first_response.find("\r\n", cl_val_start);
+        auto cl = std::stoul(
+            first_response.substr(cl_val_start, cl_val_end - cl_val_start));
+        if (first_response.size() >= hdr_end + 4 + cl) { break; }
+      } else {
+        break; // No Content-Length, assume headers-only response
+      }
+    }
+  }
+  ASSERT_TRUE(first_response.find("HTTP/1.1 200") != std::string::npos);
+
+  // Step 3: Now send the body, which looks like a new HTTP request.
+  // On a vulnerable server the keep-alive loop reads this as a second request.
+  sent = send(sock, smuggled.data(), smuggled.size(), MSG_NOSIGNAL);
+  ASSERT_EQ(static_cast<ssize_t>(smuggled.size()), sent);
+
+  // Step 4: Try to read a second response (should NOT exist after fix)
+  std::string second_response;
+  for (;;) {
+    auto n = recv(sock, buf, sizeof(buf), 0);
+    if (n <= 0) break;
+    second_response.append(buf, static_cast<size_t>(n));
+  }
+
+  // The smuggled request must NOT have been processed
+  EXPECT_EQ(0, smuggled_count.load());
+}
+
+TEST(RequestSmugglingTest, ContentLengthAndTransferEncodingRejected) {
+  // RFC 9112 §6.3: A request with both Content-Length and Transfer-Encoding
+  // must be rejected with 400 Bad Request.
+  Server svr;
+  svr.Post("/test", [&](const Request &, Response &res) {
+    res.set_content("ok", "text/plain");
+  });
+
+  thread t = thread([&] { svr.listen(HOST, PORT); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+    ASSERT_FALSE(svr.is_running());
+  });
+  svr.wait_until_ready();
+
+  // Exact "chunked"
+  {
+    auto req = "POST /test HTTP/1.1\r\n"
+               "Host: localhost\r\n"
+               "Content-Length: 5\r\n"
+               "Transfer-Encoding: chunked\r\n"
+               "Connection: close\r\n"
+               "\r\n"
+               "hello";
+
+    std::string response;
+    ASSERT_TRUE(send_request(1, req, &response));
+    EXPECT_EQ("HTTP/1.1 400 Bad Request",
+              response.substr(0, response.find("\r\n")));
+  }
+
+  // Multi-valued Transfer-Encoding (e.g., "gzip, chunked")
+  {
+    auto req = "POST /test HTTP/1.1\r\n"
+               "Host: localhost\r\n"
+               "Content-Length: 5\r\n"
+               "Transfer-Encoding: gzip, chunked\r\n"
+               "Connection: close\r\n"
+               "\r\n"
+               "hello";
+
+    std::string response;
+    ASSERT_TRUE(send_request(1, req, &response));
+    EXPECT_EQ("HTTP/1.1 400 Bad Request",
+              response.substr(0, response.find("\r\n")));
+  }
+}

+ 27 - 28
test/test_proxy.cc

@@ -16,29 +16,29 @@ std::string normalizeJson(const std::string &json) {
 
 template <typename T> void ProxyTest(T &cli, bool basic) {
   cli.set_proxy("localhost", basic ? 3128 : 3129);
-  auto res = cli.Get("/httpbin/get");
+  auto res = cli.Get("/get");
   ASSERT_TRUE(res != nullptr);
   EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status);
 }
 
 TEST(ProxyTest, NoSSLBasic) {
-  Client cli("nghttp2.org");
+  Client cli("httpbingo.org");
   ProxyTest(cli, true);
 }
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
 TEST(ProxyTest, SSLBasic) {
-  SSLClient cli("nghttp2.org");
+  SSLClient cli("httpbingo.org");
   ProxyTest(cli, true);
 }
 
 TEST(ProxyTest, NoSSLDigest) {
-  Client cli("nghttp2.org");
+  Client cli("httpbingo.org");
   ProxyTest(cli, false);
 }
 
 TEST(ProxyTest, SSLDigest) {
-  SSLClient cli("nghttp2.org");
+  SSLClient cli("httpbingo.org");
   ProxyTest(cli, false);
 }
 #endif
@@ -63,24 +63,24 @@ void RedirectProxyText(T &cli, const char *path, bool basic) {
 }
 
 TEST(RedirectTest, HTTPBinNoSSLBasic) {
-  Client cli("nghttp2.org");
-  RedirectProxyText(cli, "/httpbin/redirect/2", true);
+  Client cli("httpbingo.org");
+  RedirectProxyText(cli, "/redirect/2", true);
 }
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
 TEST(RedirectTest, HTTPBinNoSSLDigest) {
-  Client cli("nghttp2.org");
-  RedirectProxyText(cli, "/httpbin/redirect/2", false);
+  Client cli("httpbingo.org");
+  RedirectProxyText(cli, "/redirect/2", false);
 }
 
 TEST(RedirectTest, HTTPBinSSLBasic) {
-  SSLClient cli("nghttp2.org");
-  RedirectProxyText(cli, "/httpbin/redirect/2", true);
+  SSLClient cli("httpbingo.org");
+  RedirectProxyText(cli, "/redirect/2", true);
 }
 
 TEST(RedirectTest, HTTPBinSSLDigest) {
-  SSLClient cli("nghttp2.org");
-  RedirectProxyText(cli, "/httpbin/redirect/2", false);
+  SSLClient cli("httpbingo.org");
+  RedirectProxyText(cli, "/redirect/2", false);
 }
 #endif
 
@@ -290,26 +290,25 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
 #endif
 
   {
-    auto res = cli.Get("/httpbin/get");
+    auto res = cli.Get("/get");
     EXPECT_EQ(StatusCode::OK_200, res->status);
   }
   {
-    auto res = cli.Get("/httpbin/redirect/2");
+    auto res = cli.Get("/redirect/2");
     EXPECT_EQ(StatusCode::OK_200, res->status);
   }
 
   {
     std::vector<std::string> paths = {
-        "/httpbin/digest-auth/auth/hello/world/MD5",
-        "/httpbin/digest-auth/auth/hello/world/SHA-256",
-        "/httpbin/digest-auth/auth/hello/world/SHA-512",
-        "/httpbin/digest-auth/auth-int/hello/world/MD5",
+        "/digest-auth/auth/hello/world/MD5",
+        "/digest-auth/auth/hello/world/SHA-256",
     };
 
     for (auto path : paths) {
       auto res = cli.Get(path.c_str());
-      EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\"}\n"),
-                normalizeJson(res->body));
+      auto body = normalizeJson(res->body);
+      EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
+      EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
       EXPECT_EQ(StatusCode::OK_200, res->status);
     }
   }
@@ -317,7 +316,7 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
   {
     int count = 10;
     while (count--) {
-      auto res = cli.Get("/httpbin/get");
+      auto res = cli.Get("/get");
       EXPECT_EQ(StatusCode::OK_200, res->status);
     }
   }
@@ -325,22 +324,22 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
 TEST(KeepAliveTest, NoSSLWithBasic) {
-  Client cli("nghttp2.org");
+  Client cli("httpbingo.org");
   KeepAliveTest(cli, true);
 }
 
 TEST(KeepAliveTest, SSLWithBasic) {
-  SSLClient cli("nghttp2.org");
+  SSLClient cli("httpbingo.org");
   KeepAliveTest(cli, true);
 }
 
 TEST(KeepAliveTest, NoSSLWithDigest) {
-  Client cli("nghttp2.org");
+  Client cli("httpbingo.org");
   KeepAliveTest(cli, false);
 }
 
 TEST(KeepAliveTest, SSLWithDigest) {
-  SSLClient cli("nghttp2.org");
+  SSLClient cli("httpbingo.org");
   KeepAliveTest(cli, false);
 }
 #endif
@@ -349,11 +348,11 @@ TEST(KeepAliveTest, SSLWithDigest) {
 
 #ifdef CPPHTTPLIB_SSL_ENABLED
 TEST(ProxyTest, SSLOpenStream) {
-  SSLClient cli("nghttp2.org");
+  SSLClient cli("httpbingo.org");
   cli.set_proxy("localhost", 3128);
   cli.set_proxy_basic_auth("hello", "world");
 
-  auto handle = cli.open_stream("GET", "/httpbin/get");
+  auto handle = cli.open_stream("GET", "/get");
   ASSERT_TRUE(handle.response);
   EXPECT_EQ(StatusCode::OK_200, handle.response->status);