Compare commits

...

9 Commits

Author SHA1 Message Date
yhirose 79d83feb18 Fix WebSocketClient dropping query string from URL during handshake (#2468)
The constructor stored only uc.path in path_, discarding uc.query, so the
WebSocket upgrade handshake sent the Request-URI without the query string.
Append the query to path_ so query parameters (e.g. auth tokens) are sent.
2026-06-07 17:08:11 -04:00
yhirose fe56a07da5 Wait for in-progress CI runs before releasing
The release check treated runs with an empty conclusion as failures.
Now it inspects each run's status and aborts with an error if any CI
check is still running, so releases wait until CI completes.
2026-06-06 13:38:36 -04:00
Kim, Hyuk 907257f51d add set_hostname_addr_map to WebSocketClient (#2463)
* add set_hostname_addr_map to WebSocketClient

* add WebSocketTest unit test cases
* SpecifyServerIPAddress_AnotherHostname
* SpecifyServerIPAddress_RealHostname

* Change wrong_ip from 0.0.0.0 to 192.0.2.1

Use 192.0.2.1 (RFC 5737 documentation address) to ensure it acts
as a non-routable address and does not alias to loopback.

* Fix style check

* set short timeout in WebSocketTest.SpecifyServerIPAddress_RealHostname

cannot reach wrong_ip
2026-06-05 16:34:20 -04:00
yhirose 0c2f535b74 Fix #2467 2026-06-04 21:20:37 -04:00
Florian Fischer c7ba963a17 Ignore ranges for unknown-length streams (#2465) 2026-06-04 20:15:21 -04:00
yhirose 4465e81b9f Fix #2464 2026-06-03 22:24:52 -04:00
yhirose 44215e23e9 Release v0.46.1 2026-06-01 12:24:27 -04:00
yhirose 91219d4508 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`.
2026-05-29 06:19:40 -04:00
NsPro04 c86c192f3e Fix: (#2459)
"httplib.h(5733,29): warning : missing field 'InternalHigh' initializer [-Wmissing-field-initializers]"
"httplib.h(5742,28): warning : missing field 'ai_family' initializer [-Wmissing-field-initializers]"
2026-05-28 18:19:33 -04:00
4 changed files with 347 additions and 18 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.46.0"
version = "0.46.1"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+66 -14
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.46.0"
#define CPPHTTPLIB_VERSION_NUM "0x002e00"
#define CPPHTTPLIB_VERSION "0.46.1"
#define CPPHTTPLIB_VERSION_NUM "0x002e01"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -1643,6 +1643,8 @@ public:
using Expect100ContinueHandler =
std::function<int(const Request &, Response &)>;
using StartHandler = std::function<void()>;
using WebSocketHandler =
std::function<void(const Request &, ws::WebSocket &)>;
using SubProtocolSelector =
@@ -1694,6 +1696,9 @@ public:
Server &set_pre_request_handler(HandlerWithResponse handler);
Server &set_expect_100_continue_handler(Expect100ContinueHandler handler);
Server &set_start_handler(StartHandler handler);
Server &set_logger(Logger logger);
Server &set_pre_compression_logger(Logger logger);
Server &set_error_logger(ErrorLogger error_logger);
@@ -1883,6 +1888,7 @@ private:
Handler post_routing_handler_;
HandlerWithResponse pre_request_handler_;
Expect100ContinueHandler expect_100_continue_handler_;
StartHandler start_handler_;
mutable std::mutex logger_mutex_;
Logger logger_;
@@ -3842,6 +3848,7 @@ public:
void set_socket_options(SocketOptions socket_options);
void set_connection_timeout(time_t sec, time_t usec = 0);
void set_interface(const std::string &intf);
void set_hostname_addr_map(std::map<std::string, std::string> addr_map);
#ifdef CPPHTTPLIB_SSL_ENABLED
void set_ca_cert_path(const std::string &path);
@@ -3876,6 +3883,9 @@ private:
time_t connection_timeout_usec_ = CPPHTTPLIB_CONNECTION_TIMEOUT_USECOND;
std::string interface_;
// Hostname-IP map
std::map<std::string, std::string> addr_map_;
#ifdef CPPHTTPLIB_SSL_ENABLED
bool is_ssl_ = false;
tls::ctx_t tls_ctx_ = nullptr;
@@ -4629,7 +4639,7 @@ inline std::string sha1(const std::string &input) {
// Pre-processing: adding padding bits
std::string msg = input;
uint64_t original_bit_len = static_cast<uint64_t>(msg.size()) * 8;
msg.push_back(static_cast<char>(0x80));
msg.push_back(static_cast<char>(0x80u));
while (msg.size() % 64 != 56) {
msg.push_back(0);
}
@@ -5730,7 +5740,7 @@ inline int getaddrinfo_with_timeout(const char *node, const char *service,
#ifdef _WIN32
// Windows-specific implementation using GetAddrInfoEx with overlapped I/O
OVERLAPPED overlapped = {0};
OVERLAPPED overlapped = {};
HANDLE event = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!event) { return EAI_FAIL; }
@@ -5739,7 +5749,7 @@ inline int getaddrinfo_with_timeout(const char *node, const char *service,
PADDRINFOEXW result_addrinfo = nullptr;
HANDLE cancel_handle = nullptr;
ADDRINFOEXW hints_ex = {0};
ADDRINFOEXW hints_ex = {};
if (hints) {
hints_ex.ai_flags = hints->ai_flags;
hints_ex.ai_family = hints->ai_family;
@@ -8443,6 +8453,14 @@ inline void coalesce_ranges(Ranges &ranges, size_t content_length) {
inline bool range_error(Request &req, Response &res) {
if (!req.ranges.empty() && 200 <= res.status && res.status < 300) {
if (res.body.empty() && res.content_provider_ && res.content_length_ == 0) {
req.ranges.clear();
if (res.status == StatusCode::PartialContent_206) {
res.status = StatusCode::OK_200;
}
return false;
}
ssize_t content_len = static_cast<ssize_t>(
res.content_length_ ? res.content_length_ : res.body.size());
@@ -11092,6 +11110,11 @@ Server::set_expect_100_continue_handler(Expect100ContinueHandler handler) {
return *this;
}
inline Server &Server::set_start_handler(StartHandler handler) {
start_handler_ = std::move(handler);
return *this;
}
inline Server &Server::set_address_family(int family) {
address_family_ = family;
return *this;
@@ -11787,6 +11810,8 @@ inline bool Server::listen_internal() {
is_running_ = true;
auto se = detail::scope_exit([&]() { is_running_ = false; });
if (start_handler_) { start_handler_(); }
{
std::unique_ptr<TaskQueue> task_queue(new_task_queue());
@@ -13810,13 +13835,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 +13864,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);
@@ -20225,6 +20266,7 @@ inline WebSocketClient::WebSocketClient(
if (!uc.port.empty() && !detail::parse_port(uc.port, port_)) { return; }
path_ = std::move(uc.path);
if (!uc.query.empty()) { path_ += uc.query; }
#ifdef CPPHTTPLIB_SSL_ENABLED
is_ssl_ = is_ssl;
@@ -20289,9 +20331,14 @@ inline bool WebSocketClient::connect() {
if (!is_valid_) { return false; }
shutdown_and_close();
// Check is custom IP specified for host_
std::string ip;
auto it = addr_map_.find(host_);
if (it != addr_map_.end()) { ip = it->second; }
Error error;
sock_ = detail::create_client_socket(
host_, std::string(), port_, address_family_, tcp_nodelay_, ipv6_v6only_,
host_, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_,
socket_options_, connection_timeout_sec_, connection_timeout_usec_,
read_timeout_sec_, read_timeout_usec_, write_timeout_sec_,
write_timeout_usec_, interface_, error);
@@ -20386,6 +20433,11 @@ inline void WebSocketClient::set_interface(const std::string &intf) {
interface_ = intf;
}
inline void WebSocketClient::set_hostname_addr_map(
std::map<std::string, std::string> addr_map) {
addr_map_ = std::move(addr_map);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
inline void WebSocketClient::set_ca_cert_path(const std::string &path) {
+18 -3
View File
@@ -62,7 +62,7 @@ HEAD_SHORT=$(git rev-parse --short HEAD)
echo " Latest commit: $HEAD_SHORT"
# Fetch all workflow runs for the HEAD commit
RUNS=$(gh run list --commit "$HEAD_SHA" --json name,conclusion,headSha)
RUNS=$(gh run list --commit "$HEAD_SHA" --json name,status,conclusion,headSha)
NUM_RUNS=$(echo "$RUNS" | jq 'length')
@@ -75,8 +75,17 @@ fi
echo " Found $NUM_RUNS workflow run(s):"
FAILED=0
RUNNING=0
ABIDIFF_PASSED=0
while IFS=$'\t' read -r name conclusion; do
while IFS=$'\t' read -r name status conclusion; do
# A run that hasn't completed yet has an empty conclusion; don't treat it
# as a failure — the release should wait until CI finishes.
if [ "$status" != "completed" ]; then
echo " [ .. ] $name (still running)"
RUNNING=1
continue
fi
if [[ "$name" == *abidiff* ]] || [[ "$name" == *abi* && "$name" != *stability* ]]; then
if [ "$conclusion" = "success" ]; then
echo " [ OK ] $name"
@@ -94,7 +103,13 @@ while IFS=$'\t' read -r name conclusion; do
echo " [FAIL] $name ($conclusion)"
FAILED=1
fi
done < <(echo "$RUNS" | jq -r '.[] | [.name, .conclusion] | @tsv')
done < <(echo "$RUNS" | jq -r '.[] | [.name, .status, .conclusion] | @tsv')
if [ "$RUNNING" -eq 1 ]; then
echo ""
echo "Error: Some CI checks are still running. Wait for them to complete before releasing."
exit 1
fi
if [ "$FAILED" -eq 1 ]; then
echo ""
+262
View File
@@ -810,6 +810,41 @@ TEST(ParseAcceptHeaderTest, ContentTypesPopulatedAndInvalidHeaderHandling) {
}
}
TEST(ServerStartHandlerTest, CalledOnceWhenReady) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
std::atomic<int> start_count{0};
std::atomic<bool> running_when_called{false};
svr.set_start_handler([&]() {
running_when_called = svr.is_running();
start_count++;
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// A successful request proves the accept loop is running, which the start
// handler precedes; so by now the handler must have run exactly once.
Client cli(HOST, port);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res = cli.Get("/");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1, start_count.load());
EXPECT_TRUE(running_when_called.load());
}
TEST(DivideTest, DivideStringTests) {
auto divide = [](const std::string &str, char d) {
std::string lhs;
@@ -3718,6 +3753,23 @@ protected:
return true;
});
})
.Get("/streamed-without-length",
[&](const Request & /*req*/, Response &res) {
auto data = new std::string("abcdefg");
res.set_content_provider(
"text/plain",
[data](size_t offset, DataSink &sink) {
if (offset < data->size()) {
sink.os << data->substr(offset);
}
sink.done();
return true;
},
[data](bool success) {
EXPECT_TRUE(success);
delete data;
});
})
.Get("/streamed-with-range",
[&](const Request &req, Response &res) {
auto data = new std::string("abcdefg");
@@ -5197,6 +5249,16 @@ TEST_F(ServerTest, GetStreamed) {
EXPECT_EQ(std::string("aaabbb"), res->body);
}
TEST_F(ServerTest, GetStreamedWithoutLengthWithRange) {
auto res =
cli_.Get("/streamed-without-length", {make_range_header({{0, -1}})});
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(false, res->has_header("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(std::string("abcdefg"), res->body);
}
TEST_F(ServerTest, GetStreamedWithRange1) {
auto res = cli_.Get("/streamed-with-range", {{make_range_header({{3, 5}})}});
ASSERT_TRUE(res);
@@ -13675,6 +13737,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) {
@@ -17540,6 +17706,57 @@ TEST(WebSocketTest, ComplexPath) {
EXPECT_TRUE(ws2.is_valid());
}
TEST(WebSocketTest, SpecifyServerIPAddress_AnotherHostname) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
auto another_host = "example.com";
auto wrong_ip = "192.0.2.1";
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
client.set_hostname_addr_map({{another_host, wrong_ip}});
ASSERT_TRUE(client.connect());
EXPECT_TRUE(client.is_open());
client.close();
svr.stop();
t.join();
}
TEST(WebSocketTest, SpecifyServerIPAddress_RealHostname) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
auto wrong_ip = "192.0.2.1";
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
client.set_hostname_addr_map({{"localhost", wrong_ip}});
client.set_connection_timeout(1);
client.set_read_timeout(1);
client.set_write_timeout(1);
EXPECT_FALSE(client.connect());
EXPECT_FALSE(client.is_open());
svr.stop();
t.join();
}
class WebSocketIntegrationTest : public ::testing::Test {
protected:
void SetUp() override {
@@ -17974,6 +18191,51 @@ TEST(WebSocketPreRoutingTest, RejectWithoutAuth) {
t.join();
}
TEST(WebSocketTest, QueryStringInHandshake) {
Server svr;
std::mutex mtx;
std::string received_target;
std::string received_token;
svr.WebSocket("/ws", [&](const Request &req, ws::WebSocket &ws) {
{
std::lock_guard<std::mutex> lock(mtx);
received_target = req.target;
if (req.has_param("token")) {
received_token = req.get_param_value("token");
}
}
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) +
"/ws?token=ABC&session=123");
ASSERT_TRUE(client.connect());
// Round-trip ensures the handler has run and captured the request.
ASSERT_TRUE(client.send("hello"));
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
{
std::lock_guard<std::mutex> lock(mtx);
EXPECT_EQ("/ws?token=ABC&session=123", received_target);
EXPECT_EQ("ABC", received_token);
}
svr.stop();
t.join();
}
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
class WebSocketSSLIntegrationTest : public ::testing::Test {
protected: