Sfoglia il codice sorgente

Address review of the audit fixes

A retry or redirect after an early answer to Expect: 100-continue now
opens a new connection, interim responses are also skipped while
waiting for 100 Continue, and a URL Client cannot use, such as one with
userinfo, gives an invalid client. Only a 407 is passed on from a
refused CONNECT, WebSocket::close() sends only a valid payload, and the
SSE client stops on an oversized event rather than reconnect to it.

Media type parameter names and user extension mappings are matched
without case, file content is read in larger chunks from a buffer of
its own, the streaming decompression limit reports
ExceedMaxPayloadSize, and the tests use the existing single-response
server helpers. Comments and the README are brought in line.
yhirose 1 giorno fa
parent
commit
ae169c6253
5 ha cambiato i file con 300 aggiunte e 322 eliminazioni
  1. 1 1
      README-sse.md
  2. 2 2
      README-websocket.md
  3. 9 3
      README.md
  4. 133 157
      httplib.h
  5. 155 159
      test/test.cc

+ 1 - 1
README-sse.md

@@ -79,7 +79,7 @@ sse.set_max_reconnect_attempts(10);
 sse.set_headers({{"Authorization", "Bearer new_token"}});
 ```
 
-An event, or a line still waiting for its end, longer than `CPPHTTPLIB_SSE_MAX_EVENT_LENGTH` (16MB by default, `0` for no limit) ends the connection with `Error::ExceedMaxPayloadSize`, and the client reconnects as usual.
+An event, or a line still waiting for its end, longer than `CPPHTTPLIB_SSE_MAX_EVENT_LENGTH` (16MB by default, `0` for no limit) ends the connection with `Error::ExceedMaxPayloadSize`, and the client stops rather than reconnect to the same event. The whole stream also counts against the client's `set_payload_max_length()` (100MB by default), so set it to `0` for a long-lived stream.
 
 #### Control
 

+ 2 - 2
README-websocket.md

@@ -121,8 +121,6 @@ using SubProtocolSelector =
 
 The `SubProtocolSelector` receives the list of subprotocols proposed by the client (from the `Sec-WebSocket-Protocol` header) and returns the selected one. Return an empty string to decline all proposed subprotocols. A returned value that the client did not propose is ignored.
 
-The handler is called whatever the `Origin` of the handshake. A browser sends its cookies along with a WebSocket handshake that another site starts, so if cookies authenticate the connection, check `req.get_header_value("Origin")` against the origins you serve first (cross-site WebSocket hijacking).
-
 ### WebSocket (Server-side)
 
 Passed to the handler registered with `Server::WebSocket()`. The handler runs in a dedicated thread per connection.
@@ -360,6 +358,8 @@ svr.set_pre_request_handler([](const httplib::Request &req, httplib::Response &r
 });
 ```
 
+The server accepts a handshake from any `Origin`. A browser sends its cookies with a handshake another site starts, so if cookies authenticate the connection, also check `req.get_header_value("Origin")` against your own origins in one of these handlers (cross-site WebSocket hijacking).
+
 ### Custom Headers and Timeouts
 
 ```cpp

+ 9 - 3
README.md

@@ -100,7 +100,7 @@ cli.enable_server_hostname_verification(false);
 ```
 
 > [!NOTE]
-> The CA certificates are loaded when the client makes its first connection. Changing `set_ca_cert_path()` or `enable_system_ca()` after that has no effect, so make the settings before the first request, or use a new client.
+> The CA certificates are loaded when the client makes its first connection. Changing `set_ca_cert_path()` or `enable_system_ca()` after that has no effect, so make the settings before the first request, or use a new client. `set_ca_cert_store()` and `load_ca_cert_store()` take effect at once. `WebSocketClient` works the same way.
 
 ### SSL Error Handling
 
@@ -343,6 +343,8 @@ Note the following:
 * Static file serving remains `GET`/`HEAD` only, and WebSocket upgrades `GET` only.
 * `Allow` and the WebDAV `DAV:` header are not generated automatically. Register an `Options` handler if clients need them.
 
+The request line itself is read strictly, as RFC 9112 asks: a single space between its three parts, no fragment (`#...`) in the target, and a `Host` header in every HTTP/1.1 request. Anything else gets `400 Bad Request`.
+
 ### Bind a socket to multiple interfaces and any available port
 
 ```cpp
@@ -441,7 +443,7 @@ svr.set_file_request_handler([](const Request &req, Response &res) {
 });
 ```
 
-It is not called for a `HEAD` request, nor when the file has not changed and `304 Not Modified` is sent, so do not rely on it for access control; use `set_pre_routing_handler()` for that.
+It is not called for `HEAD` requests or `304 Not Modified` responses, so do not use it for access control; use `set_pre_routing_handler()`.
 
 ### Logging
 
@@ -1337,6 +1339,8 @@ cli.set_proxy_bearer_token_auth("pass");
 > [!NOTE]
 > A TLS backend (OpenSSL, Mbed TLS, or wolfSSL) is required for Digest Authentication.
 
+When the proxy refuses the `CONNECT` of an HTTPS request, the request fails with `Error::ProxyConnection`. Only a `407 Proxy Authentication Required` comes back as the response, since any other answer from the proxy could be taken for the origin's.
+
 #### Bypass the proxy for specific hosts (`NO_PROXY`)
 
 ```cpp
@@ -1393,7 +1397,9 @@ res = cli.Get("/");
 res->status; // 200
 ```
 
-On a redirect to another host, `Authorization`, `Cookie` and `Proxy-Authorization` are dropped along with the credentials set with `set_basic_auth()` and the like, and the client certificate is not presented. Other headers, the default ones included, go along (RFC 9110 §15.4), and so do the timeouts, the payload limit and the TLS verification settings.
+A URL with userinfo (`http://user:pass@host/`) is not supported, neither for a `Client` nor in a `Location`; use `set_basic_auth()` for the credentials.
+
+On a redirect to another host, `Authorization`, `Cookie` and `Proxy-Authorization` are dropped along with the credentials set with `set_basic_auth()`, `set_bearer_token_auth()` and `set_digest_auth()`, and the client certificate is not presented. Other headers, the default ones included, go along (RFC 9110 §15.4), and so do the timeouts, the payload limit and the TLS verification settings.
 
 ### Use a specific network interface
 

