Compare commits

...

13 Commits

Author SHA1 Message Date
yhirose 600d220c84 Release v0.43.4 2026-05-09 21:29:23 +09:00
yhirose 87d62db46b Reject malformed chunk-size in chunked decoder
strtoul silently accepts a leading "-" and wraps via unsigned
arithmetic, so chunk-size "-2" produced ULONG_MAX-1, bypassing the
ULONG_MAX guard and letting a client drive the server toward unbounded
allocation.

Replace strtoul with a manual hex parser that requires at least one hex
digit, detects size_t overflow per digit, and accepts only chunk-ext or
end-of-line after the digits (RFC 9112 §7.1).
2026-05-09 16:52:32 +09:00
yhirose a1fdc07f34 Guard nullptr res in KeepAliveTest proxy template (#2443)
When the upstream request to httpbingo.org transiently fails, cli.Get()
returns nullptr and the next line dereferences it (res->status / res->body),
producing a SEGV in std::string::begin() under ASan. Sibling templates in
the same file already use ASSERT_TRUE(res != nullptr); apply the same
guard to the four Get() call sites in KeepAliveTest so a flaky network
turns into a clean test failure instead of a crash.
2026-05-06 08:36:38 -04:00
yhirose eb49a304b6 Use vswhere to locate VS install in 32-bit Windows CI (#2442)
The hosted windows-latest runner is migrating from VS 2022 to VS 2026
(NOTICE: windows-2025 -> windows-2025-vs2026 by 2026-05-12). The
hardcoded path C:\Program Files\Microsoft Visual Studio\2022\Enterprise
no longer exists on the new image, so vcvarsall.bat silently fails and
'cl' is not on PATH.

Resolve the install path via vswhere.exe (stable location, version
agnostic) and exit if vcvarsall.bat fails so future breakage surfaces
immediately instead of as a confusing 'cl not recognized' error.
2026-05-06 08:25:56 -04:00
yhirose a9bfe5914b Fix #2441 2026-05-06 18:44:14 +09:00
yhirose ec5ce17929 Release v0.43.3 2026-05-04 16:19:49 +09:00
yhirose f6524c0802 Drop Str2tagTest unit test that broke split / no-exceptions builds
The test referenced detail::can_compress_content_type, which lives below
the split BORDER in httplib.h and is therefore not visible to test.cc in
test_split / Windows-CMake builds. EXPECT_NO_THROW also expanded to a
try/catch that would not compile under -fno-exceptions. The OSS-Fuzz
reproducer in test/fuzzing/corpus already serves as the regression test
for #508087118 and is exercised by make fuzz_test.
2026-05-01 22:20:41 +09:00
yhirose 35c4026c7f Make fuzz_test robust to missing corpus files
When a glob like clusterfuzz-testcase-minimized-foo_fuzzer-* did not
match anything, bash passed the literal pattern through. The standalone
runner then tried to open it, tellg() returned -1, and the resulting
size_t cast (SIZE_MAX) crashed std::vector with length_error. This made
fuzz_test fail loudly during bisects to commits before a corpus file
landed. Filter each glob through a -f test so unmatched patterns are
silently skipped with a "(no XXX corpus)" notice, mirroring what was
already done for url_parser_fuzzer.
2026-05-01 21:50:26 +09:00
yhirose 40e18460bc Document str2tag_core's compile-time-only role 2026-05-01 21:46:13 +09:00
yhirose 92aecf85d8 Fix OSS-Fuzz #508087118: avoid stack overflow in str2tag
str2tag_core is recursive (one frame per character), so a long runtime
input such as a fuzzer-supplied Content-Type would overflow the stack.
Rewrite the runtime entry point str2tag() iteratively while keeping the
recursive constexpr str2tag_core for compile-time UDL evaluation. The
hash output is unchanged for all inputs.
2026-05-01 21:39:46 +09:00
yhirose b223e29778 Add OSS-Fuzz #508370122 reproducer to client_fuzzer corpus
Same root cause as #508342856 (fixed in 2d2efe4): an oversized
Content-Length value (here 4467440718547775) caused res.body.reserve()
to attempt a multi-petabyte allocation. The UBSAN fuzzer job surfaced
it as a std::bad_alloc-driven abort, while the ASAN job for #508342856
reported it as allocation-size-too-big. The payload_max_length_ cap
introduced in 2d2efe4 already addresses both.
2026-05-01 21:34:03 +09:00
yhirose 2d2efe46da Fix OSS-Fuzz #508342856: cap Content-Length reservation by payload_max_length_
A malicious or malformed server response with an enormous Content-Length
header (e.g. 20000000000) caused the client to call res.body.reserve(len)
with the untrusted value, triggering OOM before read_content's
payload_max_length_ check could take effect. Cap the pre-reservation
at payload_max_length_, since reading more than that is never useful.
2026-05-01 21:28:57 +09:00
yhirose cae753425e Run all fuzzers via make fuzz_test 2026-05-01 21:28:45 +09:00
10 changed files with 197 additions and 18 deletions
+2 -1
View File
@@ -21,7 +21,8 @@ jobs:
- name: Build (Win32)
shell: cmd
run: |
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" x86
for /f "usebackq tokens=*" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath`) do set VSDIR=%%i
call "%VSDIR%\VC\Auxiliary\Build\vcvarsall.bat" x86 || exit /b 1
cl /std:c++14 /EHsc /W4 /WX /c /Fo:NUL test\test_32bit_build.cpp
test-arm32:
+3
View File
@@ -43,6 +43,9 @@ test/test_mbedtls
test/test_wolfssl
test/test_no_tls
test/server_fuzzer
test/client_fuzzer
test/header_parser_fuzzer
test/url_parser_fuzzer
test/test_proxy
test/test_proxy_mbedtls
test/test_proxy_wolfssl
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.43.2"
version = "0.43.4"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+42 -9
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.43.2"
#define CPPHTTPLIB_VERSION_NUM "0x002b02"
#define CPPHTTPLIB_VERSION "0.43.4"
#define CPPHTTPLIB_VERSION_NUM "0x002b04"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -6361,6 +6361,10 @@ inline void get_remote_ip_and_port(socket_t sock, std::string &ip, int &port) {
}
}
// Recursive form retained so operator""_t below can compute hashes for
// switch-case labels at compile time (C++11 constexpr forbids loops). Do not
// call from runtime paths with arbitrary-length inputs — use str2tag()
// instead, which is iterative and stack-safe.
inline constexpr unsigned int str2tag_core(const char *s, size_t l,
unsigned int h) {
return (l == 0)
@@ -6374,7 +6378,16 @@ inline constexpr unsigned int str2tag_core(const char *s, size_t l,
}
inline unsigned int str2tag(const std::string &s) {
return str2tag_core(s.data(), s.size(), 0);
// Iterative form of str2tag_core: the recursive constexpr version is kept
// for compile-time UDL evaluation of short string literals, but at runtime
// we may receive arbitrarily long inputs (e.g. fuzzed Content-Type) that
// would blow the stack with one frame per character.
unsigned int h = 0;
for (auto c : s) {
h = (((std::numeric_limits<unsigned int>::max)() >> 6) & h * 33) ^
static_cast<unsigned char>(c);
}
return h;
}
namespace udl {
@@ -12822,10 +12835,22 @@ inline ssize_t ChunkedDecoder::read_payload(char *buf, size_t len,
stream_line_reader lr(strm, line_buf, sizeof(line_buf));
if (!lr.getline()) { return -1; }
char *endptr = nullptr;
unsigned long chunk_len = std::strtoul(lr.ptr(), &endptr, 16);
if (endptr == lr.ptr()) { return -1; }
if (chunk_len == ULONG_MAX) { return -1; }
// RFC 9112 §7.1: chunk-size = 1*HEXDIG
const char *p = lr.ptr();
int v = 0;
if (!is_hex(*p, v)) { return -1; }
size_t chunk_len = 0;
constexpr size_t chunk_len_max = (std::numeric_limits<size_t>::max)();
for (; is_hex(*p, v); ++p) {
if (chunk_len > (chunk_len_max >> 4)) { return -1; }
chunk_len = (chunk_len << 4) | static_cast<size_t>(v);
}
while (is_space_or_tab(*p)) {
++p;
}
if (*p != '\0' && *p != ';' && *p != '\r' && *p != '\n') { return -1; }
if (chunk_len == 0) {
chunk_remaining = 0;
@@ -12835,7 +12860,7 @@ inline ssize_t ChunkedDecoder::read_payload(char *buf, size_t len,
return 0;
}
chunk_remaining = static_cast<size_t>(chunk_len);
chunk_remaining = chunk_len;
last_chunk_total = chunk_remaining;
last_chunk_offset = 0;
}
@@ -13632,7 +13657,15 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
output_error_log(error, &req);
return false;
}
res.body.reserve(static_cast<size_t>(len));
// Cap the reservation by payload_max_length_ to avoid OOM when a
// hostile or malformed server sends an enormous Content-Length.
// The actual body read below is bounded by payload_max_length_,
// so reserving more than that is never useful.
auto reserve_len = static_cast<size_t>(len);
if (payload_max_length_ > 0 && reserve_len > payload_max_length_) {
reserve_len = payload_max_length_;
}
res.body.reserve(reserve_len);
}
}
+31 -7
View File
@@ -18,8 +18,10 @@ ifneq ($(OS), Windows_NT)
OPENSSL_SUPPORT = -DCPPHTTPLIB_OPENSSL_SUPPORT -lssl -lcrypto
MBEDTLS_SUPPORT = -DCPPHTTPLIB_MBEDTLS_SUPPORT -lmbedtls -lmbedx509 -lmbedcrypto
WOLFSSL_SUPPORT = -DCPPHTTPLIB_WOLFSSL_SUPPORT -lwolfssl
# Disable ASLR for ASAN compatibility on WSL2 (high-entropy ASLR conflicts with ASAN shadow memory)
SETARCH = setarch $(shell uname -m) -R
ifeq ($(UNAME_S), Linux)
# Disable ASLR for ASAN compatibility on WSL2 (high-entropy ASLR conflicts with ASAN shadow memory)
SETARCH = setarch $(shell uname -m) -R
endif
endif
endif
@@ -251,16 +253,38 @@ test_proxy_mbedtls : test_proxy.cc ../httplib.h Makefile cert.pem
test_proxy_wolfssl : test_proxy.cc ../httplib.h Makefile cert.pem
$(CXX) -o $@ -I.. $(CXXFLAGS) test_proxy.cc $(TEST_ARGS_WOLFSSL)
# Runs server_fuzzer.cc based on value of $(LIB_FUZZING_ENGINE).
# Usage: make fuzz_test LIB_FUZZING_ENGINE=/path/to/libFuzzer
fuzz_test: server_fuzzer
./server_fuzzer fuzzing/corpus/*
# Runs all fuzz harnesses based on the value of $(LIB_FUZZING_ENGINE).
# By default LIB_FUZZING_ENGINE is standalone_fuzz_target_runner.o, so each
# fuzzer is replayed over its regression corpus.
# Override for actual fuzzing:
# make fuzz_test LIB_FUZZING_ENGINE=/path/to/libFuzzer
fuzz_test: server_fuzzer client_fuzzer header_parser_fuzzer url_parser_fuzzer
@m=""; for f in fuzzing/corpus/[0-9]* fuzzing/corpus/issue1264 fuzzing/corpus/clusterfuzz-testcase-minimized-server_fuzzer-*; do if [ -f "$$f" ]; then m="$$m $$f"; fi; done; \
if [ -n "$$m" ]; then echo "./server_fuzzer$$m"; ./server_fuzzer $$m; else echo "(no server_fuzzer corpus)"; fi
@m=""; for f in fuzzing/corpus/clusterfuzz-testcase-minimized-client_fuzzer-*; do if [ -f "$$f" ]; then m="$$m $$f"; fi; done; \
if [ -n "$$m" ]; then echo "./client_fuzzer$$m"; ./client_fuzzer $$m; else echo "(no client_fuzzer corpus)"; fi
@m=""; for f in fuzzing/corpus/clusterfuzz-testcase-minimized-header_parser_fuzzer-*; do if [ -f "$$f" ]; then m="$$m $$f"; fi; done; \
if [ -n "$$m" ]; then echo "./header_parser_fuzzer$$m"; ./header_parser_fuzzer $$m; else echo "(no header_parser_fuzzer corpus)"; fi
@m=""; for f in fuzzing/corpus/clusterfuzz-testcase-minimized-url_parser_fuzzer-*; do if [ -f "$$f" ]; then m="$$m $$f"; fi; done; \
if [ -n "$$m" ]; then echo "./url_parser_fuzzer$$m"; ./url_parser_fuzzer $$m; else echo "(no url_parser_fuzzer corpus)"; fi
# Fuzz target, so that you can choose which $(LIB_FUZZING_ENGINE) to use.
server_fuzzer : fuzzing/server_fuzzer.cc ../httplib.h standalone_fuzz_target_runner.o
$(CXX) -o $@ -I.. $(CXXFLAGS) $< $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT) $(LIB_FUZZING_ENGINE) $(ZSTD_SUPPORT) $(LIBS)
@file $@
client_fuzzer : fuzzing/client_fuzzer.cc ../httplib.h standalone_fuzz_target_runner.o
$(CXX) -o $@ -I.. $(CXXFLAGS) $< $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT) $(LIB_FUZZING_ENGINE) $(ZSTD_SUPPORT) $(LIBS)
@file $@
header_parser_fuzzer : fuzzing/header_parser_fuzzer.cc ../httplib.h standalone_fuzz_target_runner.o
$(CXX) -o $@ -I.. $(CXXFLAGS) $< $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT) $(LIB_FUZZING_ENGINE) $(ZSTD_SUPPORT) $(LIBS)
@file $@
url_parser_fuzzer : fuzzing/url_parser_fuzzer.cc ../httplib.h standalone_fuzz_target_runner.o
$(CXX) -o $@ -I.. $(CXXFLAGS) $< $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT) $(LIB_FUZZING_ENGINE) $(ZSTD_SUPPORT) $(LIBS)
@file $@
# Standalone fuzz runner, which just reads inputs from fuzzing/corpus/ dir and
# feeds it to server_fuzzer.
standalone_fuzz_target_runner.o : fuzzing/standalone_fuzz_target_runner.cpp
@@ -273,5 +297,5 @@ cert.pem:
./gen-certs.sh
clean:
rm -rf test test_split test_mbedtls test_split_mbedtls test_wolfssl test_split_wolfssl test_no_tls, test_split_no_tls test_proxy test_proxy_mbedtls test_proxy_wolfssl test_benchmark server_fuzzer *.pem *.0 *.o *.1 *.srl httplib.h httplib.cc _build* *.dSYM *_shard_*.log cpp-httplib
rm -rf test test_split test_mbedtls test_split_mbedtls test_wolfssl test_split_wolfssl test_no_tls, test_split_no_tls test_proxy test_proxy_mbedtls test_proxy_wolfssl test_benchmark server_fuzzer client_fuzzer header_parser_fuzzer url_parser_fuzzer *.pem *.0 *.o *.1 *.srl httplib.h httplib.cc _build* *.dSYM *_shard_*.log cpp-httplib
@@ -0,0 +1,3 @@
HTTP/1.1 777
Content-Length:20000000000
@@ -0,0 +1,3 @@
HTTP/1.1 777
Content-Length:446744071854775
+108
View File
@@ -5146,6 +5146,39 @@ TEST_F(ServerTest, CaseInsensitiveTransferEncoding) {
EXPECT_EQ(StatusCode::OK_200, res->status);
}
// GHSA-h6wq-j5mv-f3q8: the server must reject malformed chunk-size lines
// rather than treat them as valid lengths.
template <typename ClientT>
static void expect_chunked_body_rejected(ClientT &cli, const char *body) {
Request req;
req.method = "POST";
req.path = "/chunked";
std::string host_and_port;
host_and_port += HOST;
host_and_port += ":";
host_and_port += std::to_string(PORT);
req.headers.emplace("Host", host_and_port.c_str());
req.headers.emplace("Content-Length", "0");
req.headers.emplace("Transfer-Encoding", "chunked");
req.body = body;
auto res = std::make_shared<Response>();
auto error = Error::Success;
ASSERT_TRUE(cli.send(req, *res, error));
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
}
TEST_F(ServerTest, RejectsNegativeChunkSize) {
expect_chunked_body_rejected(cli_, "-2\r\nAAAA\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsChunkSizeWithLeadingPlus) {
expect_chunked_body_rejected(
cli_, "+4\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, GetStreamed2) {
auto res = cli_.Get("/streamed", {{make_range_header({{2, 3}})}});
ASSERT_TRUE(res);
@@ -9288,6 +9321,81 @@ TEST(ClientVulnerabilityTest, PayloadMaxLengthZeroMeansNoLimit) {
<< " bytes without truncation, but only read " << total_read << " bytes.";
}
// Regression test for OSS-Fuzz issue 508342856: a malicious server sending an
// enormous Content-Length must not cause the client to pre-allocate a huge
// response body buffer. The reservation is capped at payload_max_length_, and
// the read itself fails when the body exceeds the limit.
TEST(ClientVulnerabilityTest, HugeContentLengthDoesNotPreallocate) {
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto server_thread = std::thread([] {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
::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);
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) {
char buf[4096];
::recv(cli, buf, sizeof(buf), 0);
// Malicious response: claim a 20GB body but send only a tiny payload.
std::string response = "HTTP/1.1 200 OK\r\n"
"Content-Length: 20000000000\r\n"
"\r\n"
"abc";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response.c_str()),
static_cast<int>(response.size()),
#else
response.c_str(), response.size(),
#endif
0);
detail::close_socket(cli);
}
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
{
Client cli("127.0.0.1", PORT + 2);
cli.set_read_timeout(5, 0);
// Default payload_max_length_ is 100MB; a 20GB Content-Length must not
// result in a 20GB pre-allocation. The Get() call is expected to fail
// (server claims more bytes than payload_max_length permits), but it must
// not exhaust memory before getting there.
auto res = cli.Get("/malicious");
EXPECT_FALSE(res); // Read fails because body exceeds payload_max_length_
}
server_thread.join();
}
// Verify that content_receiver bypasses the default payload_max_length,
// allowing streaming downloads larger than 100MB without requiring an explicit
// set_payload_max_length call.
+4
View File
@@ -291,10 +291,12 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
{
auto res = cli.Get("/get");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
auto res = cli.Get("/redirect/2");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
@@ -306,6 +308,7 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
for (auto path : paths) {
auto res = cli.Get(path.c_str());
ASSERT_TRUE(res != nullptr);
auto body = normalizeJson(res->body);
EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
@@ -317,6 +320,7 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
int count = 10;
while (count--) {
auto res = cli.Get("/get");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
}