Compare commits

...

5 Commits

Author SHA1 Message Date
yhirose b6c55c6030 Release v0.22.0 2025-06-24 08:01:53 -04:00
yhirose 28dcf379e8 Merge commit from fork 2025-06-24 07:56:00 -04:00
yhirose 91e79e9a63 Fix #1777 (#2160)
* Add benchmark unit test

* Update

* Update

* Update

* Change the default value of CPPHTTPLIB_IDLE_INTERVAL_USECOND to 1ms
2025-06-24 07:44:10 -04:00
yhirose 27879c4874 Fix #2157 (#2158)
* Fix #2157

* Fix Windows build error: wrap std::max in parentheses to avoid macro conflict

- On Windows, max/min are often defined as macros by windows.h
- This causes compilation errors with std::max/std::min
- Solution: use (std::max) to prevent macro expansion
- Fixes CI build error: error C2589: '(': illegal token on right side of '::'

Fixes: error in coalesce_ranges function on line 5376
2025-06-23 08:35:56 -04:00
DSiekmeier 08a0452fb2 Update README.md (#2156)
According to the curl manpage, the option is called "--max-time".
2025-06-18 07:05:23 -04:00
3 changed files with 278 additions and 20 deletions
+1 -1
View File
@@ -671,7 +671,7 @@ cli.set_connection_timeout(0, 300000); // 300 milliseconds
cli.set_read_timeout(5, 0); // 5 seconds
cli.set_write_timeout(5, 0); // 5 seconds
// This method works the same as curl's `--max-timeout` option
// This method works the same as curl's `--max-time` option
svr.set_max_timeout(5000); // 5 seconds
```
+87 -12
View File
@@ -8,7 +8,7 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.21.0"
#define CPPHTTPLIB_VERSION "0.22.0"
/*
* Configuration
@@ -76,7 +76,7 @@
#ifndef CPPHTTPLIB_IDLE_INTERVAL_USECOND
#ifdef _WIN32
#define CPPHTTPLIB_IDLE_INTERVAL_USECOND 10000
#define CPPHTTPLIB_IDLE_INTERVAL_USECOND 1000
#else
#define CPPHTTPLIB_IDLE_INTERVAL_USECOND 0
#endif
@@ -90,6 +90,10 @@
#define CPPHTTPLIB_HEADER_MAX_LENGTH 8192
#endif
#ifndef CPPHTTPLIB_HEADER_MAX_COUNT
#define CPPHTTPLIB_HEADER_MAX_COUNT 100
#endif
#ifndef CPPHTTPLIB_REDIRECT_MAX_COUNT
#define CPPHTTPLIB_REDIRECT_MAX_COUNT 20
#endif
@@ -4355,6 +4359,8 @@ inline bool read_headers(Stream &strm, Headers &headers) {
char buf[bufsiz];
stream_line_reader line_reader(strm, buf, bufsiz);
size_t header_count = 0;
for (;;) {
if (!line_reader.getline()) { return false; }
@@ -4375,6 +4381,9 @@ inline bool read_headers(Stream &strm, Headers &headers) {
if (line_reader.size() > CPPHTTPLIB_HEADER_MAX_LENGTH) { return false; }
// Check header count limit
if (header_count >= CPPHTTPLIB_HEADER_MAX_COUNT) { return false; }
// Exclude line terminator
auto end = line_reader.ptr() + line_reader.size() - line_terminator_len;
@@ -4384,6 +4393,8 @@ inline bool read_headers(Stream &strm, Headers &headers) {
})) {
return false;
}
header_count++;
}
return true;
@@ -4486,9 +4497,13 @@ inline bool read_content_chunked(Stream &strm, T &x,
// chunked transfer coding data without the final CRLF.
if (!line_reader.getline()) { return true; }
size_t trailer_header_count = 0;
while (strcmp(line_reader.ptr(), "\r\n") != 0) {
if (line_reader.size() > CPPHTTPLIB_HEADER_MAX_LENGTH) { return false; }
// Check trailer header count limit
if (trailer_header_count >= CPPHTTPLIB_HEADER_MAX_COUNT) { return false; }
// Exclude line terminator
constexpr auto line_terminator_len = 2;
auto end = line_reader.ptr() + line_reader.size() - line_terminator_len;
@@ -4498,6 +4513,8 @@ inline bool read_content_chunked(Stream &strm, T &x,
x.headers.emplace(key, val);
});
trailer_header_count++;
if (!line_reader.getline()) { return false; }
}
@@ -5329,13 +5346,68 @@ serialize_multipart_formdata(const MultipartFormDataItems &items,
return body;
}
inline void coalesce_ranges(Ranges &ranges, size_t content_length) {
if (ranges.size() <= 1) return;
// Sort ranges by start position
std::sort(ranges.begin(), ranges.end(),
[](const Range &a, const Range &b) { return a.first < b.first; });
Ranges coalesced;
coalesced.reserve(ranges.size());
for (auto &r : ranges) {
auto first_pos = r.first;
auto last_pos = r.second;
// Handle special cases like in range_error
if (first_pos == -1 && last_pos == -1) {
first_pos = 0;
last_pos = static_cast<ssize_t>(content_length);
}
if (first_pos == -1) {
first_pos = static_cast<ssize_t>(content_length) - last_pos;
last_pos = static_cast<ssize_t>(content_length) - 1;
}
if (last_pos == -1 || last_pos >= static_cast<ssize_t>(content_length)) {
last_pos = static_cast<ssize_t>(content_length) - 1;
}
// Skip invalid ranges
if (!(0 <= first_pos && first_pos <= last_pos &&
last_pos < static_cast<ssize_t>(content_length))) {
continue;
}
// Coalesce with previous range if overlapping or adjacent (but not
// identical)
if (!coalesced.empty()) {
auto &prev = coalesced.back();
// Check if current range overlaps or is adjacent to previous range
// but don't coalesce identical ranges (allow duplicates)
if (first_pos <= prev.second + 1 &&
!(first_pos == prev.first && last_pos == prev.second)) {
// Extend the previous range
prev.second = (std::max)(prev.second, last_pos);
continue;
}
}
// Add new range
coalesced.emplace_back(first_pos, last_pos);
}
ranges = std::move(coalesced);
}
inline bool range_error(Request &req, Response &res) {
if (!req.ranges.empty() && 200 <= res.status && res.status < 300) {
ssize_t content_len = static_cast<ssize_t>(
res.content_length_ ? res.content_length_ : res.body.size());
ssize_t prev_first_pos = -1;
ssize_t prev_last_pos = -1;
std::vector<std::pair<ssize_t, ssize_t>> processed_ranges;
size_t overwrapping_count = 0;
// NOTE: The following Range check is based on '14.2. Range' in RFC 9110
@@ -5378,18 +5450,21 @@ inline bool range_error(Request &req, Response &res) {
return true;
}
// Ranges must be in ascending order
if (first_pos <= prev_first_pos) { return true; }
// Request must not have more than two overlapping ranges
if (first_pos <= prev_last_pos) {
overwrapping_count++;
if (overwrapping_count > 2) { return true; }
for (const auto &processed_range : processed_ranges) {
if (!(last_pos < processed_range.first ||
first_pos > processed_range.second)) {
overwrapping_count++;
if (overwrapping_count > 2) { return true; }
break; // Only count once per range
}
}
prev_first_pos = (std::max)(prev_first_pos, first_pos);
prev_last_pos = (std::max)(prev_last_pos, last_pos);
processed_ranges.emplace_back(first_pos, last_pos);
}
// After validation, coalesce overlapping ranges as per RFC 9110
coalesce_ranges(req.ranges, static_cast<size_t>(content_len));
}
return false;
+190 -7
View File
@@ -3,7 +3,11 @@
#include <signal.h>
#ifndef _WIN32
#include <arpa/inet.h>
#include <curl/curl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#endif
#include <gtest/gtest.h>
@@ -3155,6 +3159,47 @@ TEST_F(ServerTest, GetMethod200) {
EXPECT_EQ("Hello World!", res->body);
}
TEST(BenchmarkTest, SimpleGetPerformance) {
Server svr;
svr.Get("/benchmark", [&](const Request & /*req*/, Response &res) {
res.set_content("Benchmark Response", "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
const int NUM_REQUESTS = 50;
const int MAX_AVERAGE_MS = 5;
auto warmup = cli.Get("/benchmark");
ASSERT_TRUE(warmup);
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < NUM_REQUESTS; ++i) {
auto res = cli.Get("/benchmark");
ASSERT_TRUE(res) << "Request " << i << " failed";
EXPECT_EQ(StatusCode::OK_200, res->status);
}
auto end = std::chrono::high_resolution_clock::now();
auto total_ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
double avg_ms = static_cast<double>(total_ms) / NUM_REQUESTS;
std::cout << "Standalone: " << NUM_REQUESTS << " requests in " << total_ms
<< "ms (avg: " << avg_ms << "ms)" << std::endl;
EXPECT_LE(avg_ms, MAX_AVERAGE_MS) << "Standalone test too slow: " << avg_ms << "ms (Issue #1777)";
}
TEST_F(ServerTest, GetEmptyFile) {
auto res = cli_.Get("/empty_file");
ASSERT_TRUE(res);
@@ -3782,6 +3827,50 @@ TEST_F(ServerTest, TooLongHeader) {
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, HeaderCountAtLimit) {
// Test with headers just under the 100 limit
httplib::Headers headers;
// Add 95 custom headers (the client will add Host, User-Agent, Accept, etc.)
// This should keep us just under the 100 header limit
for (int i = 0; i < 95; i++) {
std::string name = "X-Test-Header-" + std::to_string(i);
std::string value = "value" + std::to_string(i);
headers.emplace(name, value);
}
// This should work fine as we're under the limit
auto res = cli_.Get("/hi", headers);
EXPECT_TRUE(res);
if (res) {
EXPECT_EQ(StatusCode::OK_200, res->status);
}
}
TEST_F(ServerTest, HeaderCountExceedsLimit) {
// Test with many headers to exceed the 100 limit
httplib::Headers headers;
// Add 150 headers to definitely exceed the 100 limit
for (int i = 0; i < 150; i++) {
std::string name = "X-Test-Header-" + std::to_string(i);
std::string value = "value" + std::to_string(i);
headers.emplace(name, value);
}
// This should fail due to exceeding header count limit
auto res = cli_.Get("/hi", headers);
// The request should either fail or return 400 Bad Request
if (res) {
// If we get a response, it should be 400 Bad Request
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
} else {
// Or the request should fail entirely
EXPECT_FALSE(res);
}
}
TEST_F(ServerTest, PercentEncoding) {
auto res = cli_.Get("/e%6edwith%");
ASSERT_TRUE(res);
@@ -3819,6 +3908,32 @@ TEST_F(ServerTest, PlusSignEncoding) {
EXPECT_EQ("a +b", res->body);
}
TEST_F(ServerTest, HeaderCountSecurityTest) {
// This test simulates a potential DoS attack using many headers
// to verify our security fix prevents memory exhaustion
httplib::Headers attack_headers;
// Attempt to add many headers like an attacker would (200 headers to far exceed limit)
for (int i = 0; i < 200; i++) {
std::string name = "X-Attack-Header-" + std::to_string(i);
std::string value = "attack_payload_" + std::to_string(i);
attack_headers.emplace(name, value);
}
// Try to POST with excessive headers
auto res = cli_.Post("/", attack_headers, "test_data", "text/plain");
// Should either fail or return 400 Bad Request due to security limit
if (res) {
// If we get a response, it should be 400 Bad Request
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
} else {
// Request failed, which is the expected behavior for DoS protection
EXPECT_FALSE(res);
}
}
TEST_F(ServerTest, MultipartFormData) {
MultipartFormDataItems items = {
{"text1", "text default", "", ""},
@@ -3992,6 +4107,16 @@ TEST_F(ServerTest, GetStreamedWithRangeMultipart) {
EXPECT_EQ("267", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(267U, res->body.size());
// Check that both range contents are present
EXPECT_TRUE(res->body.find("bc\r\n") != std::string::npos);
EXPECT_TRUE(res->body.find("ef\r\n") != std::string::npos);
// Check that Content-Range headers are present for both ranges
EXPECT_TRUE(res->body.find("Content-Range: bytes 1-2/7") !=
std::string::npos);
EXPECT_TRUE(res->body.find("Content-Range: bytes 4-5/7") !=
std::string::npos);
}
TEST_F(ServerTest, GetStreamedWithTooManyRanges) {
@@ -4009,14 +4134,59 @@ TEST_F(ServerTest, GetStreamedWithTooManyRanges) {
EXPECT_EQ(0U, res->body.size());
}
TEST_F(ServerTest, GetStreamedWithNonAscendingRanges) {
auto res = cli_.Get("/streamed-with-range?error",
{{make_range_header({{0, -1}, {0, -1}})}});
TEST_F(ServerTest, GetStreamedWithOverwrapping) {
auto res =
cli_.Get("/streamed-with-range", {{make_range_header({{1, 4}, {2, 5}})}});
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
EXPECT_EQ("0", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(0U, res->body.size());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ(5U, res->body.size());
// Check that overlapping ranges are coalesced into a single range
EXPECT_EQ("bcdef", res->body);
EXPECT_EQ("bytes 1-5/7", res->get_header_value("Content-Range"));
// Should be single range, not multipart
EXPECT_TRUE(res->has_header("Content-Range"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
}
TEST_F(ServerTest, GetStreamedWithNonAscendingRanges) {
auto res =
cli_.Get("/streamed-with-range", {{make_range_header({{4, 5}, {0, 2}})}});
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ(268U, res->body.size());
// Check that both range contents are present
EXPECT_TRUE(res->body.find("ef\r\n") != std::string::npos);
EXPECT_TRUE(res->body.find("abc\r\n") != std::string::npos);
// Check that Content-Range headers are present for both ranges
EXPECT_TRUE(res->body.find("Content-Range: bytes 4-5/7") !=
std::string::npos);
EXPECT_TRUE(res->body.find("Content-Range: bytes 0-2/7") !=
std::string::npos);
}
TEST_F(ServerTest, GetStreamedWithDuplicateRanges) {
auto res =
cli_.Get("/streamed-with-range", {{make_range_header({{0, 2}, {0, 2}})}});
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ(269U, res->body.size());
// Check that both duplicate range contents are present
size_t first_abc = res->body.find("abc\r\n");
EXPECT_TRUE(first_abc != std::string::npos);
size_t second_abc = res->body.find("abc\r\n", first_abc + 1);
EXPECT_TRUE(second_abc != std::string::npos);
// Check that Content-Range headers are present for both ranges
size_t first_range = res->body.find("Content-Range: bytes 0-2/7");
EXPECT_TRUE(first_range != std::string::npos);
size_t second_range =
res->body.find("Content-Range: bytes 0-2/7", first_range + 1);
EXPECT_TRUE(second_range != std::string::npos);
}
TEST_F(ServerTest, GetStreamedWithRangesMoreThanTwoOverwrapping) {
@@ -4122,6 +4292,19 @@ TEST_F(ServerTest, GetWithRange4) {
EXPECT_EQ(std::string("fg"), res->body);
}
TEST_F(ServerTest, GetWithRange5) {
auto res = cli_.Get("/with-range", {
make_range_header({{0, 5}}),
{"Accept-Encoding", ""},
});
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("6", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 0-5/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("abcdef"), res->body);
}
TEST_F(ServerTest, GetWithRangeOffsetGreaterThanContent) {
auto res = cli_.Get("/with-range", {{make_range_header({{10000, 20000}})}});
ASSERT_TRUE(res);