Compare commits

..

4 Commits

Author SHA1 Message Date
yhirose 28f8264d13 Release v0.45.0 2026-05-15 09:22:11 +09:00
yhirose 91271c062d Fix keep-alive corruption on requests without framed body (#2450) 2026-05-15 06:57:51 +09:00
yhirose d755c43d58 Extract has_framed_body and is_connection_persistent helpers 2026-05-15 06:56:16 +09:00
yhirose 5c9285776e Fix crash on empty X-Forwarded-For with trusted proxies configured 2026-05-14 23:19:36 +09:00
3 changed files with 143 additions and 35 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.44.0"
version = "0.45.0"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+35 -34
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.44.0"
#define CPPHTTPLIB_VERSION_NUM "0x002c00"
#define CPPHTTPLIB_VERSION "0.45.0"
#define CPPHTTPLIB_VERSION_NUM "0x002d00"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -8578,17 +8578,24 @@ write_multipart_ranges_data(Stream &strm, const Request &req, Response &res,
});
}
inline bool has_framed_body(const Request &req) {
return is_chunked_transfer_encoding(req.headers) ||
req.get_header_value_u64("Content-Length") > 0;
}
inline bool is_connection_persistent(const Request &req) {
auto conn = req.get_header_value("Connection");
if (conn == "close") { return false; }
if (req.version == "HTTP/1.0" && conn != "Keep-Alive") { return false; }
return true;
}
inline bool expect_content(const Request &req) {
if (req.method == "POST" || req.method == "PUT" || req.method == "PATCH" ||
req.method == "DELETE") {
return true;
}
if (req.has_header("Content-Length") &&
req.get_header_value_u64("Content-Length") > 0) {
return true;
}
if (is_chunked_transfer_encoding(req.headers)) { return true; }
return false;
return has_framed_body(req);
}
#ifdef _WIN32
@@ -11304,29 +11311,18 @@ inline bool Server::read_content_core(
size_t /*len*/) { return receiver(buf, n); };
}
// RFC 7230 Section 3.3.3: If this is a request message and none of the above
// are true (no Transfer-Encoding and no Content-Length), then the message
// body length is zero (no message body is present).
//
// For non-SSL builds, detect clients that send a body without a
// Content-Length header (raw HTTP over TCP). Check both the stream's
// internal read buffer (data already read from the socket during header
// parsing) and the socket itself for pending data. If data is found and
// exceeds the configured payload limit, reject with 413.
// For SSL builds we cannot reliably peek the decrypted application bytes,
// so keep the original behaviour.
// RFC 9112 §6: no Transfer-Encoding and no Content-Length means no body.
// For non-SSL builds we still scan non-persistent connections for stray
// body bytes so the payload limit is enforced (413). On keep-alive,
// pending bytes may be the next request (issue #2450), so skip.
#if !defined(CPPHTTPLIB_SSL_ENABLED)
if (!req.has_header("Content-Length") &&
!detail::is_chunked_transfer_encoding(req.headers)) {
// Only check if payload_max_length is set to a finite value
if (payload_max_length_ > 0 &&
if (!detail::is_connection_persistent(req) && payload_max_length_ > 0 &&
payload_max_length_ < (std::numeric_limits<size_t>::max)()) {
// Check if there is data already buffered in the stream (read during
// header parsing) or pending on the socket. Use a non-blocking socket
// check to avoid deadlock when the client sends no body.
bool has_data = strm.is_readable();
auto has_data = strm.is_readable();
if (!has_data) {
socket_t s = strm.socket();
auto s = strm.socket();
if (s != INVALID_SOCKET) {
has_data = detail::select_read(s, 0, 0) > 0;
}
@@ -11888,6 +11884,11 @@ get_client_ip(const std::string &x_forwarded_for,
ip_list.emplace_back(std::string(b + r.first, b + r.second));
});
// A malformed X-Forwarded-For (empty, comma-only, whitespace-only) yields
// no segments. Signal "no client IP derived" with an empty string so the
// caller can fall back to the connection-level remote address.
if (ip_list.empty()) { return std::string(); }
for (size_t i = 0; i < ip_list.size(); ++i) {
auto ip = ip_list[i];
@@ -11978,7 +11979,8 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
if (!trusted_proxies_.empty() && req.has_header("X-Forwarded-For")) {
auto x_forwarded_for = req.get_header_value("X-Forwarded-For");
req.remote_addr = get_client_ip(x_forwarded_for, trusted_proxies_);
auto derived = get_client_ip(x_forwarded_for, trusted_proxies_);
req.remote_addr = derived.empty() ? remote_addr : derived;
} else {
req.remote_addr = remote_addr;
}
@@ -12180,15 +12182,14 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
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
// Drain any unconsumed framed body to prevent request smuggling on
// keep-alive. Without framing there is no body to drain — reading would
// consume the next request (issue #2450).
if (!req.body_consumed_ && detail::has_framed_body(req)) {
int dummy_status;
if (!detail::read_content(
strm, req, payload_max_length_, drain_status, nullptr,
strm, req, payload_max_length_, dummy_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;
}
}
+107
View File
@@ -14251,6 +14251,53 @@ TEST(ForwardedHeadersTest, HandlesWhitespaceAroundIPs) {
EXPECT_EQ(observed_remote_addr, "203.0.113.66");
}
// An X-Forwarded-For header whose value parses to zero IP segments must not
// crash the server (it used to call front() on an empty vector inside
// get_client_ip). The connection-level remote address must be retained instead.
static void run_malformed_xff_test(const std::string &xff_value) {
Server svr;
svr.set_trusted_proxies({"192.0.2.45"});
std::string observed_remote_addr;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
res.set_content("ok", "text/plain");
});
int port = 0;
thread t = thread([&]() {
port = svr.bind_to_any_port(HOST);
svr.listen_after_bind();
});
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Get("/ip", {{"X-Forwarded-For", xff_value}});
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_TRUE(observed_remote_addr == "::1" ||
observed_remote_addr == "127.0.0.1");
}
TEST(ForwardedHeadersTest, EmptyXForwardedFor_DoesNotCrash) {
run_malformed_xff_test("");
}
TEST(ForwardedHeadersTest, CommaOnlyXForwardedFor_DoesNotCrash) {
run_malformed_xff_test(",");
}
TEST(ForwardedHeadersTest, MultipleCommasXForwardedFor_DoesNotCrash) {
run_malformed_xff_test(", , ,");
}
#ifndef _WIN32
TEST(ServerRequestParsingTest, RequestWithoutContentLengthOrTransferEncoding) {
Server svr;
@@ -18157,3 +18204,63 @@ TEST(RequestSmugglingTest, ContentLengthAndTransferEncodingRejected) {
response.substr(0, response.find("\r\n")));
}
}
// Regression for issue #2450: a DELETE without Content-Length on a
// keep-alive connection must not let the post-response drain consume the
// next request's bytes.
TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) {
Server svr;
std::atomic<int> delete_count(0);
svr.Delete("/items/:id", [&](const Request &, Response &res) {
delete_count++;
res.status = StatusCode::NoContent_204;
});
auto port = svr.bind_to_any_port(HOST);
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(
HOST, "", 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); });
auto send_request_and_read_response = [&](const std::string &req,
std::string &out) -> bool {
auto sent = send(sock, req.data(), req.size(), 0);
if (sent != static_cast<ssize_t>(req.size())) { return false; }
char buf[4096];
for (;;) {
auto n = recv(sock, buf, sizeof(buf), 0);
if (n <= 0) { return !out.empty(); }
out.append(buf, static_cast<size_t>(n));
if (out.find("\r\n\r\n") != std::string::npos) { return true; }
}
};
std::string req1 = "DELETE /items/1 HTTP/1.1\r\n"
"Host: localhost\r\n"
"\r\n";
std::string resp1;
ASSERT_TRUE(send_request_and_read_response(req1, resp1));
EXPECT_NE(std::string::npos, resp1.find("HTTP/1.1 204"));
std::string req2 = "DELETE /items/2 HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n";
std::string resp2;
ASSERT_TRUE(send_request_and_read_response(req2, resp2));
EXPECT_NE(std::string::npos, resp2.find("HTTP/1.1 204"));
EXPECT_EQ(2, delete_count.load());
}