Compare commits

...

13 Commits

Author SHA1 Message Date
yhirose b1792ef29c Release v0.45.1 2026-05-24 20:58:48 -04:00
yhirose 0f3d063f0a ci: add best-effort BoringSSL job (#2456)
Adds Ubuntu and macOS CI jobs that build BoringSSL from source and exercise cpp-httplib's existing OpenSSL backend path (continue-on-error: best-effort). Makes SSLClientServerTest.TlsVerifyHostname backend-aware (BoringSSL is SAN-only per RFC 6125 §6.4.4). README notes BoringSSL as a best-effort variant with the C++14 and SAN-only caveats.
2026-05-24 02:48:46 -04:00
sakurai-ryuhei 0d7d637466 Fix zstd detection in installed httplibConfig.cmake (#2453) 2026-05-23 11:59:58 -04:00
yhirose 1ff0c8588d Fix iOS build break and modernize macOS Keychain cert loading (#2455)
* Replace deprecated SecTrustCopyAnchorCertificates on macOS

SecTrustCopyAnchorCertificates was deprecated in macOS 13. Switch to
SecTrustSettingsCopyCertificates, iterating over the System, Admin, and
User trust domains to retain equivalent coverage of anchor certificates.

* Restrict Keychain cert loading to macOS

TARGET_OS_MAC is true on all Apple platforms including iOS, tvOS, and
watchOS, which caused the keychain enumeration path to be compiled on
iOS where SecTrustSettingsCopyCertificates is unavailable.

Narrow the auto-enable and the Security.h include guards to
TARGET_OS_OSX, and emit an explicit #error when the user defines
CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN on a non-macOS Apple platform,
directing them to use set_ca_cert_path() with a bundled CA file.

Addresses the iOS build break reported in #2454.

* Add iOS header parse check to CI

Run a cross-compile syntax check against the iOS SDK to catch
accidental use of macOS-only APIs or guards (e.g. TARGET_OS_MAC vs
TARGET_OS_OSX) that would silently break iOS builds. Also verify that
defining CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN on iOS fires the
expected #error.

iOS is not officially supported as a runtime target; this job only
guarantees the header stays parse-clean on iOS toolchains.
2026-05-23 08:39:45 -04:00
NsPro04 b1cc8095a8 Specifying "Server::stop()" as noexcept (#2451)
* The current implementation of "Server::stop()" doesn't throw an exception, so why not specify this explicitly?

* Adding the missing "noexcept" to the declaration
2026-05-16 09:50:08 -04:00
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
yhirose 811dd0b6f2 Release v0.44.0 2026-05-10 21:46:24 +09:00
yhirose e8e652824b Add --minor flag to release.sh for forced minor bumps
Allows forcing a minor version bump even when abidiff passes,
for behavioral breaking changes that don't break ABI.
2026-05-10 21:28:38 +09:00
yhirose fbb031ed85 Stop percent-decoding HTTP request header values
parse_header() applied decode_path_component() to every header value
except Location and Referer, after is_field_value() validation. Wire
sequences like %0D%0A passed the check and expanded into literal CR/LF
inside stored values, enabling response splitting, log injection, and
proxy smuggling. %3D/%2C/%3B also flipped Cookie and X-Forwarded-For
boundaries against WAFs inspecting the wire form.

RFC 9110 §5.5 specifies header values as opaque octets. Drop the
decoding and the Location/Referer special case (originally workarounds
for the same auto-decode misbehavior; redundant once decoding stops).
Applications that need URI semantics should call decode_uri_component()
or decode_path_component() on the result explicitly.

Add regression tests covering CRLF injection, %3D/%2C/%3B boundary
characters, UTF-8 and %uXXXX sequences, browser-style Referer URLs
containing %0A (issue #2033), and the explicit-decode migration
pattern.
2026-05-10 12:59:29 +09:00
yhirose 7d5082cc0e Make ThreadPool ctor exception-safe on partial thread creation (#2445)
* Make ThreadPool ctor exception-safe on partial thread creation

If std::thread construction throws partway through the ThreadPool
constructor (e.g., pthread_create returns EAGAIN under thread-resource
pressure), the partially-built threads_ vector would destruct joinable
std::thread objects, calling std::terminate(). Wrap the spawn loop and,
on failure, signal shutdown to the workers already created, join them,
and rethrow.

Adds a reproducer test in test_thread_pool.cc that interposes
pthread_create at link time to deterministically fail the second call,
gated to POSIX + exceptions-enabled builds.

Fix #2444

* Strip ASAN from test_thread_pool to coexist with pthread_create override

Linux libasan installs its own pthread_create interceptor; our in-binary
symbol override sits on top of it and corrupts ASAN's thread bookkeeping,
which surfaces as "Joining already joined thread" on the very first test.
Disable ASAN for this small unit-test binary -- ThreadPool memory behavior
is still exercised under ASAN by the main `test` binary.
2026-05-09 21:13:40 -04:00
9 changed files with 665 additions and 92 deletions
+197
View File
@@ -120,6 +120,155 @@ jobs:
- name: build and run ThreadPool test
run: cd test && make test_thread_pool && ./test_thread_pool
# BoringSSL is Google's fork of OpenSSL. It has no API stability guarantee
# and is not packaged by distros, so we build it from source. cpp-httplib
# treats it as an OpenSSL backend variant via the OPENSSL_IS_BORINGSSL
# macro (see httplib.h). This job is best-effort: continue-on-error keeps
# upstream API drift from blocking PRs while still surfacing breakage.
ubuntu-boringssl:
runs-on: ubuntu-latest
if: >
(github.event_name == 'push') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.test_linux == 'true')
continue-on-error: true
name: ubuntu (boringssl, best-effort)
env:
# Tracking HEAD keeps us honest about upstream churn. If breakage
# becomes routine, replace HEAD with a 40-char commit SHA; the
# resolve step uses the SHA directly when it matches that shape.
BORINGSSL_REF: HEAD
BORINGSSL_PREFIX: ${{ github.workspace }}/boringssl-install
steps:
- name: checkout
uses: actions/checkout@v4
- name: install common libraries
run: |
sudo apt-get update
sudo apt-get install -y libcurl4-openssl-dev zlib1g-dev libbrotli-dev libzstd-dev
- name: resolve BoringSSL commit
id: boringssl-rev
# Accept either a ref name (resolved via git ls-remote) or a full
# 40-char SHA used directly. ls-remote does not list arbitrary
# commit SHAs, so pinning requires the second path.
run: |
if [[ "${BORINGSSL_REF}" =~ ^[0-9a-f]{40}$ ]]; then
sha="${BORINGSSL_REF}"
echo "Using pinned BoringSSL SHA: ${sha}"
else
sha=$(git ls-remote https://boringssl.googlesource.com/boringssl "${BORINGSSL_REF}" | awk '{print $1}')
if [ -z "$sha" ]; then
echo "Failed to resolve BoringSSL ref ${BORINGSSL_REF}" >&2
exit 1
fi
echo "Resolved ${BORINGSSL_REF} -> ${sha}"
fi
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- name: cache BoringSSL build
id: boringssl-cache
uses: actions/cache@v4
with:
path: ${{ env.BORINGSSL_PREFIX }}
key: boringssl-${{ runner.os }}-${{ steps.boringssl-rev.outputs.sha }}
- name: build BoringSSL
if: steps.boringssl-cache.outputs.cache-hit != 'true'
run: |
set -e
git clone https://boringssl.googlesource.com/boringssl boringssl
cd boringssl
git checkout "${{ steps.boringssl-rev.outputs.sha }}"
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_INSTALL_PREFIX="${BORINGSSL_PREFIX}"
cmake --build build -j"$(nproc)" --target install
- name: build and run tests (BoringSSL)
# Override OPENSSL_SUPPORT to point the existing OpenSSL Makefile path
# at BoringSSL's prefix. BoringSSL defines OPENSSL_IS_BORINGSSL in
# <openssl/base.h>, which httplib.h and test.cc use to switch on API
# differences (e.g. SAN-only hostname verification, no CN fallback).
#
# BoringSSL's public headers (<openssl/stack.h>) use std::enable_if_t,
# so consumers must compile with C++14 or later. cpp-httplib itself
# supports C++11, but anyone pairing it with BoringSSL inherits this
# constraint. EXTRA_CXXFLAGS appends after the Makefile's -std=c++11
# and the later flag wins.
run: |
cd test
BORINGSSL_FLAGS="-DCPPHTTPLIB_OPENSSL_SUPPORT -I${BORINGSSL_PREFIX}/include -L${BORINGSSL_PREFIX}/lib -lssl -lcrypto -lpthread"
make test_split OPENSSL_SUPPORT="${BORINGSSL_FLAGS}" EXTRA_CXXFLAGS="-std=c++17"
make test_openssl_parallel OPENSSL_SUPPORT="${BORINGSSL_FLAGS}" EXTRA_CXXFLAGS="-std=c++17"
env:
LSAN_OPTIONS: suppressions=lsan_suppressions.txt
# macOS counterpart of the BoringSSL job. Same best-effort posture; the
# extra framework links cover the macOS Keychain integration that
# httplib.h auto-enables for any TLS backend on macOS.
macos-boringssl:
runs-on: macos-latest
if: >
(github.event_name == 'push') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.test_macos == 'true')
continue-on-error: true
name: macos (boringssl, best-effort)
env:
BORINGSSL_REF: HEAD
BORINGSSL_PREFIX: ${{ github.workspace }}/boringssl-install
steps:
- name: checkout
uses: actions/checkout@v4
- name: resolve BoringSSL commit
id: boringssl-rev
# Accept either a ref name (resolved via git ls-remote) or a full
# 40-char SHA used directly. ls-remote does not list arbitrary
# commit SHAs, so pinning requires the second path.
run: |
if [[ "${BORINGSSL_REF}" =~ ^[0-9a-f]{40}$ ]]; then
sha="${BORINGSSL_REF}"
echo "Using pinned BoringSSL SHA: ${sha}"
else
sha=$(git ls-remote https://boringssl.googlesource.com/boringssl "${BORINGSSL_REF}" | awk '{print $1}')
if [ -z "$sha" ]; then
echo "Failed to resolve BoringSSL ref ${BORINGSSL_REF}" >&2
exit 1
fi
echo "Resolved ${BORINGSSL_REF} -> ${sha}"
fi
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- name: cache BoringSSL build
id: boringssl-cache
uses: actions/cache@v4
with:
path: ${{ env.BORINGSSL_PREFIX }}
key: boringssl-${{ runner.os }}-${{ steps.boringssl-rev.outputs.sha }}
- name: build BoringSSL
if: steps.boringssl-cache.outputs.cache-hit != 'true'
run: |
set -e
git clone https://boringssl.googlesource.com/boringssl boringssl
cd boringssl
git checkout "${{ steps.boringssl-rev.outputs.sha }}"
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_INSTALL_PREFIX="${BORINGSSL_PREFIX}"
cmake --build build -j"$(sysctl -n hw.ncpu)" --target install
- name: build and run tests (BoringSSL)
run: |
cd test
# CoreFoundation/Security frameworks satisfy the Keychain integration
# auto-enabled in httplib.h for macOS TLS builds.
BORINGSSL_FLAGS="-DCPPHTTPLIB_OPENSSL_SUPPORT -I${BORINGSSL_PREFIX}/include -L${BORINGSSL_PREFIX}/lib -lssl -lcrypto -framework CoreFoundation -framework Security"
make test_split OPENSSL_SUPPORT="${BORINGSSL_FLAGS}" EXTRA_CXXFLAGS="-std=c++17"
make test_openssl_parallel OPENSSL_SUPPORT="${BORINGSSL_FLAGS}" EXTRA_CXXFLAGS="-std=c++17"
env:
LSAN_OPTIONS: suppressions=lsan_suppressions.txt
# Reproducer for https://github.com/yhirose/cpp-httplib/issues/2431.
# On Linux/glibc, getaddrinfo_with_timeout() schedules an asynchronous
# DNS lookup with getaddrinfo_a(GAI_NOWAIT) using a stack-local gaicb.
@@ -250,6 +399,54 @@ jobs:
- name: build and run ThreadPool test
run: cd test && make test_thread_pool && ./test_thread_pool
ios-parse-check:
runs-on: macos-latest
if: >
(github.event_name == 'push') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.test_macos == 'true')
name: ios header parse check (not officially supported)
steps:
- name: checkout
uses: actions/checkout@v4
- name: install OpenSSL headers
run: brew install openssl@3
- name: verify header parses on iOS target
run: |
IOS_SDK=$(xcrun --sdk iphoneos --show-sdk-path)
OPENSSL_INC=$(brew --prefix openssl@3)/include
echo "Using iOS SDK: $IOS_SDK"
echo '#include "httplib.h"' | clang++ \
-isysroot "$IOS_SDK" \
-target arm64-apple-ios16.0 \
-std=c++11 \
-DCPPHTTPLIB_OPENSSL_SUPPORT \
-I"$OPENSSL_INC" \
-I. -Wall -Wextra \
-fsyntax-only -x c++ -
- name: verify CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN is rejected on iOS
run: |
IOS_SDK=$(xcrun --sdk iphoneos --show-sdk-path)
OPENSSL_INC=$(brew --prefix openssl@3)/include
out=$(echo '#include "httplib.h"' | clang++ \
-isysroot "$IOS_SDK" \
-target arm64-apple-ios16.0 \
-std=c++11 \
-DCPPHTTPLIB_OPENSSL_SUPPORT \
-DCPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN \
-I"$OPENSSL_INC" \
-I. \
-fsyntax-only -x c++ - 2>&1 || true)
if echo "$out" | grep -q "only supported on macOS"; then
echo "OK: #error fired as expected"
else
echo "FAIL: expected #error did not fire"
echo "--- compiler output ---"
echo "$out"
exit 1
fi
windows:
runs-on: windows-latest
if: >
+3
View File
@@ -73,6 +73,9 @@ cpp-httplib supports multiple TLS backends through an abstraction layer:
> [!NOTE]
> **Mbed TLS / wolfSSL limitation:** `get_ca_certs()` and `get_ca_names()` only reflect CA certificates loaded via `load_ca_cert_store()`. Certificates loaded through `set_ca_cert_path()` or system certificates (`load_system_certs`) are not enumerable.
> [!NOTE]
> **BoringSSL (best-effort):** BoringSSL builds under `CPPHTTPLIB_OPENSSL_SUPPORT` and is exercised by CI against current upstream. Because BoringSSL does not guarantee API stability, support is best-effort — breakage may occasionally land. Two known behavioral differences vs OpenSSL: (1) BoringSSL's public headers require C++14 or later, so consumers must compile accordingly; (2) hostname verification is SAN-only per RFC 6125 §6.4.4 (no CN fallback).
```c++
// Use either OpenSSL, Mbed TLS, or wolfSSL
#define CPPHTTPLIB_OPENSSL_SUPPORT // or CPPHTTPLIB_MBEDTLS_SUPPORT or CPPHTTPLIB_WOLFSSL_SUPPORT
+1 -1
View File
@@ -61,7 +61,7 @@ if(@HTTPLIB_IS_USING_ZSTD@)
if(${CMAKE_FIND_PACKAGE_NAME}_FIND_REQUIRED)
set(httplib_fd_zstd_required_arg REQUIRED)
endif()
find_package(zstd QUIET)
find_package(zstd 1.5.6 CONFIG QUIET)
if(NOT zstd_FOUND)
find_package(PkgConfig ${httplib_fd_zstd_quiet_arg} ${httplib_fd_zstd_required_arg})
if(PKG_CONFIG_FOUND)
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.43.4"
version = "0.45.1"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+120 -75
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.43.4"
#define CPPHTTPLIB_VERSION_NUM "0x002b04"
#define CPPHTTPLIB_VERSION "0.45.1"
#define CPPHTTPLIB_VERSION_NUM "0x002d01"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -339,16 +339,26 @@ using socket_t = int;
#include <utility>
// On macOS with a TLS backend, enable Keychain root certificates by default
// unless the user explicitly opts out.
// unless the user explicitly opts out. Not enabled on iOS/tvOS/watchOS since
// the SecTrustSettings APIs used to enumerate anchor certificates are macOS
// only; on those platforms the user must provide a CA bundle explicitly.
#if defined(__APPLE__) && defined(__clang__) && \
!defined(CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES) && \
(defined(CPPHTTPLIB_OPENSSL_SUPPORT) || \
defined(CPPHTTPLIB_MBEDTLS_SUPPORT) || \
defined(CPPHTTPLIB_WOLFSSL_SUPPORT))
#if TARGET_OS_OSX
#ifndef CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
#define CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
#endif
#endif
#endif
#if defined(CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN) && \
defined(__APPLE__) && !TARGET_OS_OSX
#error \
"CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN is only supported on macOS. On iOS/tvOS/watchOS, supply a CA bundle via set_ca_cert_path()."
#endif
// On Windows, enable Schannel certificate verification by default
// unless the user explicitly opts out.
@@ -382,7 +392,7 @@ using socket_t = int;
#endif // _WIN32
#ifdef CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
#if TARGET_OS_MAC
#if TARGET_OS_OSX
#include <Security/Security.h>
#endif
#endif
@@ -430,7 +440,7 @@ using socket_t = int;
#endif
#endif // _WIN32
#ifdef CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
#if TARGET_OS_MAC
#if TARGET_OS_OSX
#include <Security/Security.h>
#endif
#endif
@@ -473,7 +483,7 @@ using socket_t = int;
#endif
#endif // _WIN32
#ifdef CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
#if TARGET_OS_MAC
#if TARGET_OS_OSX
#include <Security/Security.h>
#endif
#endif
@@ -1597,7 +1607,7 @@ private:
std::regex regex_;
};
int close_socket(socket_t sock);
int close_socket(socket_t sock) noexcept;
ssize_t write_headers(Stream &strm, const Headers &headers);
@@ -1734,7 +1744,7 @@ public:
bool is_running() const;
void wait_until_ready() const;
void stop();
void stop() noexcept;
void decommission();
std::function<TaskQueue *(void)> new_task_queue;
@@ -3028,8 +3038,6 @@ bool parse_range_header(const std::string &s, Ranges &ranges);
bool parse_accept_header(const std::string &s,
std::vector<std::string> &content_types);
int close_socket(socket_t sock);
ssize_t send_socket(socket_t sock, const void *ptr, size_t size, int flags);
ssize_t read_socket(socket_t sock, void *ptr, size_t size, int flags);
@@ -5016,12 +5024,11 @@ inline bool parse_header(const char *beg, const char *end, T fn) {
if (!detail::fields::is_field_value(val)) { return false; }
if (case_ignore::equal(key, "Location") ||
case_ignore::equal(key, "Referer")) {
fn(key, val);
} else {
fn(key, decode_path_component(val));
}
// RFC 9110 §5.5: header field values are opaque octets and MUST NOT be
// percent-decoded by the recipient. Applications that need to interpret a
// value as a URI component should call httplib::decode_uri_component()
// (or decode_path_component()) explicitly.
fn(key, val);
return true;
}
@@ -5423,7 +5430,7 @@ inline void mmap::close() {
#endif
size_ = 0;
}
inline int close_socket(socket_t sock) {
inline int close_socket(socket_t sock) noexcept {
#ifdef _WIN32
return closesocket(sock);
#else
@@ -5650,7 +5657,7 @@ inline bool process_client_socket(
return callback(strm);
}
inline int shutdown_socket(socket_t sock) {
inline int shutdown_socket(socket_t sock) noexcept {
#ifdef _WIN32
return shutdown(sock, SD_BOTH);
#else
@@ -8579,17 +8586,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
@@ -10047,9 +10061,29 @@ inline ThreadPool::ThreadPool(size_t n, size_t max_n, size_t mqr)
#endif
max_thread_count_ = max_n == 0 ? n : max_n;
threads_.reserve(base_thread_count_);
for (size_t i = 0; i < base_thread_count_; i++) {
threads_.emplace_back(std::thread([this]() { worker(false); }));
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
try {
#endif
for (size_t i = 0; i < base_thread_count_; i++) {
threads_.emplace_back(std::thread([this]() { worker(false); }));
}
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
} catch (...) {
// If thread creation fails partway (e.g., pthread_create returns EAGAIN),
// signal the workers we already spawned to exit and join them so the
// vector destructor does not see joinable threads (which would call
// std::terminate). Then rethrow so the caller learns of the failure.
{
std::unique_lock<std::mutex> lock(mutex_);
shutdown_ = true;
}
cond_.notify_all();
for (auto &t : threads_) {
if (t.joinable()) { t.join(); }
}
throw;
}
#endif
}
inline bool ThreadPool::enqueue(std::function<void()> fn) {
@@ -10978,7 +11012,7 @@ inline void Server::wait_until_ready() const {
}
}
inline void Server::stop() {
inline void Server::stop() noexcept {
if (is_running_) {
assert(svr_sock_ != INVALID_SOCKET);
std::atomic<socket_t> sock(svr_sock_.exchange(INVALID_SOCKET));
@@ -11285,29 +11319,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;
}
@@ -11869,6 +11892,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];
@@ -11959,7 +11987,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;
}
@@ -12161,15 +12190,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;
}
}
@@ -16125,9 +16153,18 @@ inline bool enumerate_windows_system_certs(Callback cb) {
template <typename Callback>
inline bool enumerate_macos_keychain_certs(Callback cb) {
bool loaded = false;
CFArrayRef certs = nullptr;
OSStatus status = SecTrustCopyAnchorCertificates(&certs);
if (status == errSecSuccess && certs) {
const SecTrustSettingsDomain domains[] = {
kSecTrustSettingsDomainSystem,
kSecTrustSettingsDomainAdmin,
kSecTrustSettingsDomainUser,
};
for (auto domain : domains) {
CFArrayRef certs = nullptr;
OSStatus status = SecTrustSettingsCopyCertificates(domain, &certs);
if (status != errSecSuccess || !certs) {
if (certs) CFRelease(certs);
continue;
}
CFIndex count = CFArrayGetCount(certs);
for (CFIndex i = 0; i < count; i++) {
SecCertificateRef cert =
@@ -16490,28 +16527,36 @@ inline bool load_system_certs(ctx_t ctx) {
auto store = SSL_CTX_get_cert_store(ssl_ctx);
if (!store) return false;
CFArrayRef certs = nullptr;
if (SecTrustCopyAnchorCertificates(&certs) != errSecSuccess || !certs) {
return SSL_CTX_set_default_verify_paths(ssl_ctx) == 1;
}
bool loaded_any = false;
auto count = CFArrayGetCount(certs);
for (CFIndex i = 0; i < count; i++) {
auto cert = reinterpret_cast<SecCertificateRef>(
const_cast<void *>(CFArrayGetValueAtIndex(certs, i)));
CFDataRef der = SecCertificateCopyData(cert);
if (der) {
const unsigned char *data = CFDataGetBytePtr(der);
auto x509 = d2i_X509(nullptr, &data, CFDataGetLength(der));
if (x509) {
if (X509_STORE_add_cert(store, x509) == 1) { loaded_any = true; }
X509_free(x509);
}
CFRelease(der);
const SecTrustSettingsDomain domains[] = {
kSecTrustSettingsDomainSystem,
kSecTrustSettingsDomainAdmin,
kSecTrustSettingsDomainUser,
};
for (auto domain : domains) {
CFArrayRef certs = nullptr;
if (SecTrustSettingsCopyCertificates(domain, &certs) != errSecSuccess ||
!certs) {
if (certs) CFRelease(certs);
continue;
}
auto count = CFArrayGetCount(certs);
for (CFIndex i = 0; i < count; i++) {
auto cert = reinterpret_cast<SecCertificateRef>(
const_cast<void *>(CFArrayGetValueAtIndex(certs, i)));
CFDataRef der = SecCertificateCopyData(cert);
if (der) {
const unsigned char *data = CFDataGetBytePtr(der);
auto x509 = d2i_X509(nullptr, &data, CFDataGetLength(der));
if (x509) {
if (X509_STORE_add_cert(store, x509) == 1) { loaded_any = true; }
X509_free(x509);
}
CFRelease(der);
}
}
CFRelease(certs);
}
CFRelease(certs);
return loaded_any || SSL_CTX_set_default_verify_paths(ssl_ctx) == 1;
#else
return SSL_CTX_set_default_verify_paths(ssl_ctx) == 1;
+33 -14
View File
@@ -2,10 +2,12 @@
#
# Release a new version of cpp-httplib.
#
# Usage: ./release.sh [--run]
# Usage: ./release.sh [--run] [--minor]
#
# By default, runs in dry-run mode (no changes made).
# Pass --run to actually update files, commit, tag, and push.
# Pass --minor to force a minor bump even when ABI is unchanged
# (use this for behavioral breaking changes that don't break ABI).
#
# This script:
# 1. Reads the current version from httplib.h
@@ -14,21 +16,30 @@
# 4. Determines the next version automatically:
# - abidiff passed → patch bump (e.g., 0.38.0 → 0.38.1)
# - abidiff failed → minor bump (e.g., 0.38.1 → 0.39.0)
# - --minor passed → forces minor bump regardless of abidiff
# 5. Updates httplib.h and docs-src/config.toml
# 6. Commits, tags (vX.Y.Z), and pushes
set -euo pipefail
DRY_RUN=1
if [ "${1:-}" = "--run" ]; then
DRY_RUN=0
shift
fi
if [ $# -ne 0 ]; then
echo "Usage: $0 [--run]"
exit 1
fi
FORCE_MINOR=0
while [ $# -gt 0 ]; do
case "$1" in
--run)
DRY_RUN=0
shift
;;
--minor)
FORCE_MINOR=1
shift
;;
*)
echo "Usage: $0 [--run] [--minor]"
exit 1
;;
esac
done
# --- Step 1: Read current version from httplib.h ---
CURRENT_VERSION=$(sed -n 's/^#define CPPHTTPLIB_VERSION "\([^"]*\)"/\1/p' httplib.h)
@@ -51,8 +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 --json name,conclusion,headSha \
--jq "[.[] | select(.headSha == \"$HEAD_SHA\")]")
RUNS=$(gh run list --commit "$HEAD_SHA" --json name,conclusion,headSha)
NUM_RUNS=$(echo "$RUNS" | jq 'length')
@@ -95,7 +105,12 @@ fi
echo " All non-abidiff CI checks passed."
# --- Step 4: Determine new version ---
if [ "$ABIDIFF_PASSED" -eq 1 ]; then
if [ "$FORCE_MINOR" -eq 1 ] && [ "$ABIDIFF_PASSED" -eq 1 ]; then
NEW_MINOR=$((V_MINOR + 1))
NEW_VERSION="$V_MAJOR.$NEW_MINOR.0"
echo ""
echo "==> abidiff passed but --minor specified → forced minor bump"
elif [ "$ABIDIFF_PASSED" -eq 1 ]; then
NEW_PATCH=$((V_PATCH + 1))
NEW_VERSION="$V_MAJOR.$V_MINOR.$NEW_PATCH"
echo ""
@@ -104,7 +119,11 @@ else
NEW_MINOR=$((V_MINOR + 1))
NEW_VERSION="$V_MAJOR.$NEW_MINOR.0"
echo ""
echo "==> abidiff failed → minor bump"
if [ "$FORCE_MINOR" -eq 1 ]; then
echo "==> abidiff failed → minor bump (--minor also specified)"
else
echo "==> abidiff failed → minor bump"
fi
fi
VERSION_HEX=$(printf "0x%02x%02x%02x" "${NEW_VERSION%%.*}" "$(echo "$NEW_VERSION" | cut -d. -f2)" "${NEW_VERSION##*.}")
+18 -1
View File
@@ -202,8 +202,25 @@ test_split_no_tls : test.cc ../httplib.h httplib.cc Makefile
$(CXX) -o $@ $(CXXFLAGS) test.cc httplib.cc $(TEST_ARGS_NO_TLS)
# ThreadPool unit tests (no TLS, no compression needed)
#
# The constructor-exception-safety reproducer test interposes pthread_create
# at link time. The link flags below enable that interposition. ASAN is also
# stripped from this target because libasan installs its own pthread_create
# interceptor; layering our override on top corrupts ASAN's thread bookkeeping
# and trips "Joining already joined thread" on Linux. ThreadPool memory
# behavior is still covered by the ASAN-instrumented `test` binary.
ifneq ($(OS), Windows_NT)
ifeq ($(shell uname -s), Darwin)
THREAD_POOL_INTERPOSE_LDFLAGS := -Wl,-flat_namespace
else
THREAD_POOL_INTERPOSE_LDFLAGS := -Wl,--export-dynamic
endif
endif
THREAD_POOL_CXXFLAGS := $(filter-out -fsanitize=address,$(CXXFLAGS))
test_thread_pool : test_thread_pool.cc ../httplib.h Makefile
$(CXX) -o $@ -I.. $(CXXFLAGS) test_thread_pool.cc gtest/src/gtest-all.cc gtest/src/gtest_main.cc -Igtest -Igtest/include -lpthread
$(CXX) -o $@ -I.. $(THREAD_POOL_CXXFLAGS) test_thread_pool.cc gtest/src/gtest-all.cc gtest/src/gtest_main.cc -Igtest -Igtest/include -lpthread $(THREAD_POOL_INTERPOSE_LDFLAGS)
check_abi:
@./check-shared-library-abi-compatibility.sh
+235
View File
@@ -7441,6 +7441,122 @@ TEST(ServerRequestParsingTest, EmptyFieldValue) {
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
}
TEST(ServerRequestParsingTest, HeaderValueNotPercentDecoded) {
Server svr;
std::string x_custom;
std::string cookie;
std::string xff;
std::string x_unicode;
std::string x_iis;
svr.Get("/check", [&](const Request &req, Response &res) {
x_custom = req.get_header_value("X-Custom");
cookie = req.get_header_value("Cookie");
xff = req.get_header_value("X-Forwarded-For");
x_unicode = req.get_header_value("X-Unicode");
x_iis = req.get_header_value("X-IIS");
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET /check HTTP/1.1\r\n"
"Host: localhost\r\n"
"X-Custom: a%0D%0AInjected: b\r\n"
"Cookie: session%3Dvictim%3B%20admin%3Dyes\r\n"
"X-Forwarded-For: 1.2.3.4%2C5.6.7.8\r\n"
"X-Unicode: %E3%81%82\r\n"
"X-IIS: %u00E9\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
// Every value must be returned verbatim (wire form), with no decoding.
EXPECT_EQ("a%0D%0AInjected: b", x_custom);
EXPECT_EQ("session%3Dvictim%3B%20admin%3Dyes", cookie);
EXPECT_EQ("1.2.3.4%2C5.6.7.8", xff);
EXPECT_EQ("%E3%81%82", x_unicode);
EXPECT_EQ("%u00E9", x_iis);
}
// Applications that previously relied on automatic percent-decoding can
// reproduce the old behavior by explicitly calling decode_path_component()
// or, for RFC 3986 conformance, decode_uri_component().
TEST(ServerRequestParsingTest, HeaderValueExplicitDecodingByApplication) {
Server svr;
std::string decoded;
svr.Get("/check", [&](const Request &req, Response &res) {
decoded = decode_uri_component(req.get_header_value("X-Custom"));
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET /check HTTP/1.1\r\n"
"Host: localhost\r\n"
"X-Custom: hello%20world\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
EXPECT_EQ("hello world", decoded);
}
// Regression test for #2033. Browsers send Referer values that include
// percent-encoded characters such as %0A inside the URL. Decoding the
// header value would either trip the post-decode CR/LF/NUL guard (the
// original bug, returning 400) or, after that guard was relaxed, silently
// store a literal LF — both unacceptable. The wire form must round-trip.
TEST(ServerRequestParsingTest, RefererWithPercentEncodedNewline) {
Server svr;
std::string referer;
svr.Get("/check", [&](const Request &req, Response &res) {
referer = req.get_header_value("Referer");
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET /check HTTP/1.1\r\n"
"Host: localhost\r\n"
"Referer: http://localhost:1111/?q=Hello%0A\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
EXPECT_EQ("http://localhost:1111/?q=Hello%0A", referer);
}
TEST(ServerStopTest, StopServerWithChunkedTransmission) {
Server svr;
@@ -10557,8 +10673,20 @@ TEST(SSLClientServerTest, TlsVerifyHostname) {
<< "Verify callback should have been called";
// CN="Common Name" should match our test certificate
//
// BoringSSL intentionally drops CN-based hostname matching per RFC 6125
// §6.4.4 — only SubjectAltName is consulted. Other backends (OpenSSL,
// MbedTLS, wolfSSL) still honor the CN fallback, so flip the expectation
// for BoringSSL builds. OPENSSL_IS_BORINGSSL is defined by BoringSSL's
// <openssl/base.h>, which is included transitively when
// CPPHTTPLIB_OPENSSL_SUPPORT is set against a BoringSSL install.
#if defined(OPENSSL_IS_BORINGSSL)
EXPECT_FALSE(verify_result_cn)
<< "BoringSSL should reject CN-based hostname matching (SAN-only)";
#else
EXPECT_TRUE(verify_result_cn)
<< "verify_hostname should match 'Common Name' (certificate CN)";
#endif
// Wrong hostname should not match
EXPECT_FALSE(verify_result_wrong)
@@ -14135,6 +14263,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;
@@ -18041,3 +18216,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());
}
+57
View File
@@ -198,6 +198,63 @@ TEST(ThreadPoolTest, InvalidMaxThreadsThrows) {
}
#endif
// Issue #2444: ThreadPool constructor must be exception-safe when std::thread
// construction fails partway (e.g., pthread_create returns EAGAIN under thread
// resource pressure). Without proper handling, the partially-built threads_
// vector destroys joinable std::thread objects, calling std::terminate().
//
// We reproduce the failure portably by interposing pthread_create at link
// time: while the counter is armed, the first N calls succeed, the rest
// return EAGAIN. This is gated to POSIX + exceptions-enabled builds.
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
#if defined(__unix__) || defined(__APPLE__)
#include <dlfcn.h>
#include <errno.h>
#include <pthread.h>
namespace {
// -1 = pass-through (default). >= 0 = number of remaining successful calls
// before EAGAIN is returned. Reset to -1 after each test that arms it.
std::atomic<int> g_pthread_create_remaining{-1};
} // namespace
extern "C" int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg) {
using fn_t =
int (*)(pthread_t *, const pthread_attr_t *, void *(*)(void *), void *);
static fn_t real = reinterpret_cast<fn_t>(dlsym(RTLD_NEXT, "pthread_create"));
int n = g_pthread_create_remaining.load(std::memory_order_relaxed);
if (n == 0) { return EAGAIN; }
if (n > 0) {
g_pthread_create_remaining.fetch_sub(1, std::memory_order_relaxed);
}
return real(thread, attr, start_routine, arg);
}
TEST(ThreadPoolTest, ConstructorRecoversWhenThreadCreationFails) {
// Allow only the first thread to spawn; subsequent pthread_create calls
// return EAGAIN, causing std::thread() to throw std::system_error mid-loop.
g_pthread_create_remaining.store(1);
bool caught = false;
try {
ThreadPool pool(/*n=*/4);
(void)pool;
} catch (const std::system_error &) { caught = true; } catch (...) {
caught = true;
}
// Disarm before any further test runs.
g_pthread_create_remaining.store(-1);
EXPECT_TRUE(caught);
}
#endif // POSIX
#endif // CPPHTTPLIB_NO_EXCEPTIONS
TEST(ThreadPoolTest, EnqueueAfterShutdownReturnsFalse) {
ThreadPool pool(2);
pool.shutdown();