diff --git a/httplib.h b/httplib.h index a3d44c9..9f98985 100644 --- a/httplib.h +++ b/httplib.h @@ -20539,6 +20539,11 @@ inline WebSocketClient::~WebSocketClient() { inline bool WebSocketClient::is_valid() const { return is_valid_; } inline void WebSocketClient::shutdown_and_close() { + // Send the close frame while the TLS session is still alive: ws_ holds an + // SSLSocketStream that keeps a raw pointer to tls_session_, so the session + // must outlive ws_->close() and ws_.reset() to avoid a use-after-free. + if (ws_ && ws_->is_open()) { ws_->close(); } + ws_.reset(); #ifdef CPPHTTPLIB_SSL_ENABLED if (is_ssl_) { if (tls_session_) { @@ -20548,8 +20553,6 @@ inline void WebSocketClient::shutdown_and_close() { } } #endif - if (ws_ && ws_->is_open()) { ws_->close(); } - ws_.reset(); if (sock_ != INVALID_SOCKET) { detail::shutdown_socket(sock_); detail::close_socket(sock_); diff --git a/test/test.cc b/test/test.cc index 1410438..4b6c3c0 100644 --- a/test/test.cc +++ b/test/test.cc @@ -19271,6 +19271,64 @@ TEST_F(WebSocketSSLIntegrationTest, TextEcho) { client.close(); } + +// Regression test (GHSA-w7p7-f35j-mw7q): shutdown_and_close() used to free the +// TLS session before ws_->close() sent the close frame. Because the WebSocket's +// SSLSocketStream keeps a raw pointer to that session, sending the close frame +// then read/wrote a freed SSL object (use-after-free). Destroying an open wss +// client (without calling close() first) is the only path that runs +// shutdown_and_close() while the WebSocket is still open, so it reproduces the +// bug exactly. +// +// NOTE: the offending read/write happens inside OpenSSL, so ASan only flags it +// when linked against an instrumented libssl; run under Valgrind to catch it +// with a stock OpenSSL. The short timeouts keep a regression from wedging the +// suite (the freed session otherwise stalls on the close handshake) — this test +// is a memory-tool tripwire, not a pass/fail assertion on stock builds. +TEST_F(WebSocketSSLIntegrationTest, DestroyWhileOpenSendsCloseOverLiveSession) { + { + ws::WebSocketClient client("wss://localhost:" + std::to_string(port_) + + "/ws-echo"); + client.enable_server_certificate_verification(false); + client.set_read_timeout(2, 0); + client.set_write_timeout(2, 0); + ASSERT_TRUE(client.connect()); + ASSERT_TRUE(client.is_open()); + ASSERT_TRUE(client.send("still open")); + // Intentionally do NOT call client.close(): leaving scope runs + // shutdown_and_close() with the WebSocket still open, which must send the + // close frame while the TLS session is still alive. + } +} + +// Regression test (GHSA-w7p7-f35j-mw7q): reconnecting an open wss client runs +// shutdown_and_close() at the start of connect(), reproducing the same +// use-after-free within the test body rather than at scope exit. The second +// connect must succeed and echo, proving the close handshake ran over a live +// session. See the note above about memory-tool requirements. +TEST_F(WebSocketSSLIntegrationTest, + ReconnectWhileOpenSendsCloseOverLiveSession) { + ws::WebSocketClient client("wss://localhost:" + std::to_string(port_) + + "/ws-echo"); + client.enable_server_certificate_verification(false); + client.set_read_timeout(2, 0); + client.set_write_timeout(2, 0); + + ASSERT_TRUE(client.connect()); + ASSERT_TRUE(client.is_open()); + ASSERT_TRUE(client.send("still open")); + + // Reconnect without closing first: connect() calls shutdown_and_close() on + // the still-open connection, which must not touch a freed TLS session. + ASSERT_TRUE(client.connect()); + ASSERT_TRUE(client.is_open()); + ASSERT_TRUE(client.send("after reconnect")); + std::string msg; + EXPECT_EQ(ws::Text, client.read(msg)); + EXPECT_EQ("after reconnect", msg); + + client.close(); +} #endif #ifdef CPPHTTPLIB_SSL_ENABLED