Quellcode durchsuchen

Fix #2458: send body when no 100 Continue arrives over TLS (#2460)

The auto-added `Expect: 100-continue` (for bodies >= 1024 bytes) decided
whether to withhold the request body based on raw socket readability via
select_read(). Over TLS, post-handshake records such as TLS 1.3 session
tickets make the socket readable without any HTTP response being
available, so the client withheld the body and then blocked reading a
response that never came, failing with `Failed to read connection`.

Decide based on whether a status line can actually be read within the
100-continue timeout instead: temporarily shorten the read timeout, try
to read the status line, and if none arrives, send the body and proceed
as usual (matching curl). This keeps the `100 Continue` and early
final-response paths working while no longer being fooled by TLS records.

Add a regression test using a raw OpenSSL server that never sends
`100 Continue`.
yhirose vor 2 Monaten
Ursprung
Commit
91219d4508
2 geänderte Dateien mit 128 neuen und 8 gelöschten Zeilen
  1. 24 8
      httplib.h
  2. 104 0
      test/test.cc

+ 24 - 8
httplib.h

@@ -13810,13 +13810,28 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
   }
 #endif
 
-  // Handle Expect: 100-continue with timeout
-  if (expect_100_continue && CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND > 0) {
-    time_t sec = CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND / 1000;
-    time_t usec = (CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND % 1000) * 1000;
-    auto ret = detail::select_read(strm.socket(), sec, usec);
-    if (ret <= 0) {
-      // Timeout or error: send body anyway (server didn't respond in time)
+  // Handle Expect: 100-continue.
+  //
+  // Wait for an interim/early response by attempting to read the status line
+  // under a short timeout, instead of trusting raw socket readability. Over
+  // TLS, post-handshake records (e.g. session tickets) make the socket
+  // readable without any HTTP response being available; relying on
+  // `select_read` there caused the body to be withheld forever and the
+  // request to fail with `Read` (#2458). If no status line arrives within the
+  // timeout, send the body anyway (matching curl's behavior).
+  auto status_line_read = false;
+  if (expect_100_continue && write_request_success) {
+    if (CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND > 0) {
+      time_t sec = CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND / 1000;
+      time_t usec = (CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND % 1000) * 1000;
+      strm.set_read_timeout(sec, usec);
+      status_line_read = read_response_line(strm, req, res, false);
+      strm.set_read_timeout(read_timeout_sec_, read_timeout_usec_);
+    }
+
+    if (!status_line_read) {
+      // No interim response within the timeout: send the body and handle the
+      // response as usual.
       if (!write_request_body(strm, req, error)) { return false; }
       expect_100_continue = false; // Switch to normal response handling
     }
@@ -13824,7 +13839,8 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
 
   // Receive response and headers
   // When using Expect: 100-continue, don't auto-skip `100 Continue` response
-  if (!read_response_line(strm, req, res, !expect_100_continue) ||
+  if ((!status_line_read &&
+       !read_response_line(strm, req, res, !expect_100_continue)) ||
       !detail::read_headers(strm, res.headers)) {
     if (write_request_success) { error = Error::Read; }
     output_error_log(error, &req);

+ 104 - 0
test/test.cc

@@ -13675,6 +13675,110 @@ TEST(Expect100ContinueTest, ServerClosesConnection) {
 }
 #endif
 
+#if defined(CPPHTTPLIB_OPENSSL_SUPPORT) && !defined(_WIN32)
+// Regression test for #2458.
+//
+// A large request body (>= CPPHTTPLIB_EXPECT_100_THRESHOLD) makes the client
+// auto-add `Expect: 100-continue`. Over TLS, the server's TLS 1.3 session
+// ticket can make the client socket spuriously readable during the
+// 100-continue wait. If the readiness is mistaken for an incoming response,
+// the client withholds the body and then blocks reading a response that never
+// comes, failing with `Failed to read connection`.
+//
+// A correct client (like curl) sends the body once no `100 Continue` arrives
+// within the timeout. This raw OpenSSL server deliberately never sends
+// `100 Continue`; the client must still deliver the body and receive 200.
+TEST(Expect100ContinueTest, TLSServerOmits100Continue) {
+  signal(SIGPIPE, SIG_IGN);
+
+  const auto port = PORT + 4;
+
+  auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
+  ASSERT_NE(srv, INVALID_SOCKET);
+
+  int opt = 1;
+  ::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
+
+  sockaddr_in addr{};
+  addr.sin_family = AF_INET;
+  addr.sin_port = htons(static_cast<uint16_t>(port));
+  ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
+
+  ASSERT_EQ(0, ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
+  ASSERT_EQ(0, ::listen(srv, 1));
+
+  std::atomic<size_t> server_body_bytes{0};
+
+  auto server_thread = std::thread([&] {
+    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) { return; }
+
+    // Bound the lifetime of the server side so a buggy client (which never
+    // sends the body) cannot make this thread block forever on join.
+    detail::set_socket_opt_time(cli, SOL_SOCKET, SO_RCVTIMEO, 4, 0);
+
+    SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
+    SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION);
+    SSL_CTX_use_certificate_file(ctx, SERVER_CERT_FILE, SSL_FILETYPE_PEM);
+    SSL_CTX_use_PrivateKey_file(ctx, SERVER_PRIVATE_KEY_FILE, SSL_FILETYPE_PEM);
+
+    SSL *ssl = SSL_new(ctx);
+    SSL_set_fd(ssl, static_cast<int>(cli));
+
+    if (SSL_accept(ssl) > 0) {
+      // Read the request headers. Reading here also flushes the TLS 1.3
+      // session tickets to the client. Deliberately do NOT send
+      // `100 Continue`.
+      std::string buf;
+      char tmp[1024];
+      while (buf.find("\r\n\r\n") == std::string::npos) {
+        auto n = SSL_read(ssl, tmp, sizeof(tmp));
+        if (n <= 0) { break; }
+        buf.append(tmp, static_cast<size_t>(n));
+      }
+
+      // A correct client sends the body now; count what arrives.
+      auto pos = buf.find("\r\n\r\n");
+      size_t body = (pos == std::string::npos) ? 0 : buf.size() - (pos + 4);
+      while (body < 4096) {
+        auto n = SSL_read(ssl, tmp, sizeof(tmp));
+        if (n <= 0) { break; }
+        body += static_cast<size_t>(n);
+      }
+      server_body_bytes = body;
+
+      std::string resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok";
+      SSL_write(ssl, resp.data(), static_cast<int>(resp.size()));
+      SSL_shutdown(ssl);
+    }
+
+    SSL_free(ssl);
+    detail::close_socket(cli);
+    SSL_CTX_free(ctx);
+  });
+
+  auto se = detail::scope_exit([&] {
+    server_thread.join();
+    detail::close_socket(srv);
+  });
+
+  SSLClient cli("127.0.0.1", port);
+  cli.enable_server_certificate_verification(false);
+  cli.set_connection_timeout(5, 0);
+  cli.set_read_timeout(3, 0); // short, so a hang surfaces quickly
+
+  // Body larger than CPPHTTPLIB_EXPECT_100_THRESHOLD (1024) -> auto Expect.
+  std::string body(4096, 'A');
+  auto res = cli.Put("/api/test", body, "application/json");
+
+  ASSERT_TRUE(res) << "request failed: " << to_string(res.error());
+  EXPECT_EQ(StatusCode::OK_200, res->status);
+  EXPECT_EQ(body.size(), server_body_bytes.load());
+}
+#endif
+
 template <typename S, typename C>
 inline void max_timeout_test(S &svr, C &cli, time_t timeout, time_t threshold) {
   svr.Get("/stream", [&](const Request &, Response &res) {