Compare commits

...
8 Commits
Author SHA1 Message Date
yhirose 17eeb18feb Release v0.60.0 2026-10-06 22:57:42 -04:00
metsw24-maxandyhirose edc9760005 use the SAN type tag in Mbed TLS verify_hostname and get_cert_sans (#2614)
* use the SAN type tag in Mbed TLS verify_hostname and get_cert_sans

Mbed TLS keeps a subjectAltName entry's GeneralName tag in buf.tag and the bare value in buf.p / buf.len. verify_hostname ignored the tag, so a dNSName whose bytes equal an address authenticated that IP host, and an iPAddress or rfc822Name was matched as a DNS pattern. get_cert_sans looked for the tag inside the value, so it reported no entries for an ordinary certificate, or part of a dNSName as an entry of its own.

* Shorten the SAN type comments

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-10-05 21:13:50 -04:00
yhirose 6d1049462d Ignore a subprotocol the WebSocket client did not offer
A SubProtocolSelector could return a value outside the client's
Sec-WebSocket-Protocol list, and the server sent it back as is. The
server now treats such a value as no selection.
2026-10-04 18:34:21 -04:00
metsw24-maxandyhirose 4fcbc08f2d reject an unoffered subprotocol in read_websocket_upgrade_response (#2595)
* reject an unoffered subprotocol in the ws client handshake

* test the subprotocol check through WebSocketClient so the split build compiles

* Trim comments in the subprotocol check

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-10-04 18:21:39 -04:00
Jordan Woyak d59f3e438c Remove semantically redundant closed_ checks in ping thread. (#2613) 2026-10-04 09:39:24 -04:00
yhirose 09fa6820fe Fix 303 redirect request bodies and Location path decoding (Fix #2606, #2607)
A 303 response turns the follow-up request into a GET, but only the buffered body and headers were cleared. A content provider (sized or chunked) stayed on the request, so the original payload was sent again, and for a chunked provider the unframed chunks also broke the keep-alive connection.

The redirect also percent-decoded the Location path before sending it. That turned %23, %3F and %25 into a fragment, a query delimiter and a different octet, so the client requested a different resource than the one named, and made set_path_encode(false) fail on any Location containing %20. The path is now sent as given.
2026-10-03 19:06:45 -04:00
yhirose 438319cfcb Fix WebSocket pings being sent early on spurious wakeups
The heartbeat thread waited on ping_cv_ without a predicate, so a
spurious wakeup ended the wait early and sent a ping before
ping_interval_sec_ had elapsed. With max_missed_pongs enabled, the
early ping also counted toward the pong timeout.

Pass a predicate to wait_for so that the wait only ends when the
interval elapses or the connection is closed.

Reported in #2612.
2026-10-03 17:47:30 -04:00
yhirose eda9a10bfe Fix SSE client not clearing Last-Event-ID on an empty id field (Fix #2611)
An event with an empty id field must reset the last event ID, so that
no Last-Event-ID header is sent on reconnect. run_event_loop only
updated last_event_id_ when the id was non-empty, so it could not tell
an empty id field from an event with no id field, and kept sending the
stale ID.

Track whether an id field was seen with a has_id flag, as has_data
does for the data field, and add a regression test.
2026-10-03 17:29:33 -04:00
7 changed files with 470 additions and 97 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ using SubProtocolSelector =
std::function<std::string(const std::vector<std::string> &protocols)>; std::function<std::string(const std::vector<std::string> &protocols)>;
``` ```
The `SubProtocolSelector` receives the list of subprotocols proposed by the client (from the `Sec-WebSocket-Protocol` header) and returns the selected one. Return an empty string to decline all proposed subprotocols. The `SubProtocolSelector` receives the list of subprotocols proposed by the client (from the `Sec-WebSocket-Protocol` header) and returns the selected one. Return an empty string to decline all proposed subprotocols. A returned value that the client did not propose is ignored.
### WebSocket (Server-side) ### WebSocket (Server-side)
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site] [site]
title = "cpp-httplib" title = "cpp-httplib"
version = "0.59.0" version = "0.60.0"
hostname = "https://yhirose.github.io" hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib" base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved." footer_message = "© 2026 Yuji Hirose. All rights reserved."
+98 -89
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H #ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.59.0" #define CPPHTTPLIB_VERSION "0.60.0"
#define CPPHTTPLIB_VERSION_NUM "0x003b00" #define CPPHTTPLIB_VERSION_NUM "0x003c00"
#ifdef _WIN32 #ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00 #if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -4377,7 +4377,7 @@ public:
private: private:
bool parse_sse_line(const std::string &line, SSEMessage &msg, int &retry_ms, bool parse_sse_line(const std::string &line, SSEMessage &msg, int &retry_ms,
bool &has_data); bool &has_data, bool &has_id);
void run_event_loop(); void run_event_loop();
void dispatch_event(const SSEMessage &msg); void dispatch_event(const SSEMessage &msg);
bool should_reconnect(int count) const; bool should_reconnect(int count) const;
@@ -4887,7 +4887,8 @@ inline void SSEClient::stop() {
} }
inline bool SSEClient::parse_sse_line(const std::string &line, SSEMessage &msg, inline bool SSEClient::parse_sse_line(const std::string &line, SSEMessage &msg,
int &retry_ms, bool &has_data) { int &retry_ms, bool &has_data,
bool &has_id) {
// Blank line signals end of event // Blank line signals end of event
if (line.empty()) { return true; } if (line.empty()) { return true; }
@@ -4917,6 +4918,7 @@ inline bool SSEClient::parse_sse_line(const std::string &line, SSEMessage &msg,
} else if (field == "id") { } else if (field == "id") {
// Empty id is valid (clears the last event ID) // Empty id is valid (clears the last event ID)
msg.id = value; msg.id = value;
has_id = true;
} else if (field == "retry") { } else if (field == "retry") {
// Parse retry interval in milliseconds // Parse retry interval in milliseconds
// Per the SSE spec, a value that is not all ASCII digits is ignored. // Per the SSE spec, a value that is not all ASCII digits is ignored.
@@ -4987,7 +4989,8 @@ inline void SSEClient::run_event_loop() {
// Event receiving loop // Event receiving loop
std::string buffer; std::string buffer;
SSEMessage current_msg; SSEMessage current_msg;
bool has_data = false; auto has_data = false;
auto has_id = false;
while (running_.load() && result.next()) { while (running_.load() && result.next()) {
buffer.append(result.data(), result.size()); buffer.append(result.data(), result.size());
@@ -5006,13 +5009,13 @@ inline void SSEClient::run_event_loop() {
if (!line.empty() && line.back() == '\r') { line.pop_back(); } if (!line.empty() && line.back() == '\r') { line.pop_back(); }
// Parse the line and check if event is complete // Parse the line and check if event is complete
auto event_complete = auto event_complete = parse_sse_line(
parse_sse_line(line, current_msg, reconnect_interval_ms_, has_data); line, current_msg, reconnect_interval_ms_, has_data, has_id);
if (event_complete) { if (event_complete) {
// Update last_event_id for reconnection, even for an event that // Update last_event_id for reconnection, even for an event that
// has no data // has no data. An empty id clears it.
if (!current_msg.id.empty()) { last_event_id_ = current_msg.id; } if (has_id) { last_event_id_ = current_msg.id; }
// An event without a data field is not dispatched // An event without a data field is not dispatched
if (has_data) { dispatch_event(current_msg); } if (has_data) { dispatch_event(current_msg); }
@@ -5020,6 +5023,7 @@ inline void SSEClient::run_event_loop() {
// Reset the message for the next event either way // Reset the message for the next event either way
current_msg.clear(); current_msg.clear();
has_data = false; has_data = false;
has_id = false;
} }
} }
@@ -8268,9 +8272,11 @@ struct WebSocketUpgradeResponse {
std::string selected_subprotocol; std::string selected_subprotocol;
}; };
inline bool read_websocket_upgrade_response(Stream &strm, inline bool
const std::string &expected_accept, read_websocket_upgrade_response(Stream &strm,
WebSocketUpgradeResponse &upgrade) { const std::string &expected_accept,
const std::string &offered_subprotocols,
WebSocketUpgradeResponse &upgrade) {
// Read status line // Read status line
const auto bufsiz = 2048; const auto bufsiz = 2048;
char buf[bufsiz]; char buf[bufsiz];
@@ -8327,6 +8333,22 @@ inline bool read_websocket_upgrade_response(Stream &strm,
upgrade.selected_subprotocol = proto_it->second; upgrade.selected_subprotocol = proto_it->second;
} }
// Verify the subprotocol is one the client offered (RFC 6455 4.1)
if (!upgrade.selected_subprotocol.empty()) {
auto was_offered = false;
split(offered_subprotocols.data(),
offered_subprotocols.data() + offered_subprotocols.size(), ',',
[&](const char *b, const char *e) {
if (std::string(b, e) == upgrade.selected_subprotocol) {
was_offered = true;
}
});
if (!was_offered) {
upgrade.error = Error::WebSocketHandshake;
return false;
}
}
return true; return true;
} }
@@ -8972,6 +8994,9 @@ inline bool redirect(T &cli, Request &req, Response &res,
new_req.method = "GET"; new_req.method = "GET";
new_req.body.clear(); new_req.body.clear();
new_req.headers.clear(); new_req.headers.clear();
new_req.content_length_ = 0;
new_req.content_provider_ = nullptr;
new_req.is_chunked_content_provider_ = false;
} }
Response new_res; Response new_res;
@@ -10321,7 +10346,10 @@ inline bool perform_websocket_handshake(Stream &strm, Request &req,
// Verify 101 response and Sec-WebSocket-Accept header // Verify 101 response and Sec-WebSocket-Accept header
auto expected_accept = websocket_accept_key(client_key); auto expected_accept = websocket_accept_key(client_key);
return read_websocket_upgrade_response(strm, expected_accept, upgrade); auto offered_subprotocols =
get_combined_header_value(req.headers, "Sec-WebSocket-Protocol");
return read_websocket_upgrade_response(strm, expected_accept,
offered_subprotocols, upgrade);
} }
inline bool is_ip_address(const std::string &host) { inline bool is_ip_address(const std::string &host) {
@@ -14664,6 +14692,12 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
protocols.emplace_back(b, e); protocols.emplace_back(b, e);
}); });
selected_subprotocol = entry.sub_protocol_selector(protocols); selected_subprotocol = entry.sub_protocol_selector(protocols);
// Ignore a selection the client did not offer (RFC 6455 4.2.2)
if (std::find(protocols.begin(), protocols.end(),
selected_subprotocol) == protocols.end()) {
selected_subprotocol.clear();
}
} }
} }
@@ -15734,7 +15768,7 @@ inline bool ClientImpl::redirect(Request &req, Response &res, Error &error) {
if (next_host.empty()) { next_host = host_; } if (next_host.empty()) { next_host = host_; }
if (next_path.empty()) { next_path = "/"; } if (next_path.empty()) { next_path = "/"; }
auto path = decode_path_component(next_path) + next_query; auto path = std::move(next_path) + next_query;
// Same host redirect - use current client // Same host redirect - use current client
if (next_scheme == scheme && next_host == host_ && next_port == port_) { if (next_scheme == scheme && next_host == host_ && next_port == port_) {
@@ -15989,9 +16023,9 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req,
// Write request line and headers // Write request line and headers
if (detail::write_request_line(bstrm, req.method, path_with_query) < 0) { if (detail::write_request_line(bstrm, req.method, path_with_query) < 0) {
// A rejected method (not a token, e.g. carrying CR/LF) or target (e.g. // A rejected method (not a token, e.g. carrying CR/LF) or target (e.g.
// CR/LF smuggled in via a decoded redirect Location under // CR/LF in a caller-supplied path under set_path_encode(false)) must
// set_path_encode(false)) must fail the request cleanly instead of // fail the request cleanly instead of emitting a request-line-less,
// emitting a request-line-less, header-injecting request. // header-injecting request.
error = Error::Write; error = Error::Write;
rejected_locally = true; rejected_locally = true;
output_error_log(error, &req); output_error_log(error, &req);
@@ -21022,29 +21056,24 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
auto ip_len = impl::parse_ip_address(host_str, ip_bytes); auto ip_len = impl::parse_ip_address(host_str, ip_bytes);
auto is_ip = ip_len > 0; auto is_ip = ip_len > 0;
// Check Subject Alternative Names (SAN) // Check Subject Alternative Names (SAN). Mbed TLS keeps the GeneralName type
// In Mbed TLS 3.x, subject_alt_names contains raw values without ASN.1 tags // in buf.tag and the raw value in buf.p / buf.len.
// - DNS names: raw string bytes
// - IP addresses: raw IP bytes (4 for IPv4, 16 for IPv6)
const mbedtls_x509_sequence *san = &mcert->subject_alt_names; const mbedtls_x509_sequence *san = &mcert->subject_alt_names;
while (san != nullptr && san->buf.p != nullptr && san->buf.len > 0) { while (san != nullptr && san->buf.p != nullptr && san->buf.len > 0) {
const unsigned char *p = san->buf.p; const unsigned char *p = san->buf.p;
size_t len = san->buf.len; size_t len = san->buf.len;
auto san_type = san->buf.tag & MBEDTLS_ASN1_TAG_VALUE_MASK;
if (is_ip) { if (is_ip) {
// For an IP host, only a matching iPAddress SAN of the same family // For an IP host, only a matching iPAddress SAN of the same family
// (4 bytes for IPv4, 16 bytes for IPv6) may authenticate it. // (4 bytes for IPv4, 16 bytes for IPv6) may authenticate it.
if (len == ip_len && memcmp(p, ip_bytes, ip_len) == 0) { return true; } if (san_type == MBEDTLS_X509_SAN_IP_ADDRESS && len == ip_len &&
} else { memcmp(p, ip_bytes, ip_len) == 0) {
// Check if this SAN is a DNS name (printable ASCII string) return true;
bool is_dns = len > 0;
for (size_t i = 0; i < len && is_dns; i++) {
if (p[i] < 32 || p[i] > 126) { is_dns = false; }
}
if (is_dns) {
std::string san_name(reinterpret_cast<const char *>(p), len);
if (detail::match_hostname(san_name, host_str)) { return true; }
} }
} else if (san_type == MBEDTLS_X509_SAN_DNS_NAME) {
std::string san_name(reinterpret_cast<const char *>(p), len);
if (detail::match_hostname(san_name, host_str)) { return true; }
} }
san = san->next; san = san->next;
} }
@@ -21123,65 +21152,45 @@ inline bool get_cert_sans(cert_t cert, std::vector<SanEntry> &sans) {
const mbedtls_x509_sequence *cur = &x509->subject_alt_names; const mbedtls_x509_sequence *cur = &x509->subject_alt_names;
while (cur != nullptr) { while (cur != nullptr) {
if (cur->buf.len > 0) { if (cur->buf.len > 0) {
// Mbed TLS stores SAN as ASN.1 sequences
// The tag byte indicates the type
const unsigned char *p = cur->buf.p; const unsigned char *p = cur->buf.p;
size_t len = cur->buf.len; size_t value_len = cur->buf.len;
// First byte is the tag SanEntry entry;
unsigned char tag = *p; switch (cur->buf.tag & MBEDTLS_ASN1_TAG_VALUE_MASK) {
p++; case MBEDTLS_X509_SAN_DNS_NAME:
len--; entry.type = SanType::DNS;
entry.value = std::string(reinterpret_cast<const char *>(p), value_len);
// Parse length (simple single-byte length assumed) break;
if (len > 0 && *p < 0x80) { case MBEDTLS_X509_SAN_IP_ADDRESS:
size_t value_len = *p; entry.type = SanType::IP;
p++; if (value_len == 4) {
len--; // IPv4
char buf[16];
if (value_len <= len) { snprintf(buf, sizeof(buf), "%d.%d.%d.%d", p[0], p[1], p[2], p[3]);
SanEntry entry; entry.value = buf;
// ASN.1 context tags for GeneralName } else if (value_len == 16) {
switch (tag & 0x1F) { // IPv6
case 2: // dNSName char buf[64];
entry.type = SanType::DNS; snprintf(buf, sizeof(buf),
entry.value = "%02x%02x:%02x%02x:%02x%02x:%02x%02x:"
std::string(reinterpret_cast<const char *>(p), value_len); "%02x%02x:%02x%02x:%02x%02x:%02x%02x",
break; p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8], p[9],
case 7: // iPAddress p[10], p[11], p[12], p[13], p[14], p[15]);
entry.type = SanType::IP; entry.value = buf;
if (value_len == 4) {
// IPv4
char buf[16];
snprintf(buf, sizeof(buf), "%d.%d.%d.%d", p[0], p[1], p[2], p[3]);
entry.value = buf;
} else if (value_len == 16) {
// IPv6
char buf[64];
snprintf(buf, sizeof(buf),
"%02x%02x:%02x%02x:%02x%02x:%02x%02x:"
"%02x%02x:%02x%02x:%02x%02x:%02x%02x",
p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8],
p[9], p[10], p[11], p[12], p[13], p[14], p[15]);
entry.value = buf;
}
break;
case 1: // rfc822Name (email)
entry.type = SanType::EMAIL;
entry.value =
std::string(reinterpret_cast<const char *>(p), value_len);
break;
case 6: // uniformResourceIdentifier
entry.type = SanType::URI;
entry.value =
std::string(reinterpret_cast<const char *>(p), value_len);
break;
default: entry.type = SanType::OTHER; break;
}
if (!entry.value.empty()) { sans.push_back(std::move(entry)); }
} }
break;
case MBEDTLS_X509_SAN_RFC822_NAME:
entry.type = SanType::EMAIL;
entry.value = std::string(reinterpret_cast<const char *>(p), value_len);
break;
case MBEDTLS_X509_SAN_UNIFORM_RESOURCE_IDENTIFIER:
entry.type = SanType::URI;
entry.value = std::string(reinterpret_cast<const char *>(p), value_len);
break;
default: entry.type = SanType::OTHER; break;
} }
if (!entry.value.empty()) { sans.push_back(std::move(entry)); }
} }
cur = cur->next; cur = cur->next;
} }
@@ -22823,9 +22832,9 @@ inline void WebSocket::start_heartbeat() {
if (ping_interval_sec_ == 0) { return; } if (ping_interval_sec_ == 0) { return; }
ping_thread_ = std::thread([this]() { ping_thread_ = std::thread([this]() {
std::unique_lock<std::mutex> lock(ping_mutex_); std::unique_lock<std::mutex> lock(ping_mutex_);
while (!closed_) { // The predicate keeps a spurious wakeup from sending a ping early
ping_cv_.wait_for(lock, std::chrono::seconds(ping_interval_sec_)); while (!ping_cv_.wait_for(lock, std::chrono::seconds(ping_interval_sec_),
if (closed_) { break; } [this]() { return closed_.load(); })) {
// If the peer has failed to respond to the previous pings, give up. // If the peer has failed to respond to the previous pings, give up.
// RFC 6455 does not define a pong-timeout mechanism; this is an // RFC 6455 does not define a pong-timeout mechanism; this is an
// opt-in liveness check controlled by max_missed_pongs_. // opt-in liveness check controlled by max_missed_pongs_.
+7
View File
@@ -146,6 +146,13 @@ if(HTTPLIB_IS_USING_OPENSSL)
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMAND_ERROR_IS_FATAL ANY COMMAND_ERROR_IS_FATAL ANY
) )
# cert_san_types.pem: the bytes of each SAN read as the other type:
# DNS:a.zz is 97.46.122.122, IP:42.46.122.122 is "*.zz".
execute_process(
COMMAND ${OPENSSL_COMMAND} 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
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMAND_ERROR_IS_FATAL ANY
)
endif() endif()
add_subdirectory(fuzzing) add_subdirectory(fuzzing)
+4
View File
@@ -33,3 +33,7 @@ openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=127.0.0.1" -
# different address. The SAN address must match; the CN address # different address. The SAN address must match; the CN address
# must be ignored. # must be ignored.
openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=::1" -addext "subjectAltName=IP:2001:db8::1" -out cert_ipv6.pem openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=::1" -addext "subjectAltName=IP:2001:db8::1" -out cert_ipv6.pem
# cert_san_types.pem: the bytes of each SAN read as the other type:
# 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
+11 -1
View File
@@ -137,6 +137,15 @@ cert_ipv6_pem = custom_target(
command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=::1', '-addext', 'subjectAltName=IP:2001:db8::1', '-out', '@OUTPUT@'] command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=::1', '-addext', 'subjectAltName=IP:2001:db8::1', '-out', '@OUTPUT@']
) )
# cert_san_types.pem: the bytes of each SAN read as the other type: DNS:a.zz is
# 97.46.122.122, IP:42.46.122.122 is "*.zz".
cert_san_types_pem = custom_target(
'cert_san_types_pem',
input: key_pem,
output: 'cert_san_types.pem',
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@']
)
# Copy test files to the build directory # Copy test files to the build directory
configure_file(input: 'ca-bundle.crt', output: 'ca-bundle.crt', copy: true) configure_file(input: 'ca-bundle.crt', output: 'ca-bundle.crt', copy: true)
configure_file(input: 'image.jpg', output: 'image.jpg', copy: true) configure_file(input: 'image.jpg', output: 'image.jpg', copy: true)
@@ -178,7 +187,8 @@ test(
client_encrypted_pbes1_key_pem, client_encrypted_pbes1_key_pem,
client_encrypted_cert_pem, client_encrypted_cert_pem,
cert_ip_cn_pem, cert_ip_cn_pem,
cert_ipv6_pem cert_ipv6_pem,
cert_san_types_pem
], ],
workdir: meson.current_build_dir(), workdir: meson.current_build_dir(),
timeout: 300 timeout: 300
+348 -5
View File
@@ -42,6 +42,7 @@ inline std::string u8_to_string(const char8_t *s) {
#define SERVER_CERT2_FILE "./cert2.pem" #define SERVER_CERT2_FILE "./cert2.pem"
#define SERVER_CERT_IP_CN_FILE "./cert_ip_cn.pem" #define SERVER_CERT_IP_CN_FILE "./cert_ip_cn.pem"
#define SERVER_CERT_IPV6_FILE "./cert_ipv6.pem" #define SERVER_CERT_IPV6_FILE "./cert_ipv6.pem"
#define SERVER_CERT_SAN_TYPES_FILE "./cert_san_types.pem"
#define SERVER_PRIVATE_KEY_FILE "./key.pem" #define SERVER_PRIVATE_KEY_FILE "./key.pem"
#define CA_CERT_FILE "./ca-bundle.crt" #define CA_CERT_FILE "./ca-bundle.crt"
#define CLIENT_CA_CERT_FILE "./rootCA.cert.pem" #define CLIENT_CA_CERT_FILE "./rootCA.cert.pem"
@@ -10209,8 +10210,7 @@ TEST(RequestLineInjectionTest, RejectsInvalidCharsInTarget) {
// A target carrying CR/LF, SP or other control octets must be rejected // A target carrying CR/LF, SP or other control octets must be rejected
// before anything reaches the wire, otherwise it splits the request line and // before anything reaches the wire, otherwise it splits the request line and
// injects a header or a whole request. This is what a decoded redirect // injects a header or a whole request.
// Location ("%0D%0A") turns into when path encoding is disabled.
const std::string evil_targets[] = { const std::string evil_targets[] = {
"/a\r\nInjected: pwned", "/a\r\nInjected: pwned",
"/a\rInjected", "/a\rInjected",
@@ -10231,9 +10231,9 @@ TEST(RequestLineInjectionTest, RejectsInvalidCharsInTarget) {
TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) { TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) {
// End-to-end counterpart to RejectsInvalidCharsInTarget. With path encoding // End-to-end counterpart to RejectsInvalidCharsInTarget. With path encoding
// disabled the client transmits the target verbatim, so a CR/LF-bearing // disabled the client transmits the target verbatim, so a CR/LF-bearing
// target -- what a redirect Location "%0D%0A" decodes to -- reaches // target reaches write_request. The client must fail cleanly with
// write_request. The client must fail cleanly with Error::Write instead of // Error::Write instead of putting a request-line-less, header-injecting
// putting a request-line-less, header-injecting request on the wire. // request on the wire.
Server svr; Server svr;
svr.Get("/a", [](const Request &, Response &res) { svr.Get("/a", [](const Request &, Response &res) {
@@ -14870,6 +14870,123 @@ TEST(SSLClientServerTest, TlsVerifyHostnameIpv6San) {
EXPECT_FALSE(cn_ipv6_matched) EXPECT_FALSE(cn_ipv6_matched)
<< "An IPv6 host must not be authenticated via the certificate CN"; << "An IPv6 host must not be authenticated via the certificate CN";
} }
// A SAN entry must only match a host of its own type: the bytes of the dNSName
// "a.zz" are also 97.46.122.122, and 42.46.122.122 reads as "*.zz".
TEST(SSLClientServerTest, TlsVerifyHostnameSanType) {
using namespace httplib::tls;
// SANs: DNS:a.zz, IP:42.46.122.122
SSLServer svr(SERVER_CERT_SAN_TYPES_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();
bool verify_callback_called = false;
bool dns_san_matched = false;
bool ip_san_matched = false;
bool ip_matched_via_dns_san = true;
bool dns_matched_via_ip_san = true;
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
dns_san_matched = ctx.check_hostname("a.zz");
ip_san_matched = ctx.check_hostname("42.46.122.122");
ip_matched_via_dns_san = ctx.check_hostname("97.46.122.122");
dns_matched_via_ip_san = ctx.check_hostname("b.zz");
return true; // Accept for the purpose of this test
});
cli.Get("/test");
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
EXPECT_TRUE(dns_san_matched) << "verify_hostname should match a dNSName SAN";
EXPECT_TRUE(ip_san_matched)
<< "verify_hostname should match an iPAddress SAN";
EXPECT_FALSE(ip_matched_via_dns_san)
<< "An IP host must not be authenticated via a dNSName SAN";
EXPECT_FALSE(dns_matched_via_ip_san)
<< "A DNS host must not be authenticated via an iPAddress SAN";
}
// sans() must report each SAN entry under its own type.
TEST(SSLClientServerTest, TlsCertSansEntryTypes) {
using namespace httplib::tls;
// SANs: DNS:a.zz, IP:42.46.122.122
SSLServer svr(SERVER_CERT_SAN_TYPES_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();
bool verify_callback_called = false;
std::vector<SanEntry> sans;
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
sans = ctx.sans();
return true; // Accept for the purpose of this test
});
cli.Get("/test");
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
auto has_san = [&](SanType type, const std::string &value) {
return std::any_of(sans.begin(), sans.end(), [&](const SanEntry &san) {
return san.type == type && san.value == value;
});
};
EXPECT_TRUE(has_san(SanType::DNS, "a.zz"))
<< "sans() should report the dNSName SAN";
EXPECT_TRUE(has_san(SanType::IP, "42.46.122.122"))
<< "sans() should report the iPAddress SAN";
EXPECT_FALSE(has_san(SanType::IP, "97.46.122.122"))
<< "sans() must not report the dNSName SAN as an address";
EXPECT_FALSE(has_san(SanType::DNS, "*.zz"))
<< "sans() must not report the iPAddress SAN as a DNS name";
}
#endif #endif
// mbedTLS-specific callback constructor test // mbedTLS-specific callback constructor test
@@ -17496,6 +17613,138 @@ TEST(TaskQueueTest, MaxQueuedRequests) {
#endif #endif
} }
TEST(RedirectTest, SeeOtherDoesNotResendContentProviderBody) {
Server svr;
std::string method;
std::string body;
auto has_content_length = false;
auto has_transfer_encoding = false;
std::atomic<int> bad_requests{0};
// Leftover body bytes get parsed as a malformed request and answered with
// 400
svr.set_logger([&](const Request & /*req*/, const Response &res) {
if (res.status == StatusCode::BadRequest_400) { bad_requests++; }
});
svr.Post("/up", [](const Request & /*req*/, Response &res) {
res.set_redirect("/down", StatusCode::SeeOther_303);
});
svr.Get("/down", [&](const Request &req, Response &res) {
method = req.method;
body = req.body;
has_content_length = req.has_header("Content-Length");
has_transfer_encoding = req.has_header("Transfer-Encoding");
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto thread = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string payload = "SECRET-BODY";
auto check = [&](Client &cli, const Result &res) {
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
EXPECT_EQ("GET", method);
EXPECT_TRUE(body.empty());
EXPECT_FALSE(has_content_length);
EXPECT_FALSE(has_transfer_encoding);
// Nothing of the original body may be left on the connection
auto res2 = cli.Get("/down");
ASSERT_TRUE(res2) << "Error: " << to_string(res2.error());
EXPECT_EQ(StatusCode::OK_200, res2->status);
EXPECT_EQ("ok", res2->body);
EXPECT_EQ(0, bad_requests);
};
// With content length
{
Client cli(HOST, port);
cli.set_keep_alive(true);
cli.set_follow_location(true);
auto res = cli.Post(
"/up", payload.size(),
[&](size_t offset, size_t length, DataSink &sink) {
return sink.write(payload.data() + offset, length);
},
"text/plain");
check(cli, res);
}
// Without content length (chunked)
{
Client cli(HOST, port);
cli.set_keep_alive(true);
cli.set_follow_location(true);
auto res = cli.Post(
"/up",
[&](size_t /*offset*/, DataSink &sink) {
sink.write(payload.data(), payload.size());
sink.done();
return true;
},
"text/plain");
check(cli, res);
}
}
TEST(RedirectTest, LocationPathIsNotDecoded) {
Server svr;
std::string target;
svr.Get(R"(/start/.*)", [](const Request &req, Response &res) {
// Echo the still-encoded name back in the Location
const std::string prefix = "/start/";
res.status = StatusCode::Found_302;
res.set_header("Location", "/dest/" + req.target.substr(prefix.size()));
});
svr.Get(R"(/dest.*)", [&](const Request &req, Response &res) {
target = req.target;
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto thread = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Decoding the Location path would turn the first four into a path
// separator, a query delimiter, a fragment delimiter and a different
// percent-encoded octet. The rest must keep reaching the server as they are.
const std::vector<std::string> names = {
"a%2Fb", "x%3Fy", "x%23y", "a%2520b", "a%20b", "%C3%BC", "a-b_c.d~e",
};
for (auto path_encode : {true, false}) {
Client cli(HOST, port);
cli.set_follow_location(true);
cli.set_path_encode(path_encode);
for (const auto &name : names) {
target.clear();
auto res = cli.Get("/start/" + name);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("/dest/" + name, target);
}
}
}
TEST(RedirectTest, RedirectToUrlWithQueryParameters) { TEST(RedirectTest, RedirectToUrlWithQueryParameters) {
Server svr; Server svr;
@@ -22470,6 +22719,30 @@ TEST_F(SSEParsingTest, EventWithoutDataUpdatesLastEventId) {
EXPECT_EQ(msgs[0].data, "42"); EXPECT_EQ(msgs[0].data, "42");
} }
TEST_F(SSEParsingTest, EmptyEventIdClearsLastEventId) {
// The first connection sets an id and then clears it with an empty one; the
// second reports whether it was reconnected with a Last-Event-ID
std::atomic<int> connection_count{0};
server_->Get("/id-clear", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content("id: 1\ndata: first\n\nid:\ndata: second\n\n",
"text/event-stream");
} else {
res.set_content(
std::string("data: ") +
(req.has_header("Last-Event-ID") ? "sent" : "not sent") +
"\n\nevent: end\ndata: end\n\n",
"text/event-stream");
}
});
auto msgs = collect("/id-clear");
ASSERT_EQ(msgs.size(), 3u);
EXPECT_EQ(msgs[0].id, "1");
EXPECT_EQ(msgs[1].id, "");
EXPECT_EQ(msgs[2].data, "not sent");
}
TEST_F(SSEParsingTest, CompleteEventParsing) { TEST_F(SSEParsingTest, CompleteEventParsing) {
auto msgs = parse("event: notification\nid: evt-42\n" auto msgs = parse("event: notification\nid: evt-42\n"
"data: {\"type\":\"alert\"}\nretry: 1000\n\n"); "data: {\"type\":\"alert\"}\nretry: 1000\n\n");
@@ -23906,6 +24179,18 @@ protected:
} }
return ""; return "";
}); });
server_->WebSocket(
"/ws-subprotocol-unoffered",
[](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
},
[](const std::vector<std::string> &) -> std::string {
return "admin";
});
} }
void start_server() { void start_server() {
@@ -24248,6 +24533,18 @@ TEST_F(WebSocketIntegrationTest, SubProtocolNoMatch) {
client.close(); client.close();
} }
TEST_F(WebSocketIntegrationTest, SubProtocolSelectorReturnsUnoffered) {
Headers headers = {{"Sec-WebSocket-Protocol", "chat"}};
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-subprotocol-unoffered",
headers);
ASSERT_TRUE(client.connect());
EXPECT_TRUE(client.subprotocol().empty());
client.close();
}
TEST_F(WebSocketIntegrationTest, SubProtocolNotRequested) { TEST_F(WebSocketIntegrationTest, SubProtocolNotRequested) {
// Connect without requesting any subprotocol // Connect without requesting any subprotocol
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
@@ -24846,6 +25143,52 @@ TEST(WebSocketTest, ClientRejectsResponseWithoutUpgradeToken) {
EXPECT_FALSE(client.is_open()); EXPECT_FALSE(client.is_open());
} }
TEST(WebSocketTest, ClientRejectsUnofferedSubprotocol) {
Server svr;
svr.Get("/ws", [](const Request &req, Response &res) {
res.status = StatusCode::SwitchingProtocol_101;
res.set_header("Upgrade", "websocket");
res.set_header("Connection", "Upgrade");
res.set_header("Sec-WebSocket-Accept",
detail::websocket_accept_key(
req.get_header_value("Sec-WebSocket-Key")));
res.set_header("Sec-WebSocket-Protocol", "admin");
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
const auto url = "ws://localhost:" + std::to_string(port) + "/ws";
// Server selects a subprotocol the client never offered
{
Headers headers = {{"Sec-WebSocket-Protocol", "chat"}};
ws::WebSocketClient client(url, headers);
auto res = client.connect();
EXPECT_FALSE(res);
EXPECT_EQ(Error::WebSocketHandshake, res.error());
EXPECT_FALSE(client.is_open());
EXPECT_TRUE(client.subprotocol().empty());
}
// Client offered none but the server named one anyway
{
ws::WebSocketClient client(url);
auto res = client.connect();
EXPECT_FALSE(res);
EXPECT_EQ(Error::WebSocketHandshake, res.error());
EXPECT_FALSE(client.is_open());
EXPECT_TRUE(client.subprotocol().empty());
}
}
TEST(WebSocketTest, HostHeaderOverUnixSocket) { TEST(WebSocketTest, HostHeaderOverUnixSocket) {
// The socket path doubles as the URL host, so it must not contain '/'. // The socket path doubles as the URL host, so it must not contain '/'.
const char *shard = getenv("GTEST_SHARD_INDEX"); const char *shard = getenv("GTEST_SHARD_INDEX");