+ 133 - 157
httplib.h

@@ -673,6 +673,15 @@ inline bool equal(const std::string &a, const std::string &b) {
          });
 }
 
+// For a literal, without building a std::string from it.
+template <size_t N>
+inline bool equal(const std::string &a, const char (&b)[N]) {
+  return a.size() == N - 1 &&
+         std::equal(a.begin(), a.end(), b, [](char ca, char cb) {
+           return to_lower(ca) == to_lower(cb);
+         });
+}
+
 struct equal_to {
   bool operator()(const std::string &a, const std::string &b) const {
     return equal(a, b);
@@ -3988,9 +3997,9 @@ public:
   bool is_open() const;
   size_t size() const;
   const char *data() const;
-  // Copies up to len bytes from offset into buf and returns how many. Unlike
-  // reading data(), it gives fewer, down to 0, for a file that has shrunk
-  // since it was opened, rather than a SIGBUS.
+  // Copies up to len bytes from offset into buf and returns how many: fewer,
+  // down to 0, if the file has shrunk since it was opened, where reading
+  // data() would raise SIGBUS.
   size_t read(size_t offset, char *buf, size_t len) const;
 
 private:
@@ -5053,6 +5062,7 @@ inline void SSEClient::run_event_loop() {
     auto has_id = false;
     auto first_line = true;
     auto after_cr = false;
+    auto event_too_long = false;
 
     while (running_.load() && result.next()) {
       // What is left in the buffer has no line ending
@@ -5110,6 +5120,7 @@ inline void SSEClient::run_event_loop() {
           buffer.size() + current_msg.data.size() >
               CPPHTTPLIB_SSE_MAX_EVENT_LENGTH) {
         if (on_error_) { on_error_(Error::ExceedMaxPayloadSize); }
+        event_too_long = true;
         break;
       }
     }
@@ -5117,7 +5128,8 @@ inline void SSEClient::run_event_loop() {
     // Connection ended
     connected_.store(false);
 
-    if (!running_.load()) { break; }
+    // Reconnecting would only get the same event again
+    if (!running_.load() || event_too_long) { break; }
 
     // Check for read errors
     if (result.has_read_error()) {
@@ -5804,7 +5816,8 @@ inline FrameRead read_websocket_frame(Stream &strm, Opcode &opcode,
     }
   }
 
-  // A max_len of 0 means no limit, as payload_max_length does.
+  // A max_len of 0 means no limit, as payload_max_length does, and a length
+  // size_t cannot hold (32-bit builds) is refused either way.
   if ((max_len > 0 && payload_len > max_len) ||
       payload_len > (std::numeric_limits<size_t>::max)()) {
     return FrameRead::Fail;
@@ -6524,22 +6537,6 @@ inline const char *mmap::data() const {
   return is_open_empty_file ? "" : static_cast<const char *>(addr_);
 }
 
-inline size_t mmap::read(size_t offset, char *buf, size_t len) const {
-  if (offset >= size_) { return 0; }
-  len = (std::min)(len, size_ - offset);
-#if defined(_WIN32)
-  // Windows does not let a mapped file be truncated.
-  std::memcpy(buf, static_cast<const char *>(addr_) + offset, len);
-  return len;
-#else
-  ssize_t n;
-  do {
-    n = ::pread(fd_, buf, len, static_cast<off_t>(offset));
-  } while (n < 0 && errno == EINTR);
-  return n > 0 ? static_cast<size_t>(n) : 0;
-#endif
-}
-
 inline void mmap::close() {
 #if defined(_WIN32)
   if (addr_) {
@@ -6592,6 +6589,20 @@ template <typename T> inline ssize_t handle_EINTR(T fn) {
   return res;
 }
 
+inline size_t mmap::read(size_t offset, char *buf, size_t len) const {
+  if (offset >= size_) { return 0; }
+  len = (std::min)(len, size_ - offset);
+#if defined(_WIN32)
+  // Windows does not let a mapped file be truncated.
+  std::memcpy(buf, static_cast<const char *>(addr_) + offset, len);
+  return len;
+#else
+  auto n = handle_EINTR(
+      [&]() { return ::pread(fd_, buf, len, static_cast<off_t>(offset)); });
+  return n > 0 ? static_cast<size_t>(n) : 0;
+#endif
+}
+
 inline ssize_t read_socket(socket_t sock, void *ptr, size_t size, int flags) {
   return handle_EINTR([&]() {
     return recv(sock,
@@ -7634,7 +7645,11 @@ find_content_type(const std::string &path,
                   const std::string &default_content_type) {
   auto ext = file_extension(path);
 
+  // Extensions are matched regardless of case, as web servers do, with an
+  // exact match to a user mapping first
+  auto lower_ext = case_ignore::to_lower(ext);
   auto it = user_data.find(ext);
+  if (it == user_data.end()) { it = user_data.find(lower_ext); }
   if (it != user_data.end()) { return it->second; }
 
   static const struct {
@@ -7692,8 +7707,6 @@ find_content_type(const std::string &path,
       {"wasm", "application/wasm"},
   };
 
-  // Extensions are matched regardless of case, as web servers do
-  auto lower_ext = case_ignore::to_lower(ext);
   for (const auto &t : types) {
     if (lower_ext == t.ext) { return t.type; }
   }
@@ -7718,8 +7731,10 @@ extract_media_type(const std::string &content_type,
                        std::string key;
                        std::string val;
                        divide_param_pair(b, e, key, val);
+                       // Parameter names are case-insensitive (RFC 9110
+                       // §5.6.6)
                        if (!key.empty()) {
-                         params->emplace(trim_copy(key),
+                         params->emplace(case_ignore::to_lower(trim_copy(key)),
                                          trim_double_quotes_copy(val));
                        }
                      });
@@ -8229,23 +8244,12 @@ create_compressor() {
 #endif
 }
 
-// Whether s is lit, ignoring case, with the lengths compared first.
-template <size_t N>
-inline bool equals_literal_ignoring_case(const std::string &s,
-                                         const char (&lit)[N]) {
-  return s.size() == N - 1 &&
-         std::equal(s.begin(), s.end(), lit, [](char a, char b) {
-           return case_ignore::to_lower(a) == case_ignore::to_lower(b);
-         });
-}
-
 inline bool is_prohibited_header_name(const std::string &name) {
-  // Field names are case-insensitive (RFC 9110 §5.1), and every header lookup
-  // by name runs this.
-  return equals_literal_ignoring_case(name, "REMOTE_ADDR") ||
-         equals_literal_ignoring_case(name, "REMOTE_PORT") ||
-         equals_literal_ignoring_case(name, "LOCAL_ADDR") ||
-         equals_literal_ignoring_case(name, "LOCAL_PORT");
+  // Field names are case-insensitive (RFC 9110 §5.1)
+  return case_ignore::equal(name, "REMOTE_ADDR") ||
+         case_ignore::equal(name, "REMOTE_PORT") ||
+         case_ignore::equal(name, "LOCAL_ADDR") ||
+         case_ignore::equal(name, "LOCAL_PORT");
 }
 
 inline bool has_header(const Headers &headers, const std::string &key) {
@@ -8958,8 +8962,7 @@ write_content_without_length(Stream &strm,
 }
 
 // Runs a known-length content provider to completion and compresses what it
-// writes into `out`. Nothing is buffered in identity form: a provider backed
-// by an mmap hands the compressor a pointer straight into the mapping.
+// writes into `out`, chunk by chunk, without holding the identity form.
 inline bool compress_content_provider(const ContentProvider &content_provider,
                                       size_t length, compressor &cmp,
                                       std::string &out) {
@@ -9008,15 +9011,17 @@ inline void set_file_content_provider(Response &res,
                                       const std::shared_ptr<mmap> &m,
                                       const std::string &content_type,
                                       EncodingType encoding) {
+  // One read and one write move up to this much of the file at a time.
+  auto buf = std::make_shared<std::vector<char>>(
+      (std::min)(m->size(), size_t(256 * 1024)));
   res.set_content_provider(
       m->size(), content_type,
-      [m](size_t offset, size_t length, DataSink &sink) -> bool {
+      [m, buf](size_t offset, size_t length, DataSink &sink) -> bool {
         // Never read past the file, which ends the response early if it has
         // shrunk since it was opened.
-        char buf[CPPHTTPLIB_SEND_BUFSIZ];
-        auto n = m->read(offset, buf, (std::min)(length, sizeof(buf)));
+        auto n = m->read(offset, buf->data(), (std::min)(length, buf->size()));
         if (n == 0) { return false; }
-        sink.write(buf, n);
+        sink.write(buf->data(), n);
         return true;
       });
 
@@ -11682,20 +11687,17 @@ inline std::string decode_path_component(const std::string &component) {
       if (component[i + 1] == 'u') {
         // Unicode %uXXXX encoding
         auto val = 0;
-        if (detail::from_hex_to_i(component, i + 2, 4, val)) {
-          // 4 digits Unicode codes: val is 0x0000-0xFFFF (from 4 hex digits),
-          // so to_utf8 writes at most 3 bytes. buff[4] is safe.
-          char buff[4];
-          size_t len = detail::to_utf8(val, buff);
-          if (len > 0) {
-            result.append(buff, len);
-            i += 5; // 'u0000'
-          } else {
-            // A surrogate is no character. Dropping it would join what is on
-            // either side of it, so the escape is kept as it is.
-            result += component[i];
-          }
+        // 4 digits Unicode codes: val is 0x0000-0xFFFF (from 4 hex digits),
+        // so to_utf8 writes at most 3 bytes. buff[4] is safe.
+        char buff[4];
+        size_t len = 0;
+        if (detail::from_hex_to_i(component, i + 2, 4, val) &&
+            (len = detail::to_utf8(val, buff)) > 0) {
+          result.append(buff, len);
+          i += 5; // 'u0000'
         } else {
+          // Not 4 hex digits, or a surrogate, which is no character: the
+          // escape is kept as it is rather than joining its neighbours
           result += component[i];
         }
       } else {
@@ -13689,12 +13691,10 @@ inline bool Server::parse_request_line(const char *s, Request &req) const {
     // version. Taking other whitespace as a separator too lets recipients
     // that parse more strictly see a different request.
     auto end = s + len;
+    if (std::count(s, end, ' ') != 2) { return false; }
     auto sp1 = std::find(s, end, ' ');
-    auto sp2 = sp1 == end ? end : std::find(sp1 + 1, end, ' ');
-    if (sp2 == end || std::find(sp2 + 1, end, ' ') != end || sp1 == s ||
-        sp2 == sp1 + 1 || sp2 + 1 == end) {
-      return false;
-    }
+    auto sp2 = std::find(sp1 + 1, end, ' ');
+    if (sp1 == s || sp2 == sp1 + 1 || sp2 + 1 == end) { return false; }
     req.method.assign(s, sp1);
     req.target.assign(sp1 + 1, sp2);
     req.version.assign(sp2 + 1, end);
@@ -14849,8 +14849,9 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
     return write_response(strm, close_connection, req, res);
   }
 
-  // RFC 9112 §6.1: answer a request that also has a Content-Length, or an
-  // HTTP/1.0 one, by its Transfer-Encoding, but close the connection after.
+  // RFC 9112 §6.1: a Transfer-Encoding alongside a Content-Length, or in an
+  // HTTP/1.0 request, is honoured, but the connection closes after the
+  // response.
   if (req.has_header("Transfer-Encoding") &&
       (req.has_header("Content-Length") || req.version == "HTTP/1.0")) {
     connection_closed = true;
@@ -14859,9 +14860,9 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
   // RFC 9112 §3.2: Reject more than one Host field line, an invalid Host
   // value, or none in HTTP/1.1, so that every recipient of the request sees
   // the same authority.
-  if (req.get_header_value_count("Host") > 1 ||
-      !detail::fields::is_host(req.get_header_value("Host")) ||
-      (req.version == "HTTP/1.1" && !req.has_header("Host"))) {
+  auto host_count = req.get_header_value_count("Host");
+  if (host_count > 1 || (host_count == 0 && req.version == "HTTP/1.1") ||
+      !detail::fields::is_host(req.get_header_value("Host"))) {
     connection_closed = true;
     res.status = StatusCode::BadRequest_400;
     output_error_log(Error::InvalidHeaders, &req);
@@ -14966,11 +14967,14 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
   // Run pre_routing_handler_ and pre_request_handler_ before upgrading so
   // that authentication and other middleware can reject the request with an
   // HTTP response (e.g., 401) before the protocol switches.
+  // The response says so when this connection closes after it.
+  auto close_after_response = close_connection || connection_closed;
+
   if (detail::is_websocket_upgrade(req)) {
     if (pre_routing_handler_ &&
         pre_routing_handler_(req, res) == HandlerResponse::Handled) {
       if (res.status == -1) { res.status = StatusCode::OK_200; }
-      return write_response_with_content(strm, close_connection, req, res);
+      return write_response_with_content(strm, close_after_response, req, res);
     }
     // Find matching WebSocket handler
     for (const auto &entry : websocket_handlers_) {
@@ -14979,7 +14983,8 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
         if (pre_request_handler_ &&
             pre_request_handler_(req, res) == HandlerResponse::Handled) {
           if (res.status == -1) { res.status = StatusCode::OK_200; }
-          return write_response_with_content(strm, close_connection, req, res);
+          return write_response_with_content(strm, close_after_response, req,
+                                             res);
         }
 
         // Compute accept key
@@ -15087,8 +15092,6 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
     }
   }
 #endif
-  // The response says so when this connection closes after it.
-  auto close = close_connection || connection_closed;
   auto ret = false;
   if (routed) {
     if (res.status == -1) {
@@ -15122,17 +15125,17 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
     }
 
     if (file_open_error) {
-      ret = write_response(strm, close, req, res);
+      ret = write_response(strm, close_after_response, req, res);
     } else if (detail::range_error(req, res)) {
       detail::clear_content(res);
       res.status = StatusCode::RangeNotSatisfiable_416;
-      ret = write_response(strm, close, req, res);
+      ret = write_response(strm, close_after_response, req, res);
     } else {
-      ret = write_response_with_content(strm, close, req, res);
+      ret = write_response_with_content(strm, close_after_response, req, res);
     }
   } else {
     if (res.status == -1) { res.status = StatusCode::NotFound_404; }
-    ret = write_response(strm, close, req, res);
+    ret = write_response(strm, close_after_response, req, res);
   }
 
   // Drain any unconsumed framed body to prevent request smuggling on
@@ -15350,11 +15353,12 @@ inline bool ClientImpl::read_response_line(Stream &strm, const Request &req,
     return req.method == "CONNECT";
   }
 
-  // Skip interim responses, such as 100 Continue (unless Expect:
-  // 100-continue is handled by the caller) and 103 Early Hints, along with
-  // their header fields. 101 ends HTTP on the connection, so it is final.
-  while (skip_100_continue && 100 <= res.status && res.status < 200 &&
-         res.status != StatusCode::SwitchingProtocol_101) {
+  // Skip interim (1xx) responses along with their header fields, except a 100
+  // Continue the caller handles itself for Expect: 100-continue. 101 ends
+  // HTTP on the connection, so it is final.
+  while (100 <= res.status && res.status < 200 &&
+         res.status != StatusCode::SwitchingProtocol_101 &&
+         (skip_100_continue || res.status != StatusCode::Continue_100)) {
     Headers interim_headers;
     if (!detail::read_headers(strm, interim_headers)) { return false; }
     if (!line_reader.getline()) { return false; } // next response line
@@ -15759,17 +15763,21 @@ inline ssize_t ClientImpl::StreamHandle::read_with_decompression(char *buf,
 
     if (n <= 0) { return n; }
 
+    auto exceeded = false;
     bool decompress_ok = decompressor_->decompress(
         compressed_buf, static_cast<size_t>(n),
-        [this](const char *data, size_t data_len) {
+        [&](const char *data, size_t data_len) {
           decompress_buffer_.append(data, data_len);
           auto limit = body_reader_.payload_max_length;
-          return limit == 0 ||
-                 decompressed_bytes_read_ + decompress_buffer_.size() <= limit;
+          exceeded =
+              limit > 0 &&
+              decompressed_bytes_read_ + decompress_buffer_.size() > limit;
+          return !exceeded;
         });
 
     if (!decompress_ok) {
-      body_reader_.last_error = Error::Read;
+      body_reader_.last_error =
+          exceeded ? Error::ExceedMaxPayloadSize : Error::Read;
       return -1;
     }
 
@@ -15934,8 +15942,7 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req,
 
   if (!ret) { return false; }
 
-  if (detail::has_header_token(res.headers, "Connection", "close") ||
-      (res.version == "HTTP/1.0" && res.reason != "Connection established")) {
+  {
     // NOTE: this requires a not-entirely-obvious chain of calls to be correct
     // for this to be safe.
 
@@ -15944,7 +15951,13 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req,
     // to call it from a different thread since it's a thread-safety issue
     // to do these things to the socket if another thread is using the socket.
     std::lock_guard<std::mutex> guard(socket_mutex_);
-    disconnect(/*gracefully=*/true);
+    // Also when the request asked for it (e.g. a body that was never sent),
+    // so that a redirect or an auth retry below does not reuse the socket.
+    if (socket_should_be_closed_when_request_is_done_ ||
+        detail::has_header_token(res.headers, "Connection", "close") ||
+        (res.version == "HTTP/1.0" && res.reason != "Connection established")) {
+      disconnect(/*gracefully=*/true);
+    }
   }
 
   if (300 < res.status && res.status < 400 && follow_location_) {
@@ -17888,6 +17901,9 @@ inline Client::Client(const std::string &scheme_host_port,
       cli_ = detail::make_unique<ClientImpl>(host, port, client_cert_path,
                                              client_key_path);
     }
+  } else if (scheme_host_port.find("//") != std::string::npos) {
+    // A URL, but not one this client can use, such as one with userinfo
+    cli_ = detail::make_unique<detail::InvalidClientImpl>();
   } else {
     // NOTE: Update TEST(UniversalClientImplTest, Ipv6LiteralAddress)
     // if port param below changes.
@@ -18846,13 +18862,13 @@ inline bool SSLClient::connect_with_proxy(
   }
 
   // If status code is not 200, proxy request is failed.
-  // Set error to ProxyConnection. A refusal such as 407 is returned as the
-  // response of the request, so the caller sees why; any other status would
-  // read as the answer of the origin, which it never reached.
+  // Set error to ProxyConnection. A 407 is returned as the response of the
+  // request, so the caller sees the proxy asks for credentials; any other
+  // status would read as the answer of the origin, which it never reached.
   if (proxy_res.status != StatusCode::OK_200) {
     error = Error::ProxyConnection;
     output_error_log(error, nullptr);
-    if (400 <= proxy_res.status && proxy_res.status < 600) {
+    if (proxy_res.status == StatusCode::ProxyAuthenticationRequired_407) {
       res = std::move(proxy_res);
     } else {
       success = false;
@@ -19712,8 +19728,8 @@ inline bool set_client_cert_file(ctx_t ctx, const char *cert_path,
 inline ctx_t create_server_context() {
   SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
   if (ctx) {
-    // No renegotiation, which would run a handshake outside the one that
-    // SSLServer::update_certs_pem() waits for, as the other backends have it
+    // No renegotiation, as on the other backends: it would run a handshake
+    // that SSLServer::update_certs_pem() does not wait for
     SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION |
                                  SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION |
                                  SSL_OP_NO_RENEGOTIATION);
@@ -22911,53 +22927,15 @@ inline std::vector<std::string> get_ca_names(ctx_t ctx) {
 inline bool update_server_cert(ctx_t ctx, const char *cert_pem,
                                const char *key_pem, const char *password) {
   if (!ctx || !cert_pem || !key_pem) { return false; }
-  auto *wctx = static_cast<impl::WolfSSLContext *>(ctx);
 
-  // Try the pair on a scratch context first, so a bad one leaves the server
-  // with its old certificate and key rather than half of each.
-  {
-    auto tmp = wolfSSL_CTX_new(wolfTLSv1_2_server_method());
-    if (!tmp) { return false; }
-    auto se = detail::scope_exit([&] { wolfSSL_CTX_free(tmp); });
-    if (password) { impl::set_wolfssl_password_cb(tmp, password); }
-    if (wolfSSL_CTX_use_certificate_buffer(
-            tmp, reinterpret_cast<const unsigned char *>(cert_pem),
-            static_cast<long>(strlen(cert_pem)),
-            SSL_FILETYPE_PEM) != SSL_SUCCESS ||
-        wolfSSL_CTX_use_PrivateKey_buffer(
-            tmp, reinterpret_cast<const unsigned char *>(key_pem),
-            static_cast<long>(strlen(key_pem)),
-            SSL_FILETYPE_PEM) != SSL_SUCCESS) {
-      impl::wolfssl_last_error() =
-          static_cast<uint64_t>(wolfSSL_ERR_peek_last_error());
-      return false;
-    }
-  }
-
-  // Load new certificate
-  int ret = wolfSSL_CTX_use_certificate_buffer(
-      wctx->ctx, reinterpret_cast<const unsigned char *>(cert_pem),
-      static_cast<long>(strlen(cert_pem)), SSL_FILETYPE_PEM);
-  if (ret != SSL_SUCCESS) {
-    impl::wolfssl_last_error() =
-        static_cast<uint64_t>(wolfSSL_ERR_peek_last_error());
-    return false;
-  }
-
-  // Set password if provided
-  if (password) { impl::set_wolfssl_password_cb(wctx->ctx, password); }
-
-  // Load new private key
-  ret = wolfSSL_CTX_use_PrivateKey_buffer(
-      wctx->ctx, reinterpret_cast<const unsigned char *>(key_pem),
-      static_cast<long>(strlen(key_pem)), SSL_FILETYPE_PEM);
-  if (ret != SSL_SUCCESS) {
-    impl::wolfssl_last_error() =
-        static_cast<uint64_t>(wolfSSL_ERR_peek_last_error());
-    return false;
-  }
+  // Try the pair on a scratch context first, so that a bad one leaves the
+  // server's certificate and key as they were
+  auto tmp = create_server_context();
+  if (!tmp) { return false; }
+  auto se = detail::scope_exit([&] { free_context(tmp); });
+  if (!set_client_cert_pem(tmp, cert_pem, key_pem, password)) { return false; }
 
-  return true;
+  return set_client_cert_pem(ctx, cert_pem, key_pem, password);
 }
 
 inline bool update_server_client_ca(ctx_t ctx, const char *ca_pem) {
@@ -22970,17 +22948,8 @@ inline bool update_server_client_ca(ctx_t ctx, const char *ca_pem) {
   if (!store) { return false; }
   free_ca_store(store);
   wolfSSL_CTX_UnloadCAs(wctx->ctx);
-
-  int ret = wolfSSL_CTX_load_verify_buffer(
-      wctx->ctx, reinterpret_cast<const unsigned char *>(ca_pem),
-      static_cast<long>(strlen(ca_pem)), SSL_FILETYPE_PEM);
-  if (ret != SSL_SUCCESS) {
-    impl::wolfssl_last_error() =
-        static_cast<uint64_t>(wolfSSL_ERR_peek_last_error());
-    return false;
-  }
-  wctx->ca_pem_data_ = ca_pem;
-  return true;
+  wctx->ca_pem_data_.clear();
+  return load_ca_pem(ctx, ca_pem, strlen(ca_pem));
 }
 
 inline bool set_verify_callback(ctx_t ctx, VerifyCallback callback) {
@@ -23093,10 +23062,6 @@ inline ReadResult WebSocket::read(std::string &msg) {
           Opcode cont_opcode;
           std::string cont_payload;
           bool cont_fin;
-          // A timeout is not reportable here: half of a fragmented message is
-          // already in `msg` and read() has no way to resume it, so it is a
-          // failure like any other. Timeouts are only ever seen on a message
-          // boundary.
           // Only what is left of the limit, but room for a control frame,
           // which may come between the fragments.
           auto max_len =
@@ -23105,6 +23070,10 @@ inline ReadResult WebSocket::read(std::string &msg) {
                   : (std::max)(size_t(125),
                                CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH -
                                    msg.size());
+          // A timeout is not reportable here: half of a fragmented message is
+          // already in `msg` and read() has no way to resume it, so it is a
+          // failure like any other. Timeouts are only ever seen on a message
+          // boundary.
           if (impl::read_websocket_frame(strm_, cont_opcode, cont_payload,
                                          cont_fin, is_server_,
                                          max_len) != impl::FrameRead::Ok) {
@@ -23173,8 +23142,15 @@ inline void WebSocket::close(CloseStatus status, const std::string &reason) {
   payload.push_back(static_cast<char>((code >> 8) & 0xFF));
   payload.push_back(static_cast<char>(code & 0xFF));
   // RFC 6455 Section 5.5: control frame payload must not exceed 125 bytes
-  // Close frame has 2-byte status code, so reason is limited to 123 bytes
-  payload += reason.substr(0, 123);
+  // Close frame has 2-byte status code, so reason is limited to 123 bytes,
+  // cut back to a whole UTF-8 character
+  auto short_reason = reason.substr(0, 123);
+  while (!short_reason.empty() && !impl::is_valid_utf8(short_reason)) {
+    short_reason.pop_back();
+  }
+  payload += short_reason;
+  // A code that may not be sent, such as NoStatus, goes without a payload
+  if (!impl::is_valid_close_payload(payload)) { payload.clear(); }
   {
     std::lock_guard<std::mutex> lock(write_mutex_);
     detail::write_websocket_frame(strm_, Opcode::Close, payload.data(),

+ 155 - 159
test/test.cc

@@ -703,8 +703,13 @@ TEST(CanCompressContentTypeTest, ComparesWholeTypes) {
 
 TEST(MultipartFormDataTest, MediaTypeIgnoresCase) {
   Request req;
-  req.set_header("Content-Type", "Multipart/Form-Data; boundary=x");
+  req.set_header("Content-Type", "Multipart/Form-Data; Boundary=x");
   EXPECT_TRUE(req.is_multipart_form_data());
+
+  std::string boundary;
+  EXPECT_TRUE(detail::parse_multipart_boundary(
+      req.get_header_value("Content-Type"), boundary));
+  EXPECT_EQ("x", boundary);
 }
 
 TEST(ProhibitedHeaderNameTest, ComparesWholeNames) {
@@ -13639,156 +13644,6 @@ TEST(ClientPayloadTest, DoesNotReserveTheClaimedLength) {
   EXPECT_FALSE(cli.Get("/"));
 }
 
-// Accepts one connection on 127.0.0.1:port, reads the request head and sends
-// back the given bytes, which httplib::Server could not be made to send.
-static void serve_raw_once(int port, const std::string &response) {
-  auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
-  default_socket_options(srv);
-  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);
-  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);
-
-  auto cli = ::accept(srv, nullptr, nullptr);
-  if (cli != INVALID_SOCKET) {
-    detail::set_socket_opt_time(cli, SOL_SOCKET, SO_RCVTIMEO, 3, 0);
-    std::string head;
-    char c;
-    while (head.find("\r\n\r\n") == std::string::npos &&
-           ::recv(cli, &c, 1, 0) == 1) {
-      head += c;
-    }
-    ::send(cli, response.data(), static_cast<int>(response.size()), 0);
-    detail::close_socket(cli);
-  }
-  detail::close_socket(srv);
-}
-
-TEST(InterimResponseTest, SkippedWithTheirHeaderFields) {
-  const char *responses[] = {
-      "HTTP/1.1 103 Early Hints\r\n"
-      "Link: </style.css>; rel=preload\r\n"
-      "\r\n"
-      "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
-      "HTTP/1.1 100 Continue\r\n"
-      "X-Note: interim\r\n"
-      "\r\n"
-      "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
-  };
-
-  for (auto response : responses) {
-    std::thread t([&]() { serve_raw_once(PORT, response); });
-    std::this_thread::sleep_for(std::chrono::milliseconds(100));
-
-    Client cli("127.0.0.1", PORT);
-    auto res = cli.Get("/");
-    t.join();
-    ASSERT_TRUE(res) << "Error: " << to_string(res.error());
-    EXPECT_EQ(StatusCode::OK_200, res->status);
-    EXPECT_EQ("ok", res->body);
-  }
-}
-
-#ifdef CPPHTTPLIB_SSL_ENABLED
-TEST(ProxyConnectTest, OnlyARefusalIsPassedOn) {
-  // Anything but 200 or a refusal would read as the origin's own response.
-  {
-    std::thread t([] {
-      serve_raw_once(PORT, "HTTP/1.1 201 Created\r\n"
-                           "Set-Cookie: session=from-proxy\r\n"
-                           "Content-Length: 0\r\n\r\n");
-    });
-    std::this_thread::sleep_for(std::chrono::milliseconds(100));
-    SSLClient cli("example.com", 443);
-    cli.set_proxy("127.0.0.1", PORT);
-    auto res = cli.Get("/");
-    t.join();
-    EXPECT_FALSE(res);
-    EXPECT_EQ(Error::ProxyConnection, res.error());
-  }
-  {
-    std::thread t([] {
-      serve_raw_once(PORT, "HTTP/1.1 407 Proxy Authentication Required\r\n"
-                           "Content-Length: 0\r\n\r\n");
-    });
-    std::this_thread::sleep_for(std::chrono::milliseconds(100));
-    SSLClient cli("example.com", 443);
-    cli.set_proxy("127.0.0.1", PORT);
-    auto res = cli.Get("/");
-    t.join();
-    ASSERT_TRUE(res);
-    EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status);
-  }
-}
-#endif
-
-TEST(ExpectContinueTest, EarlyFinalResponseClosesConnection) {
-  std::atomic<bool> closed_by_client{false};
-  auto server_thread = std::thread([&] {
-    auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
-    default_socket_options(srv);
-    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);
-    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);
-
-    auto cli = ::accept(srv, nullptr, nullptr);
-    if (cli != INVALID_SOCKET) {
-      detail::set_socket_opt_time(cli, SOL_SOCKET, SO_RCVTIMEO, 3, 0);
-
-      // Answer the head without asking for the body, and keep the connection.
-      std::string head;
-      char c;
-      while (head.find("\r\n\r\n") == std::string::npos &&
-             ::recv(cli, &c, 1, 0) == 1) {
-        head += c;
-      }
-      std::string res =
-          "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
-      ::send(cli, res.data(), static_cast<int>(res.size()), 0);
-
-      // Bytes here would be read as the body the first request declared.
-      char buf[256];
-      closed_by_client = ::recv(cli, buf, sizeof(buf), 0) == 0;
-      detail::close_socket(cli);
-    }
-    detail::close_socket(srv);
-  });
-  std::this_thread::sleep_for(std::chrono::milliseconds(100));
-
-  Client cli("127.0.0.1", PORT);
-  cli.set_keep_alive(true);
-  cli.set_read_timeout(1, 0);
-  // Long enough for the client to send Expect: 100-continue.
-  auto res = cli.Post("/upload", std::string(2048, 'B'), "text/plain");
-  ASSERT_TRUE(res) << "Error: " << to_string(res.error());
-  EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
-  cli.Get("/second");
-
-  server_thread.join();
-  EXPECT_TRUE(closed_by_client);
-}
-
 #ifdef CPPHTTPLIB_ZLIB_SUPPORT
 // `payload_max_length` is not enforced on decompressed body in ContentReader
 // path.
@@ -15040,6 +14895,12 @@ TEST(HostAndPortPropertiesTest, PortOutOfRange) {
   EXPECT_EQ(Error::Connection, res.error());
 }
 
+TEST(HostAndPortPropertiesTest, UserinfoIsNotSupported) {
+  httplib::Client cli("http://user:pass@localhost:1234");
+  ASSERT_FALSE(cli.is_valid());
+  EXPECT_FALSE(cli.Get("/"));
+}
+
 TEST(HostAndPortPropertiesTest, TrailingCharactersInPort) {
   httplib::Client cli("http://www.google.com:80abc");
   ASSERT_FALSE(cli.is_valid());
@@ -19752,8 +19613,8 @@ TEST(ParseUrlTest, FragmentHandling) {
 }
 
 TEST(ParseUrlTest, UserinfoHandling) {
-  // userinfo is not supported. Parsers that honour it, or take a backslash for
-  // '/', would see evil.com where the host used to be the whole authority.
+  // userinfo, and a backslash some parsers take for '/', are rejected: such
+  // parsers would see evil.com as the host.
   const char *urls[] = {"http://user@evil.com/", "http://user:pass@evil.com/",
                         "http://evil.com\\@good.com/", "//user@evil.com/x"};
   for (auto url : urls) {
@@ -22172,9 +22033,14 @@ TEST_F(OpenStreamTest, ProhibitedTrailersAreIgnored_Stream) {
   EXPECT_EQ(std::string(""), handle.response->get_header_value("X-Allowed"));
 }
 
-static std::thread serve_single_response(std::promise<int> &port_promise,
-                                         const std::string &response) {
-  return std::thread([&port_promise, response] {
+// Accepts one connection on an OS-assigned port, which it hands over through
+// port_promise, reads the request head and sends back response. after_send,
+// if given, runs on the connection before it is closed.
+static std::thread
+serve_single_response(std::promise<int> &port_promise,
+                      const std::string &response,
+                      std::function<void(socket_t)> after_send = nullptr) {
+  return std::thread([&port_promise, response, after_send] {
     auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
     default_socket_options(srv);
     detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
@@ -22237,6 +22103,7 @@ static std::thread serve_single_response(std::promise<int> &port_promise,
 #endif
              0);
 
+      if (after_send) { after_send(cli); }
       detail::close_socket(cli);
     }
     detail::close_socket(srv);
@@ -22315,6 +22182,134 @@ static void with_single_response(const std::string &response, Fn fn) {
   fn(cli);
 }
 
+TEST(InterimResponseTest, SkippedWithTheirHeaderFields) {
+  const char *responses[] = {
+      "HTTP/1.1 103 Early Hints\r\n"
+      "Link: </style.css>; rel=preload\r\n"
+      "\r\n"
+      "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
+      "HTTP/1.1 100 Continue\r\n"
+      "X-Note: interim\r\n"
+      "\r\n"
+      "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
+  };
+
+  for (auto response : responses) {
+    with_single_response(response, [](Client &cli) {
+      auto res = cli.Get("/");
+      ASSERT_TRUE(res) << "Error: " << to_string(res.error());
+      EXPECT_EQ(StatusCode::OK_200, res->status);
+      EXPECT_EQ("ok", res->body);
+    });
+  }
+}
+
+TEST(ExpectContinueTest, EarlyFinalResponseClosesConnection) {
+#ifndef _WIN32
+  signal(SIGPIPE, SIG_IGN);
+#endif
+  // The server answers the head without asking for the body. Bytes it then
+  // reads on the connection would be taken for the body the request declared.
+  auto closed_by_client = false;
+  std::promise<int> port_promise;
+  auto server_thread = serve_single_response(
+      port_promise, "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n",
+      [&](socket_t sock) {
+        detail::set_socket_opt_time(sock, SOL_SOCKET, SO_RCVTIMEO, 3, 0);
+        char buf[256];
+        closed_by_client = ::recv(sock, buf, sizeof(buf), 0) == 0;
+      });
+
+  Client cli("127.0.0.1", port_promise.get_future().get());
+  cli.set_keep_alive(true);
+  cli.set_read_timeout(1, 0);
+  // Long enough for the client to send Expect: 100-continue.
+  auto res = cli.Post("/upload", std::string(2048, 'B'), "text/plain");
+  ASSERT_TRUE(res) << "Error: " << to_string(res.error());
+  EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
+  cli.Get("/second");
+
+  server_thread.join();
+  EXPECT_TRUE(closed_by_client);
+}
+
+TEST(InterimResponseTest, SkippedWhileWaitingForContinue) {
+  // Long enough for the client to send Expect: 100-continue
+  with_single_response(
+      "HTTP/1.1 103 Early Hints\r\n"
+      "Link: </style.css>; rel=preload\r\n"
+      "\r\n"
+      "HTTP/1.1 401 Unauthorized\r\n"
+      "Content-Length: 0\r\n\r\n",
+      [](Client &cli) {
+        cli.set_read_timeout(2, 0);
+        auto res = cli.Post("/upload", std::string(2048, 'B'), "text/plain");
+        ASSERT_TRUE(res) << "Error: " << to_string(res.error());
+        EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
+      });
+}
+
+#ifdef CPPHTTPLIB_SSL_ENABLED
+TEST(ExpectContinueTest, AuthRetryAfterEarlyResponseUsesANewConnection) {
+#ifndef _WIN32
+  signal(SIGPIPE, SIG_IGN);
+#endif
+  auto reused = false;
+  std::promise<int> port_promise;
+  auto server_thread = serve_single_response(
+      port_promise,
+      "HTTP/1.1 401 Unauthorized\r\n"
+      "WWW-Authenticate: Digest realm=\"r\", nonce=\"n\", qop=\"auth\"\r\n"
+      "Content-Length: 0\r\n\r\n",
+      [&](socket_t sock) {
+        detail::set_socket_opt_time(sock, SOL_SOCKET, SO_RCVTIMEO, 3, 0);
+        char buf[256];
+        reused = ::recv(sock, buf, sizeof(buf), 0) > 0;
+      });
+
+  Client cli("127.0.0.1", port_promise.get_future().get());
+  cli.set_keep_alive(true);
+  cli.set_digest_auth("user", "pass");
+  cli.set_read_timeout(1, 0);
+  cli.Post("/upload", std::string(2048, 'B'), "text/plain");
+
+  server_thread.join();
+  EXPECT_FALSE(reused);
+}
+
+TEST(ProxyConnectTest, OnlyAProxyAuthChallengeIsPassedOn) {
+  // Any other answer to CONNECT would read as the origin's response.
+  struct {
+    const char *response;
+    bool passed_on;
+  } cases[] = {
+      {"HTTP/1.1 201 Created\r\n"
+       "Set-Cookie: session=from-proxy\r\n"
+       "Content-Length: 0\r\n\r\n",
+       false},
+      {"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n", false},
+      {"HTTP/1.1 407 Proxy Authentication Required\r\n"
+       "Content-Length: 0\r\n\r\n",
+       true},
+  };
+
+  for (const auto &c : cases) {
+    std::promise<int> port_promise;
+    auto server_thread = serve_single_response(port_promise, c.response);
+    SSLClient cli("example.com", 443);
+    cli.set_proxy("127.0.0.1", port_promise.get_future().get());
+    auto res = cli.Get("/");
+    server_thread.join();
+
+    EXPECT_EQ(Error::ProxyConnection, res.error()) << c.response;
+    ASSERT_EQ(c.passed_on, static_cast<bool>(res)) << c.response;
+    if (res) {
+      EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status);
+    }
+  }
+}
+#endif
+
 // RFC 9112 §6.3: a response that pairs a non-zero Content-Length with
 // Transfer-Encoding is framed ambiguously. Both read paths delimit its body by
 // the chunked coding and drop Content-Length, so a front-end that trusts
@@ -24217,7 +24212,8 @@ TEST_F(SSEIntegrationTest, TooLongEventEndsTheConnection) {
   });
 
   Client client("localhost", get_port());
-  // A stream that may run forever, so that only the event is bounded.
+  // Lift the payload limit, so that only the event limit can end this endless
+  // stream.
   client.set_payload_max_length(0);
   sse::SSEClient sse(client, "/endless-line");
   std::atomic<bool> too_long{false};
@@ -26982,8 +26978,8 @@ TEST(WebSocketTest, AnswersAMalformedCloseWithProtocolError) {
   });
   svr.wait_until_ready();
 
-  // The Close each payload is answered with: an echo when it is valid, and
-  // 1002 otherwise.
+  // Each Close payload and the reply it should get: an echo when valid, 1002
+  // otherwise.
   std::vector<std::pair<std::string, std::string>> cases = {
       {std::string("\x03\xe8") + "bye", std::string("\x03\xe8") + "bye"},
       {std::string("\x03"), std::string("\x03\xea")},         // 1 byte