Compare commits

...
9 Commits
Author SHA1 Message Date
Wleeaf 252dbaa505 Remove dot segments from absolute redirect locations (#2648)
resolve_relative_location removed "." and ".." segments only from a relative-path Location. A Location that starts with "/" or an absolute URI was requested as is, so a redirect to /dir/../target went out as /dir/../target and a server that does not normalize the path answered 404.

RFC 3986 section 5.2.2 applies remove_dot_segments to the target path of every reference that has a path, and Go and urllib3 do the same. The loop now lives in detail::remove_dot_segments, and ClientImpl::redirect applies it to an absolute path as well. Relative locations resolve as before.
2026-10-25 22:56:32 -04:00
metsw24-max 8710fbd25d Refuse a ':' in a path a mount point serves on Windows (#2643)
On NTFS a ':' in a path names a stream of the file, so /dir/test.html::$DATA opened the default stream of dir/test.html and passed a set_pre_routing_handler() guard on /dir/test.html, and /setup.exe:Zone.Identifier served the stream Windows attaches to downloaded files. is_valid_path() now refuses a ':' on Windows.

Elsewhere a ':' is an ordinary file name character, so it is still served: a file such as 2024-01-01T10:00:00.log would otherwise answer 404. The test pins both sides.
2026-10-25 22:39:44 -04:00
metsw24-max 2f8b9f6f57 Guard last_event_id() against the async event loop in SSEClient (#2644)
last_event_id() returned a reference to a string that run_event_loop() assigns on its own thread under start_async(), so a caller reading it from another thread raced the writes. The member is now guarded by a mutex and the getter returns a copy.

The return type changes from const std::string & to std::string. Code that binds the result to a const reference or copies it still compiles.
2026-10-25 22:21:55 -04:00
metsw24-maxandyhirose 391de32115 finish short writes in write_websocket_frame and open_stream_ (#2642)
write_websocket_frame and the request-body write in open_stream_ called
Stream::write once and only failed on a negative return, so a short
write (one record per mbedtls_ssl_write call, or SO_SNDTIMEO expiring
after part of the data went out) dropped the rest of the frame or body.
Route both through write_data, as the other body writers do.

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-10-25 22:19:53 -04:00
metsw24-max ba32b468a4 Honour Keychain trust settings when loading macOS system CAs (#2641)
The macOS system CA loader took every certificate that has Trust Settings, in the system, admin or user domain, as a trusted root without reading those settings. A root set to "Never Trust" in Keychain Access, or trusted only for S/MIME or code signing, still verified TLS servers.

The loader now evaluates the settings for the SSL policy. A per-user setting overrides the admin domain, which overrides the system domain, and only TrustRoot and TrustAsRoot are loaded. The OpenSSL path shares the helper that the Mbed TLS and wolfSSL paths already use.
2026-10-25 21:41:36 -04:00
Luochenchen 76f84a5bfd Honor set_decompress(false) in response streams (#2640)
open_stream_() created a decoder whatever set_decompress() said, so a response stream decoded the body when decompression was disabled, and failed with UnsupportedContentEncoding in a build without that decoder. The buffered path already checks the option; the streaming path now does too. The new test covers open_stream() and stream::Get().

Fixes #2639.
2026-10-25 20:05:56 -04:00
yhirose d23d1b6029 Match a host name against subjectAltName only
OpenSSL 4.1 no longer falls back to the subject Common Name when it
checks a host name (RFC 9525), so SSLClientServerTest.TlsVerifyHostname
began failing in the Windows CI job that installs the newest OpenSSL 4.x.

Make every backend agree instead of following whichever OpenSSL is
installed. OpenSSL passes X509_CHECK_FLAG_NEVER_CHECK_SUBJECT to both the
handshake check and verify_hostname(), and Mbed TLS and wolfSSL drop their
own CN fallback. A certificate that names its host only in the CN is now
rejected on all backends, as it already was on BoringSSL.
2026-10-25 18:58:43 -04:00
Wleeaf 7d349cbeed Follow only 301, 302, 303, 307 and 308 redirects (#2646)
is_redirect treated any 3xx with a Location as a redirect, so with set_follow_location(true) a 304 or 305 that carried a Location was followed. Only 301, 302, 303, 307 and 308 are redirects a client follows (RFC 9110 section 15.4), as in Go, requests and urllib3. A Location is still required. The 304 check in process_request that is_redirect now covers is dropped.
2026-10-25 18:55:20 -04:00
WleeafandWleeaf 5ceef83c54 Open a new connection for a redirect after sending Connection: close (#2645)
Without keep-alive, the client sends "Connection: close", but before
following a redirect it disconnected only when the response repeated
the option. RFC 9112 (section 9.6) lets a server close the connection
without repeating it, and a client that sent "close" must not send
another request on that connection. The redirected request was written
to the closing connection and failed with "Failed to read connection"
whenever the server closed it first. The client now disconnects before
a redirect or an auth retry whenever the request asked for the close.

Co-authored-by: Wleeaf <179125857+wleeaf@users.noreply.github.com>
2026-10-25 17:19:34 -04:00
7 changed files with 715 additions and 132 deletions

No files matched your search

+1 -1
View File
@@ -100,7 +100,7 @@ sse.stop();
```cpp
bool connected = sse.is_connected();
const std::string &id = sse.last_event_id();
std::string id = sse.last_event_id();
```
## Examples
+6 -3
View File
@@ -76,7 +76,10 @@ cpp-httplib supports multiple TLS backends through an abstraction layer:
> A wolfSSL server that verifies client certificates does not resume TLS sessions: wolfSSL shares its session cache between contexts, so a resumed session could carry a certificate checked against another server's client CA.
> [!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).
> **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. One known difference vs OpenSSL: BoringSSL's public headers require C++14 or later, so consumers must compile accordingly.
> [!NOTE]
> **Hostname verification is SAN-only on every backend** (RFC 9525). A host is matched against the certificate's subjectAltName entries, never its Common Name, so a certificate that names its host only in the CN is rejected. Reissue it with a subjectAltName.
```c++
// Use either OpenSSL, Mbed TLS, or wolfSSL
@@ -240,7 +243,7 @@ cpp-httplib automatically integrates with the OS certificate store on macOS and
| Platform | Behavior | Disable (compile time) |
| :------- | :------- | :--------------------- |
| macOS | Loads system certs from Keychain (link `CoreFoundation` and `Security` with `-framework`). Requires Apple Clang; GCC is not supported for this feature. | `CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES` |
| macOS | Loads system certs from Keychain, honouring their Trust Settings: a certificate set to "Never Trust", or trusted only for another policy such as S/MIME, is not loaded (link `CoreFoundation` and `Security` with `-framework`). Requires Apple Clang; GCC is not supported for this feature. | `CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES` |
| Windows | Verifies the certificate chain with CryptoAPI (`CertGetCertificateChain` / `CertVerifyCertificateChainPolicy`) instead of the TLS backend. Revocation checking is best-effort: a revoked server certificate is rejected, while one whose revocation status cannot be determined is accepted. Windows fetches missing roots and intermediates on demand. With a custom CA, the TLS backend verifies the chain instead; with `set_server_certificate_verifier()`, both do. | `CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE` |
On Windows, the CryptoAPI check can also be turned off at runtime. The TLS backend then verifies the chain instead, as on other platforms:
@@ -406,7 +409,7 @@ ret = svr.remove_mount_point("/");
ret = svr.remove_mount_point("/public");
```
A mount point serves a path only when it has no `.`, `..` or empty segment other than a trailing `/`, and no segment ending in `.` or a space, so a check on `req.path` in `set_pre_routing_handler()` sees the path of the file that is served. A path ending in `/`, and the mount point itself such as `/public`, serve the `index.html` in that directory. The file system can still give a file other names: another case on a case-insensitive file system, such as the default ones on macOS and Windows, and on Windows an 8.3 short name such as `PRIVAT~1`. Compare paths without regard to case on such a file system, and on Windows keep files that need a guard out of mounted directories.
A mount point serves a path only when it has no `.`, `..` or empty segment other than a trailing `/`, no segment ending in `.` or a space and, on Windows, no `:` (it names an NTFS stream), so a check on `req.path` in `set_pre_routing_handler()` sees the path of the file that is served. A path ending in `/`, and the mount point itself such as `/public`, serve the `index.html` in that directory. The file system can still give a file other names: another case on a case-insensitive file system, such as the default ones on macOS and Windows, and on Windows an 8.3 short name such as `PRIVAT~1`. Compare paths without regard to case on such a file system, and on Windows keep files that need a guard out of mounted directories.
```cpp
// User defined file extension and MIME type mappings
+197 -110
View File
@@ -995,6 +995,30 @@ inline bool parse_url(const std::string &url, UrlComponents &uc) {
return true;
}
// Removes "." and ".." segments from a path that starts with '/' (RFC 3986
// section 5.2.4).
inline std::string remove_dot_segments(const std::string &path) {
std::string out;
size_t i = 0;
while (i < path.size()) {
auto next = (std::min)(path.find('/', i + 1), path.size());
auto segment = path.substr(i + 1, next - i - 1);
auto is_last = next == path.size();
if (segment == "." || segment == "..") {
if (segment == "..") {
out.erase((std::min)(out.rfind('/'), out.size()));
}
if (is_last) { out += '/'; }
} else {
out += '/';
out += segment;
}
i = next;
}
if (out.empty()) { out = "/"; }
return out;
}
// Resolves a relative-path or query-only Location value against the path of
// the request being redirected (RFC 3986 section 5.2). Absolute URIs and
// references starting with '/' are returned unchanged.
@@ -1015,26 +1039,8 @@ inline std::string resolve_relative_location(const std::string &location,
// Remove "." and ".." segments from the merged path.
auto path_end = (std::min)(merged.find_first_of("?#"), merged.size());
std::string path;
size_t i = 0;
while (i < path_end) {
auto next = (std::min)(merged.find('/', i + 1), path_end);
auto segment = merged.substr(i + 1, next - i - 1);
auto is_last = next == path_end;
if (segment == "." || segment == "..") {
if (segment == "..") {
path.erase((std::min)(path.rfind('/'), path.size()));
}
if (is_last) { path += '/'; }
} else {
path += '/';
path += segment;
}
i = next;
}
if (path.empty()) { path = "/"; }
return path + merged.substr(path_end);
return remove_dot_segments(merged.substr(0, path_end)) +
merged.substr(path_end);
}
} // namespace detail
@@ -3797,6 +3803,8 @@ template <typename T>
bool check_and_write_headers(Stream &strm, Headers &headers, T header_writer,
Error &error);
bool write_data(Stream &strm, const char *d, size_t l);
std::string trim_copy(const std::string &s);
void divide(
@@ -4496,7 +4504,7 @@ public:
// State accessors
bool is_connected() const;
const std::string &last_event_id() const;
std::string last_event_id() const;
// Blocking start - runs event loop with auto-reconnect
void start();
@@ -4534,7 +4542,9 @@ private:
// State
std::atomic<bool> running_{false};
std::atomic<bool> connected_{false};
// Written by the event loop, which start_async() runs on its own thread
std::string last_event_id_;
mutable std::mutex last_event_id_mutex_;
// Async support
std::thread async_thread_;
@@ -5032,7 +5042,8 @@ inline SSEClient &SSEClient::set_headers(const Headers &headers) {
inline bool SSEClient::is_connected() const { return connected_.load(); }
inline const std::string &SSEClient::last_event_id() const {
inline std::string SSEClient::last_event_id() const {
std::lock_guard<std::mutex> lock(last_event_id_mutex_);
return last_event_id_;
}
@@ -5207,7 +5218,10 @@ inline void SSEClient::run_event_loop() {
if (event_complete) {
// Update last_event_id for reconnection, even for an event that
// has no data. An empty id clears it.
if (has_id) { last_event_id_ = current_msg.id; }
if (has_id) {
std::lock_guard<std::mutex> lock(last_event_id_mutex_);
last_event_id_ = current_msg.id;
}
// An event without a data field is not dispatched
if (has_data) { dispatch_event(current_msg); }
@@ -5344,6 +5358,8 @@ cert_t get_peer_cert(const_session_t session);
// do not use them after free_session(), as with get_peer_cert().
size_t get_peer_certs(const_session_t session, std::vector<cert_t> &certs);
void free_cert(cert_t cert);
// Matches the host against the subjectAltName entries only, never the Common
// Name (RFC 9525).
bool verify_hostname(cert_t cert, const char *hostname);
uint64_t hostname_mismatch_code();
long get_verify_result(const_session_t session);
@@ -5813,32 +5829,40 @@ inline bool write_websocket_frame(Stream &strm, ws::Opcode opcode,
if (len < 126) {
header[1] = static_cast<uint8_t>(len);
if (mask) { header[1] |= 0x80; }
if (strm.write(reinterpret_cast<char *>(header), 2) < 0) { return false; }
if (!write_data(strm, reinterpret_cast<char *>(header), 2)) {
return false;
}
} else if (len <= 0xFFFF) {
header[1] = 126;
if (mask) { header[1] |= 0x80; }
if (strm.write(reinterpret_cast<char *>(header), 2) < 0) { return false; }
if (!write_data(strm, reinterpret_cast<char *>(header), 2)) {
return false;
}
uint8_t ext[2];
ext[0] = static_cast<uint8_t>((len >> 8) & 0xFF);
ext[1] = static_cast<uint8_t>(len & 0xFF);
if (strm.write(reinterpret_cast<char *>(ext), 2) < 0) { return false; }
if (!write_data(strm, reinterpret_cast<char *>(ext), 2)) { return false; }
} else {
header[1] = 127;
if (mask) { header[1] |= 0x80; }
if (strm.write(reinterpret_cast<char *>(header), 2) < 0) { return false; }
if (!write_data(strm, reinterpret_cast<char *>(header), 2)) {
return false;
}
uint8_t ext[8];
for (int i = 7; i >= 0; i--) {
ext[7 - i] =
static_cast<uint8_t>((static_cast<uint64_t>(len) >> (i * 8)) & 0xFF);
}
if (strm.write(reinterpret_cast<char *>(ext), 8) < 0) { return false; }
if (!write_data(strm, reinterpret_cast<char *>(ext), 8)) { return false; }
}
if (mask) {
// Generate random mask key
uint8_t mask_key[4];
fill_random_bytes(mask_key, sizeof(mask_key));
if (strm.write(reinterpret_cast<char *>(mask_key), 4) < 0) { return false; }
if (!write_data(strm, reinterpret_cast<char *>(mask_key), 4)) {
return false;
}
// Write masked payload in chunks
const size_t chunk_size = 4096;
@@ -5849,11 +5873,11 @@ inline bool write_websocket_frame(Stream &strm, ws::Opcode opcode,
buf[i] =
data[offset + i] ^ static_cast<char>(mask_key[(offset + i) % 4]);
}
if (strm.write(buf.data(), n) < 0) { return false; }
if (!write_data(strm, buf.data(), n)) { return false; }
}
} else {
if (len > 0) {
if (strm.write(data, len) < 0) { return false; }
if (!write_data(strm, data, len)) { return false; }
}
}
@@ -5989,9 +6013,10 @@ inline bool is_valid_close_payload(const std::string &payload) {
namespace detail {
// A path a mount point serves: absolute, with no empty segment except a
// trailing '/' and no segment ending in '.' or a space (which covers "." and
// ".." and names Windows trims into another file's name), so the file served
// is the one that req.path names to the handlers that ran before it.
// trailing '/', no segment ending in '.' or a space (which covers "." and
// ".." and names Windows trims into another file's name) and, on Windows, no
// ':' (which names an NTFS stream, so "a.txt::$DATA" is a.txt), so the file
// served is the one that req.path names to the handlers that ran before it.
inline bool is_valid_path(const std::string &path) {
if (path.empty() || path[0] != '/') { return false; }
@@ -5999,6 +6024,9 @@ inline bool is_valid_path(const std::string &path) {
for (auto i = beg; i <= path.size(); i++) {
if (i < path.size() && path[i] != '/') {
if (path[i] == '\0' || path[i] == '\\') { return false; }
#ifdef _WIN32
if (path[i] == ':') { return false; }
#endif
continue;
}
auto len = i - beg;
@@ -15967,7 +15995,7 @@ inline ClientImpl::StreamHandle ClientImpl::open_stream_(Request &req) {
}
if (!req.body.empty()) {
if (strm.write(req.body.data(), req.body.size()) < 0) {
if (!detail::write_data(strm, req.body.data(), req.body.size())) {
handle.error = Error::Write;
handle.response.reset();
return handle;
@@ -16018,7 +16046,7 @@ inline ClientImpl::StreamHandle ClientImpl::open_stream_(Request &req) {
auto content_encoding = detail::get_combined_header_value(
handle.response->headers, "Content-Encoding");
if (!content_encoding.empty()) {
if (decompress_ && !content_encoding.empty()) {
// Same policy as prepare_content_receiver(): reject a coding we know about
// but were not built with, pass an unrecognized one through as-is.
handle.decompressor_ = detail::create_decompressor(content_encoding);
@@ -16213,10 +16241,18 @@ inline bool ChunkedDecoder::parse_trailers_into(Headers &dest,
return parse_trailers(lr, dest, src_headers);
}
// A 3xx without a Location, such as a 304, is a final response
// Only 301, 302, 303, 307 and 308 redirect. Any other 3xx, such as a 304, or
// a redirect without a Location, is a final response.
inline bool is_redirect(const Response &res) {
return 300 < res.status && res.status < 400 &&
!res.get_header_value("Location").empty();
switch (res.status) {
case StatusCode::MovedPermanently_301:
case StatusCode::Found_302:
case StatusCode::SeeOther_303:
case StatusCode::TemporaryRedirect_307:
case StatusCode::PermanentRedirect_308:
return !res.get_header_value("Location").empty();
default: return false;
}
}
} // namespace detail
@@ -16278,9 +16314,10 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req,
// to call it from a different thread since it's a thread-safety issue
// to do these things to the socket if another thread is using the socket.
std::lock_guard<std::mutex> guard(socket_mutex_);
// Also when the request asked for it (e.g. a body that was never sent),
// so that a redirect or an auth retry below does not reuse the socket.
if (socket_should_be_closed_when_request_is_done_ ||
// Also when the request asked for it (e.g. a body that was never sent,
// or "Connection: close"), so that a redirect or an auth retry below does
// not reuse the socket.
if (socket_should_be_closed_when_request_is_done_ || close_connection ||
detail::has_header_token(res.headers, "Connection", "close") ||
(res.version == "HTTP/1.0" && res.reason != "Connection established")) {
disconnect(/*gracefully=*/true);
@@ -16374,6 +16411,10 @@ inline bool ClientImpl::redirect(Request &req, Response &res, Error &error) {
if (next_scheme.empty()) { next_scheme = scheme; }
if (next_host.empty()) { next_host = host_; }
if (next_path.empty()) { next_path = "/"; }
// An absolute or absolute-path location can have dot segments too.
if (next_path[0] == '/') {
next_path = detail::remove_dot_segments(next_path);
}
auto path = std::move(next_path) + next_query;
@@ -17025,8 +17066,7 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
}
}
auto redirect = follow_location_ && detail::is_redirect(res) &&
res.status != StatusCode::NotModified_304;
auto redirect = follow_location_ && detail::is_redirect(res);
if (req.response_handler && !redirect) {
if (!req.response_handler(res)) {
@@ -19582,15 +19622,75 @@ inline bool enumerate_windows_system_certs(Callback cb) {
#endif
#ifdef CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
// Enumerate macOS Keychain certificates and call callback with DER data
// The trust a certificate's Trust Settings give it for SSL server
// verification (SecTrustSettings.h). An empty array trusts it as a root.
// Otherwise the first usage-constraints dictionary that applies decides: one
// naming a policy other than SSL, a host name or an application does not
// apply, and a result left out means TrustRoot. Keychain Access writes "Never
// Trust" as kSecTrustSettingsResultDeny.
inline SecTrustSettingsResult
macos_ssl_trust_settings_result(CFArrayRef trust_settings) {
auto count = CFArrayGetCount(trust_settings);
if (count == 0) { return kSecTrustSettingsResultTrustRoot; }
for (CFIndex i = 0; i < count; i++) {
auto value = CFArrayGetValueAtIndex(trust_settings, i);
if (!value || CFGetTypeID(value) != CFDictionaryGetTypeID()) { continue; }
auto constraint = static_cast<CFDictionaryRef>(value);
auto policy_value =
CFDictionaryGetValue(constraint, kSecTrustSettingsPolicy);
if (policy_value) {
auto policy = static_cast<SecPolicyRef>(const_cast<void *>(policy_value));
auto is_ssl = false;
if (auto properties = SecPolicyCopyProperties(policy)) {
auto oid = CFDictionaryGetValue(properties, kSecPolicyOid);
is_ssl = oid && CFEqual(oid, kSecPolicyAppleSSL);
CFRelease(properties);
}
if (!is_ssl) { continue; }
}
if (CFDictionaryContainsKey(constraint, kSecTrustSettingsPolicyString) ||
CFDictionaryContainsKey(constraint, kSecTrustSettingsApplication)) {
continue;
}
auto result = kSecTrustSettingsResultTrustRoot;
auto number = static_cast<CFNumberRef>(
CFDictionaryGetValue(constraint, kSecTrustSettingsResult));
if (number) {
SInt32 number_value = 0;
if (!CFNumberGetValue(number, kCFNumberSInt32Type, &number_value)) {
continue;
}
result = static_cast<SecTrustSettingsResult>(number_value);
}
if (result == kSecTrustSettingsResultTrustRoot ||
result == kSecTrustSettingsResultTrustAsRoot ||
result == kSecTrustSettingsResultDeny) {
return result;
}
}
return kSecTrustSettingsResultUnspecified;
}
// Enumerate the macOS Keychain certificates trusted for SSL and call callback
// with DER data. A certificate with Trust Settings is not trusted just by
// having them: the settings say whether it is, and a per-user setting
// overrides the admin domain's, which overrides the system domain's (whose
// roots are all trusted). So a system root the user set to "Never Trust" is
// left out, as is one trusted only for S/MIME or code signing.
template <typename Callback>
inline bool enumerate_macos_keychain_certs(Callback cb) {
bool loaded = false;
const SecTrustSettingsDomain domains[] = {
kSecTrustSettingsDomainSystem,
kSecTrustSettingsDomainAdmin,
kSecTrustSettingsDomainUser,
kSecTrustSettingsDomainAdmin,
kSecTrustSettingsDomainSystem,
};
std::set<std::string> decided;
std::vector<std::string> trusted;
for (auto domain : domains) {
CFArrayRef certs = nullptr;
OSStatus status = SecTrustSettingsCopyCertificates(domain, &certs);
@@ -19603,16 +19703,43 @@ inline bool enumerate_macos_keychain_certs(Callback cb) {
SecCertificateRef cert =
(SecCertificateRef)CFArrayGetValueAtIndex(certs, i);
CFDataRef data = SecCertificateCopyData(cert);
if (data) {
if (cb(CFDataGetBytePtr(data),
static_cast<size_t>(CFDataGetLength(data)))) {
loaded = true;
if (!data) { continue; }
std::string der(reinterpret_cast<const char *>(CFDataGetBytePtr(data)),
static_cast<size_t>(CFDataGetLength(data)));
CFRelease(data);
// Decided by a domain that overrides this one
if (decided.find(der) != decided.end()) { continue; }
auto result = kSecTrustSettingsResultTrustRoot;
if (domain != kSecTrustSettingsDomainSystem) {
result = kSecTrustSettingsResultUnspecified;
CFArrayRef trust_settings = nullptr;
if (SecTrustSettingsCopyTrustSettings(cert, domain, &trust_settings) ==
errSecSuccess &&
trust_settings) {
result = macos_ssl_trust_settings_result(trust_settings);
}
CFRelease(data);
if (trust_settings) CFRelease(trust_settings);
}
// Neither trusted nor distrusted here; a domain below may decide
if (result == kSecTrustSettingsResultUnspecified) { continue; }
decided.insert(der);
if (result == kSecTrustSettingsResultTrustRoot ||
result == kSecTrustSettingsResultTrustAsRoot) {
trusted.push_back(std::move(der));
}
}
CFRelease(certs);
}
bool loaded = false;
for (const auto &der : trusted) {
if (cb(reinterpret_cast<const unsigned char *>(der.data()), der.size())) {
loaded = true;
}
}
return loaded;
}
#endif
@@ -20046,36 +20173,14 @@ inline bool load_system_certs(ctx_t ctx) {
auto store = SSL_CTX_get_cert_store(ssl_ctx);
if (!store) return false;
bool loaded_any = false;
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);
}
auto loaded_any = impl::enumerate_macos_keychain_certs(
[&](const unsigned char *data, size_t len) {
auto x509 = d2i_X509(nullptr, &data, static_cast<long>(len));
if (!x509) { return false; }
auto added = X509_STORE_add_cert(store, x509) == 1;
X509_free(x509);
return added;
});
return loaded_any || SSL_CTX_set_default_verify_paths(ssl_ctx) == 1;
#else
return SSL_CTX_set_default_verify_paths(ssl_ctx) == 1;
@@ -20189,10 +20294,12 @@ inline bool set_sni(session_t session, const char *hostname,
auto ssl = static_cast<SSL *>(session);
// The handshake's chain verification then checks the name as well
if (verify_hostname &&
X509_VERIFY_PARAM_set1_host(SSL_get0_param(ssl), hostname,
strlen(hostname)) != 1) {
return false;
if (verify_hostname) {
auto param = SSL_get0_param(ssl);
X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NEVER_CHECK_SUBJECT);
if (X509_VERIFY_PARAM_set1_host(param, hostname, strlen(hostname)) != 1) {
return false;
}
}
#if defined(OPENSSL_IS_BORINGSSL)
@@ -20441,7 +20548,8 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
if (detail::is_ip_address(hostname)) {
return X509_check_ip_asc(x509, hostname, 0) == 1;
}
return X509_check_host(x509, hostname, strlen(hostname), 0, nullptr) == 1;
return X509_check_host(x509, hostname, strlen(hostname),
X509_CHECK_FLAG_NEVER_CHECK_SUBJECT, nullptr) == 1;
}
inline uint64_t hostname_mismatch_code() {
@@ -21827,7 +21935,6 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
// Check Subject Alternative Names (SAN). Mbed TLS keeps the GeneralName type
// in buf.tag and the raw value in buf.p / buf.len.
auto has_dns_san = false;
for (auto san = &mcert->subject_alt_names; san; san = san->next) {
if (!san->buf.p || san->buf.len == 0) { continue; }
const unsigned char *p = san->buf.p;
@@ -21842,24 +21949,11 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
return true;
}
} else if (san_type == MBEDTLS_X509_SAN_DNS_NAME) {
has_dns_san = true;
std::string san_name(reinterpret_cast<const char *>(p), len);
if (detail::match_hostname(san_name, host_str)) { return true; }
}
}
// Fallback: the Common Names (CN) in the subject, as X509_check_host does it:
// never for an IP host, which only an iPAddress SAN names, and never next to
// a DNS SAN (RFC 6125 6.4.4).
if (is_ip || has_dns_san) { return false; }
for (auto name = &mcert->subject; name; name = name->next) {
if (MBEDTLS_OID_CMP(MBEDTLS_OID_AT_CN, &name->oid) == 0) {
std::string cn(reinterpret_cast<const char *>(name->val.p),
name->val.len);
if (detail::match_hostname(cn, host_str)) { return true; }
}
}
return false;
}
@@ -23007,7 +23101,6 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
auto is_ip = ip_len > 0;
// Check Subject Alternative Names
auto has_dns_san = false;
auto *san_names = static_cast<WOLF_STACK_OF(WOLFSSL_GENERAL_NAME) *>(
wolfSSL_X509_get_ext_d2i(x509, NID_subject_alt_name, nullptr, nullptr));
@@ -23020,7 +23113,6 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
if (!is_ip && names->type == WOLFSSL_GEN_DNS) {
// DNS name
has_dns_san = true;
unsigned char *dns_name = nullptr;
int dns_len = wolfSSL_ASN1_STRING_to_UTF8(&dns_name, names->d.dNSName);
if (dns_name && dns_len > 0) {
@@ -23047,12 +23139,7 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
wolfSSL_sk_free(san_names);
}
// Fallback: the Common Name (CN) in the subject, as X509_check_host does it:
// never for an IP host, which only an iPAddress SAN names, and never next to
// a DNS SAN (RFC 6125 6.4.4).
if (is_ip || has_dns_san) { return false; }
auto cn = get_cert_subject_cn(cert);
return !cn.empty() && detail::match_hostname(cn, host_str);
return false;
}
inline uint64_t hostname_mismatch_code() {
+8
View File
@@ -153,6 +153,14 @@ if(HTTPLIB_IS_USING_OPENSSL)
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMAND_ERROR_IS_FATAL ANY
)
# cert_dns_cn.pem: CN is the DNS name the tests connect to, with NO
# subjectAltName. A host is named by a SAN only (RFC 9525),
# so verifying it against this cert must fail.
execute_process(
COMMAND ${OPENSSL_COMMAND} req -x509 -key key.pem -sha256 -days 3650 -nodes -subj /CN=localhost -out cert_dns_cn.pem
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMAND_ERROR_IS_FATAL ANY
)
# cert_wildcard_san.pem: a leftmost wildcard next to one that is not
# leftmost. "*.leftmost.example.test" matches a single
# label; the wildcard in "www.*.example.test" must not be
+5
View File
@@ -38,6 +38,11 @@ openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=::1" -addext
# DNS:a.zz is 97.46.122.122, IP:42.46.122.122 is "*.zz".
openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=san-types" -addext "subjectAltName=DNS:a.zz,IP:42.46.122.122" -out cert_san_types.pem
# cert_dns_cn.pem: CN is the DNS name the tests connect to, with NO
# subjectAltName. A host is named by a SAN only (RFC 9525), so
# verifying it against this cert must fail.
openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=localhost" -out cert_dns_cn.pem
# cert_wildcard_san.pem: a leftmost wildcard next to one that is not leftmost.
# "*.leftmost.example.test" matches a single label; the
# wildcard in "www.*.example.test" must not be honoured.
+11
View File
@@ -146,6 +146,16 @@ cert_san_types_pem = custom_target(
command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=san-types', '-addext', 'subjectAltName=DNS:a.zz,IP:42.46.122.122', '-out', '@OUTPUT@']
)
# cert_dns_cn.pem: CN is the DNS name the tests connect to, with NO
# subjectAltName. A host is named by a SAN only (RFC 9525), so verifying it
# against this cert must fail.
cert_dns_cn_pem = custom_target(
'cert_dns_cn_pem',
input: key_pem,
output: 'cert_dns_cn.pem',
command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=localhost', '-out', '@OUTPUT@']
)
# cert_wildcard_san.pem: a leftmost wildcard next to one that is not leftmost.
# "*.leftmost.example.test" matches a single label; the wildcard in
# "www.*.example.test" must not be honoured.
@@ -197,6 +207,7 @@ test(
client_encrypted_pbes1_key_pem,
client_encrypted_cert_pem,
cert_ip_cn_pem,
cert_dns_cn_pem,
cert_ipv6_pem,
cert_san_types_pem,
cert_wildcard_san_pem
+487 -18
View File
@@ -41,6 +41,7 @@ inline std::string u8_to_string(const char8_t *s) {
#define SERVER_CERT_FILE "./cert.pem"
#define SERVER_CERT2_FILE "./cert2.pem"
#define SERVER_CERT_IP_CN_FILE "./cert_ip_cn.pem"
#define SERVER_CERT_DNS_CN_FILE "./cert_dns_cn.pem"
#define SERVER_CERT_IPV6_FILE "./cert_ipv6.pem"
#define SERVER_CERT_SAN_TYPES_FILE "./cert_san_types.pem"
#define SERVER_CERT_WILDCARD_SAN_FILE "./cert_wildcard_san.pem"
@@ -71,6 +72,12 @@ namespace tls {
ca_store_t create_ca_store(const char *pem, size_t len);
bool set_verify_callback(ctx_t ctx, VerifyCallback callback);
std::vector<std::string> get_ca_names(ctx_t ctx);
#ifdef CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
namespace impl {
SecTrustSettingsResult
macos_ssl_trust_settings_result(CFArrayRef trust_settings);
} // namespace impl
#endif
} // namespace tls
} // namespace httplib
#endif
@@ -700,6 +707,18 @@ TEST(IsValidPathTest, RejectsSegmentsEndingInDotOrSpace) {
}
}
// ':' names an NTFS stream on Windows and is an ordinary character in a file
// name anywhere else.
TEST(IsValidPathTest, ColonIsRejectedOnlyOnWindows) {
for (auto path : {"/a.txt::$DATA", "/a.txt:Zone.Identifier", "/dir:x/a"}) {
#ifdef _WIN32
EXPECT_FALSE(detail::is_valid_path(path)) << path;
#else
EXPECT_TRUE(detail::is_valid_path(path)) << path;
#endif
}
}
TEST(CanCompressContentTypeTest, ComparesWholeTypes) {
EXPECT_TRUE(detail::can_compress_content_type("application/json"));
EXPECT_TRUE(detail::can_compress_content_type("text/plain; charset=utf-8"));
@@ -4255,6 +4274,48 @@ TEST(RedirectToDifferentPort, TrailingCharactersInPort) {
EXPECT_FALSE(res);
}
TEST(RedirectTest, OtherStatusesWithLocationAreNotFollowed) {
Server svr;
std::atomic<bool> followed{false};
svr.Get("/304", [](const Request & /*req*/, Response &res) {
res.status = StatusCode::NotModified_304;
res.set_header("Location", "/target");
});
svr.Get("/305", [](const Request & /*req*/, Response &res) {
res.status = StatusCode::UseProxy_305;
res.set_header("Location", "/target");
});
svr.Get("/target", [&](const Request & /*req*/, Response &res) {
followed = true;
res.set_content("target", "text/plain");
});
auto th = std::thread([&]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
th.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
cli.set_follow_location(true);
auto res = cli.Get("/304");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotModified_304, res->status);
res = cli.Get("/305");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::UseProxy_305, res->status);
EXPECT_FALSE(followed);
}
TEST(RedirectFromPageWithContent, Redirect) {
Server svr;
@@ -4306,6 +4367,75 @@ TEST(RedirectFromPageWithContent, Redirect) {
}
}
// A server can close the connection after a request with "Connection: close"
// without repeating the option in its response (RFC 9112 Section 9.6), so the
// redirected request must go out on a new connection.
TEST(RedirectTest, NewConnectionAfterConnectionClose) {
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
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);
ASSERT_EQ(0, ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, ::listen(srv, 2));
auto send_text = [](socket_t sock, const std::string &text) {
::send(sock,
#ifdef _WIN32
static_cast<const char *>(text.c_str()),
static_cast<int>(text.size()),
#else
text.c_str(), text.size(),
#endif
0);
};
auto reused = false;
auto server_thread = std::thread([&] {
char buf[4096];
auto first = ::accept(srv, nullptr, nullptr);
if (first == INVALID_SOCKET) { return; }
detail::set_socket_opt_time(first, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
::recv(first, buf, sizeof(buf), 0);
send_text(
first,
"HTTP/1.1 302 Found\r\nLocation: /2\r\nContent-Length: 0\r\n\r\n");
auto next = first;
if (::recv(first, buf, sizeof(buf), 0) > 0) {
reused = true;
} else {
detail::close_socket(first);
next = ::accept(srv, nullptr, nullptr);
if (next == INVALID_SOCKET) { return; }
detail::set_socket_opt_time(next, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
::recv(next, buf, sizeof(buf), 0);
}
send_text(next, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n"
"Connection: close\r\n\r\nok");
detail::close_socket(next);
});
Client cli("127.0.0.1", PORT + 2);
cli.set_follow_location(true);
cli.set_read_timeout(5, 0);
auto res = cli.Get("/1");
server_thread.join();
detail::close_socket(srv);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
EXPECT_FALSE(reused);
}
TEST(RedirectFromPageWithContentIP6, Redirect) {
Server svr;
@@ -12642,7 +12772,8 @@ TEST(MountTest, AliasedPathDoesNotPassPreRoutingGuard) {
for (auto path :
{"/./dir/test.html", "/dir/./test.html", "//dir/test.html",
"/dir//test.html", "/dir/../dir/test.html", "/%2e/dir/test.html",
"/dir/test.html.", "/dir/test.html%20"}) {
"/dir/test.html.", "/dir/test.html%20", "/dir/test.html::$DATA",
"/dir/test.html%3A%3A$DATA"}) {
SCOPED_TRACE(path);
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
@@ -16578,8 +16709,7 @@ TEST(SSLClientServerTest, CustomizeServerSSLCtxGeneric) {
ASSERT_EQ(StatusCode::OK_200, res->status);
}
// Test verify_hostname for both OpenSSL and MbedTLS backends
// Verifies that wildcard matching and exact matching work consistently
// Test verify_hostname on every backend: a host is named by a SAN only
TEST(SSLClientServerTest, TlsVerifyHostname) {
using namespace httplib::tls;
@@ -16613,7 +16743,7 @@ TEST(SSLClientServerTest, TlsVerifyHostname) {
if (!ctx.cert) return false;
// Test 1: "Common Name" should match (our test server cert CN)
// Test 1: "Common Name" is the CN of our test server cert, which has no SAN
verify_result_cn = ctx.check_hostname("Common Name");
// Test 2: wrong hostname should not match
@@ -16629,27 +16759,50 @@ TEST(SSLClientServerTest, TlsVerifyHostname) {
ASSERT_TRUE(verify_callback_called)
<< "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
<< "verify_hostname must not match the certificate CN (no SAN)";
// Wrong hostname should not match
EXPECT_FALSE(verify_result_wrong)
<< "verify_hostname should not match 'wronghost.example.com'";
}
// A DNS name in the CN must not authenticate a host (RFC 9525), whichever
// backend verifies it.
TEST(SSLClientServerTest, TlsVerifyHostnameDnsNotMatchedByCommonName) {
// Certificate CN is "localhost" and it carries no SAN.
SSLServer svr(SERVER_CERT_DNS_CN_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
SSLClient cli("localhost", port);
cli.set_ca_cert_path(SERVER_CERT_DNS_CN_FILE);
cli.enable_server_certificate_verification(true);
cli.set_connection_timeout(5);
auto res = cli.Get("/test");
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLServerHostnameVerification, res.error());
// The same certificate is accepted once host name verification is off, so the
// failure above is the host name and not the chain.
cli.enable_server_hostname_verification(false);
res = cli.Get("/test");
ASSERT_TRUE(res) << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
// An IP-literal host must only be authenticated via an iPAddress SAN, never via
// the certificate's Common Name (RFC 9110). This mirrors the OpenSSL backend's
// X509_check_ip behavior and must hold for every backend.
@@ -18169,6 +18322,98 @@ TEST(SSLClientTest, EnableSystemCaCustomCaVerifiesLocalServer) {
ASSERT_EQ(StatusCode::OK_200, res->status);
}
#ifdef CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN
// A usage-constraints dictionary of a Keychain Trust Settings array, with
// the keys SecTrustSettingsCopyTrustSettings() returns.
static CFDictionaryRef
make_usage_constraint(SecPolicyRef policy, const char *policy_string,
const SecTrustSettingsResult *result) {
auto constraint =
CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
if (policy) {
CFDictionarySetValue(constraint, kSecTrustSettingsPolicy, policy);
}
if (policy_string) {
auto str = CFStringCreateWithCString(nullptr, policy_string,
kCFStringEncodingUTF8);
CFDictionarySetValue(constraint, kSecTrustSettingsPolicyString, str);
CFRelease(str);
}
if (result) {
auto number_value = static_cast<SInt32>(*result);
auto number = CFNumberCreate(nullptr, kCFNumberSInt32Type, &number_value);
CFDictionarySetValue(constraint, kSecTrustSettingsResult, number);
CFRelease(number);
}
return constraint;
}
static SecTrustSettingsResult
ssl_trust_settings_result(std::vector<CFDictionaryRef> constraints) {
auto trust_settings =
CFArrayCreateMutable(nullptr, 0, &kCFTypeArrayCallBacks);
for (auto constraint : constraints) {
CFArrayAppendValue(trust_settings, constraint);
CFRelease(constraint);
}
auto result = tls::impl::macos_ssl_trust_settings_result(trust_settings);
CFRelease(trust_settings);
return result;
}
// The system CA loader takes a Keychain certificate as a trusted root only
// when its Trust Settings say so for SSL, not merely because it has some.
TEST(SSLClientTest, MacOSKeychainTrustSettingsForSSL) {
const auto trust_root = kSecTrustSettingsResultTrustRoot;
const auto trust_as_root = kSecTrustSettingsResultTrustAsRoot;
const auto deny = kSecTrustSettingsResultDeny;
const auto unspecified = kSecTrustSettingsResultUnspecified;
auto ssl_policy = SecPolicyCreateSSL(true, nullptr);
auto x509_policy = SecPolicyCreateBasicX509();
// "Always Trust", which stores no constraint at all
EXPECT_EQ(kSecTrustSettingsResultTrustRoot, ssl_trust_settings_result({}));
EXPECT_EQ(kSecTrustSettingsResultTrustRoot,
ssl_trust_settings_result(
{make_usage_constraint(nullptr, nullptr, nullptr)}));
EXPECT_EQ(kSecTrustSettingsResultTrustRoot,
ssl_trust_settings_result(
{make_usage_constraint(ssl_policy, nullptr, &trust_root)}));
EXPECT_EQ(kSecTrustSettingsResultTrustAsRoot,
ssl_trust_settings_result(
{make_usage_constraint(ssl_policy, nullptr, &trust_as_root)}));
// "Never Trust", for everything or for SSL
EXPECT_EQ(kSecTrustSettingsResultDeny,
ssl_trust_settings_result(
{make_usage_constraint(nullptr, nullptr, &deny)}));
EXPECT_EQ(kSecTrustSettingsResultDeny,
ssl_trust_settings_result(
{make_usage_constraint(ssl_policy, nullptr, &deny)}));
// Trusted for another policy, for one host, or not at all
EXPECT_EQ(kSecTrustSettingsResultUnspecified,
ssl_trust_settings_result(
{make_usage_constraint(x509_policy, nullptr, &trust_root)}));
EXPECT_EQ(kSecTrustSettingsResultUnspecified,
ssl_trust_settings_result({make_usage_constraint(
ssl_policy, "www.example.com", &trust_root)}));
EXPECT_EQ(kSecTrustSettingsResultUnspecified,
ssl_trust_settings_result(
{make_usage_constraint(nullptr, nullptr, &unspecified)}));
// The first constraint that applies to SSL decides
EXPECT_EQ(kSecTrustSettingsResultDeny,
ssl_trust_settings_result(
{make_usage_constraint(x509_policy, nullptr, &trust_root),
make_usage_constraint(nullptr, nullptr, &deny)}));
CFRelease(x509_policy);
CFRelease(ssl_policy);
}
#endif
// Regression test: for IP hosts the Mbed TLS and wolfSSL backends used to
// skip chain verification entirely (only the certificate identity was
// checked), so a server presenting an untrusted certificate with a matching
@@ -20348,6 +20593,51 @@ TEST(RedirectTest, ResolveRelativeLocation) {
}
}
TEST(RedirectTest, RemoveDotSegments) {
// RFC 3986 section 5.2.4
EXPECT_EQ("/a/g", detail::remove_dot_segments("/a/b/c/./../../g"));
EXPECT_EQ("/mid/6", detail::remove_dot_segments("/mid/content=5/../6"));
EXPECT_EQ("/g", detail::remove_dot_segments("/./g"));
EXPECT_EQ("/g", detail::remove_dot_segments("/../g"));
EXPECT_EQ("/", detail::remove_dot_segments("/.."));
EXPECT_EQ("/a/", detail::remove_dot_segments("/a/b/.."));
EXPECT_EQ("/a/b", detail::remove_dot_segments("/a/b"));
}
TEST(RedirectTest, AbsoluteLocationIsNormalized) {
Server svr;
svr.Get("/absolute-path", [](const Request & /*req*/, Response &res) {
res.set_redirect("/dir/../target?x=1");
});
svr.Get("/absolute-uri", [](const Request & /*req*/, Response &res) {
res.set_redirect("http://localhost:" + std::to_string(PORT) +
"/dir/./../target");
});
svr.Get("/target", [](const Request &req, Response &res) {
res.set_content(req.path, "text/plain");
});
auto th = std::thread([&]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
th.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
cli.set_follow_location(true);
for (auto path : {"/absolute-path", "/absolute-uri"}) {
auto res = cli.Get(path);
ASSERT_TRUE(res) << path << ": " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status) << path;
EXPECT_EQ("/target", res->body) << path;
}
}
TEST(RedirectTest, RelativeLocationWithoutLeadingSlash) {
Server svr;
@@ -23627,6 +23917,55 @@ TEST(OpenStreamMalformedContentLength, OutOfRange) {
server_thread.join();
}
#ifndef _WIN32
// As above for the body of a streamed request: a send() cut short by the write
// timeout must not leave the rest of the body unsent.
TEST(OpenStreamPartialWriteTest, BodyIsWrittenWhole) {
signal(SIGPIPE, SIG_IGN);
Server svr;
svr.set_socket_options([](socket_t sock) {
int size = 8192;
setsockopt(sock, SOL_SOCKET, SO_RCVBUF,
reinterpret_cast<const char *>(&size), sizeof(size));
});
svr.Post("/size", [](const Request &, Response &res,
const ContentReader &content_reader) {
// Outlast the client's write timeout before reading the body.
std::this_thread::sleep_for(std::chrono::milliseconds(750));
size_t received = 0;
content_reader([&](const char *, size_t len) {
received += len;
return true;
});
res.set_content(std::to_string(received), "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_write_timeout(0, 500000);
cli.set_socket_options([](socket_t sock) {
int size = 8192;
setsockopt(sock, SOL_SOCKET, SO_SNDBUF,
reinterpret_cast<const char *>(&size), sizeof(size));
});
const std::string body(4 * 1024 * 1024, 'A');
auto handle = cli.open_stream("POST", "/size", {}, {}, body,
"application/octet-stream");
ASSERT_TRUE(handle.is_valid()) << to_string(handle.error);
EXPECT_EQ(StatusCode::OK_200, handle.response->status);
EXPECT_EQ(std::to_string(body.size()), read_all(handle));
}
#endif
// Serves `response` to the single request `fn` makes with a fresh client.
template <typename Fn>
static void with_single_response(const std::string &response, Fn fn) {
@@ -23645,6 +23984,37 @@ static void with_single_response(const std::string &response, Fn fn) {
fn(cli);
}
// set_decompress(false) hands out the encoded bytes through the streaming APIs
// too, also in a build without the decoder for the advertised coding.
TEST(StreamDecompressionTest, DisabledPreservesEncodedBody) {
const std::string gzipped(GZIPPED_HELLO_WORLD, sizeof(GZIPPED_HELLO_WORLD));
const auto response = "HTTP/1.1 200 OK\r\n"
"Content-Encoding: gzip\r\n"
"Content-Length: " +
std::to_string(gzipped.size()) +
"\r\n"
"Connection: close\r\n\r\n" +
gzipped;
with_single_response(response, [&](Client &cli) {
cli.set_decompress(false);
auto handle = cli.open_stream("GET", "/");
ASSERT_TRUE(handle.is_valid()) << to_string(handle.error);
EXPECT_EQ("gzip", handle.response->get_header_value("Content-Encoding"));
EXPECT_EQ(gzipped, read_all(handle));
EXPECT_FALSE(handle.has_read_error());
});
with_single_response(response, [&](Client &cli) {
cli.set_decompress(false);
auto res = stream::Get(cli, "/", 7);
ASSERT_TRUE(res) << to_string(res.error());
EXPECT_EQ("gzip", res.get_header_value("Content-Encoding"));
EXPECT_EQ(gzipped, res.read_all());
EXPECT_FALSE(res.has_read_error());
});
}
TEST(StreamBodyEndTest, BodyEndingAtConnectionClose) {
// Neither Content-Length nor chunked: the body ends at the close
const char *close_delimited = "HTTP/1.1 200 OK\r\n"
@@ -26147,6 +26517,47 @@ TEST_F(SSEIntegrationTest, LastEventIdHeader) {
EXPECT_EQ(sse.last_event_id(), "evt-123");
}
// Test: last_event_id() read from another thread while start_async() runs
TEST_F(SSEIntegrationTest, LastEventIdWhileRunning) {
// Ids of every length up to 200, so the stored id keeps changing size
server_->Get("/many-ids", [](const Request &, Response &res) {
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string events;
for (size_t i = 0; i < 2000; i++) {
events += "id: " + std::string(1 + i % 200, 'a') + "\n";
events += "data: x\n\n";
}
events += "id: end\ndata: x\n\n";
sink.write(events.data(), events.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/many-ids");
// Wait out the rest of the test rather than replay the stream
sse.set_reconnect_interval(10000);
sse.set_max_reconnect_attempts(1);
sse.start_async();
auto well_formed = true;
std::string id;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (id != "end" && std::chrono::steady_clock::now() < deadline) {
id = sse.last_event_id();
if (id != "end" && id.find_first_not_of('a') != std::string::npos) {
well_formed = false;
}
}
sse.stop();
EXPECT_TRUE(well_formed);
EXPECT_EQ(sse.last_event_id(), "end");
}
// Test: Manual stop
TEST_F(SSEIntegrationTest, ManualStop) {
// Endpoint that sends one event and stays open briefly. The server outlives
@@ -27932,6 +28343,64 @@ TEST_F(WebSocketIntegrationTest, ChronoTimeoutSetters) {
EXPECT_LT(elapsed, 30000);
}
#ifndef _WIN32
// A send() that times out after part of the data went out returns the count
// written so far. Every byte of the frame must still be sent, or the peer reads
// the next frame's header and payload as the rest of this one.
TEST(WebSocketPartialWriteTest, FrameIsWrittenWhole) {
signal(SIGPIPE, SIG_IGN);
const std::string large(4 * 1024 * 1024, 'A');
std::atomic<bool> large_sent{false};
std::atomic<bool> small_sent{false};
Server svr;
svr.set_write_timeout(0, 500000);
svr.set_socket_options([](socket_t sock) {
int size = 8192;
setsockopt(sock, SOL_SOCKET, SO_SNDBUF,
reinterpret_cast<const char *>(&size), sizeof(size));
});
svr.WebSocket("/ws", [&](const Request &, ws::WebSocket &ws) {
large_sent = ws.send(large.data(), large.size());
small_sent = ws.send("after");
std::string msg;
while (ws.read(msg)) {}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
ws::WebSocketClient client("ws://" + std::string(HOST) + ":" +
std::to_string(port) + "/ws");
client.set_read_timeout(5);
client.set_socket_options([](socket_t sock) {
int size = 8192;
setsockopt(sock, SOL_SOCKET, SO_RCVBUF,
reinterpret_cast<const char *>(&size), sizeof(size));
});
ASSERT_TRUE(client.connect());
// Outlast the server's write timeout, then drain.
std::this_thread::sleep_for(std::chrono::milliseconds(750));
std::string msg;
EXPECT_EQ(ws::ReadResult::Binary, client.read(msg));
EXPECT_TRUE(msg == large);
EXPECT_EQ(ws::ReadResult::Text, client.read(msg));
EXPECT_EQ("after", msg);
EXPECT_TRUE(large_sent);
EXPECT_TRUE(small_sent);
client.close();
}
#endif
TEST(WebSocketPreRoutingTest, RejectWithoutAuth) {
Server svr;