diff --git a/scripts/sync_vendor.py b/scripts/sync_vendor.py index 1a87d73563..4d254afcd6 100755 --- a/scripts/sync_vendor.py +++ b/scripts/sync_vendor.py @@ -5,7 +5,7 @@ import os import sys import subprocess -HTTPLIB_VERSION = "refs/tags/v0.37.2" +HTTPLIB_VERSION = "refs/tags/v0.38.0" vendor = { "https://github.com/nlohmann/json/releases/latest/download/json.hpp": "vendor/nlohmann/json.hpp", diff --git a/vendor/cpp-httplib/httplib.cpp b/vendor/cpp-httplib/httplib.cpp index 41e7a361c0..fa0718218e 100644 --- a/vendor/cpp-httplib/httplib.cpp +++ b/vendor/cpp-httplib/httplib.cpp @@ -1025,6 +1025,30 @@ bool is_valid_path(const std::string &path) { return true; } +bool canonicalize_path(const char *path, std::string &resolved) { +#if defined(_WIN32) + char buf[_MAX_PATH]; + if (_fullpath(buf, path, _MAX_PATH) == nullptr) { return false; } + resolved = buf; +#else + char buf[PATH_MAX]; + if (realpath(path, buf) == nullptr) { return false; } + resolved = buf; +#endif + return true; +} + +bool is_path_within_base(const std::string &resolved_path, + const std::string &resolved_base) { +#if defined(_WIN32) + return _strnicmp(resolved_path.c_str(), resolved_base.c_str(), + resolved_base.size()) == 0; +#else + return strncmp(resolved_path.c_str(), resolved_base.c_str(), + resolved_base.size()) == 0; +#endif +} + FileStat::FileStat(const std::string &path) { #if defined(_WIN32) auto wpath = u8string_to_wstring(path.c_str()); @@ -2627,33 +2651,114 @@ bool can_compress_content_type(const std::string &content_type) { } } +bool parse_quality(const char *b, const char *e, std::string &token, + double &quality) { + quality = 1.0; + token.clear(); + + // Split on first ';': left = token name, right = parameters + const char *params_b = nullptr; + std::size_t params_len = 0; + + divide( + b, static_cast(e - b), ';', + [&](const char *lb, std::size_t llen, const char *rb, std::size_t rlen) { + auto r = trim(lb, lb + llen, 0, llen); + if (r.first < r.second) { token.assign(lb + r.first, lb + r.second); } + params_b = rb; + params_len = rlen; + }); + + if (token.empty()) { return false; } + if (params_len == 0) { return true; } + + // Scan parameters for q= (stops on first match) + bool invalid = false; + split_find(params_b, params_b + params_len, ';', + (std::numeric_limits::max)(), + [&](const char *pb, const char *pe) -> bool { + // Match exactly "q=" or "Q=" (not "query=" etc.) + auto len = static_cast(pe - pb); + if (len < 2) { return false; } + if ((pb[0] != 'q' && pb[0] != 'Q') || pb[1] != '=') { + return false; + } + + // Trim the value portion + auto r = trim(pb, pe, 2, len); + if (r.first >= r.second) { + invalid = true; + return true; + } + + double v = 0.0; + auto res = from_chars(pb + r.first, pb + r.second, v); + if (res.ec != std::errc{} || v < 0.0 || v > 1.0) { + invalid = true; + return true; + } + quality = v; + return true; + }); + + return !invalid; +} + EncodingType encoding_type(const Request &req, const Response &res) { - auto ret = - detail::can_compress_content_type(res.get_header_value("Content-Type")); - if (!ret) { return EncodingType::None; } + if (!can_compress_content_type(res.get_header_value("Content-Type"))) { + return EncodingType::None; + } const auto &s = req.get_header_value("Accept-Encoding"); - (void)(s); + if (s.empty()) { return EncodingType::None; } + // Single-pass: iterate tokens and track the best supported encoding. + // Server preference breaks ties (br > gzip > zstd). + EncodingType best = EncodingType::None; + double best_q = 0.0; // q=0 means "not acceptable" + + // Server preference: Brotli > Gzip > Zstd (lower = more preferred) + auto priority = [](EncodingType t) -> int { + switch (t) { + case EncodingType::Brotli: return 0; + case EncodingType::Gzip: return 1; + case EncodingType::Zstd: return 2; + default: return 3; + } + }; + + std::string name; + split(s.data(), s.data() + s.size(), ',', [&](const char *b, const char *e) { + double quality = 1.0; + if (!parse_quality(b, e, name, quality)) { return; } + if (quality <= 0.0) { return; } + + EncodingType type = EncodingType::None; #ifdef CPPHTTPLIB_BROTLI_SUPPORT - // TODO: 'Accept-Encoding' has br, not br;q=0 - ret = s.find("br") != std::string::npos; - if (ret) { return EncodingType::Brotli; } + if (case_ignore::equal(name, "br")) { type = EncodingType::Brotli; } #endif - #ifdef CPPHTTPLIB_ZLIB_SUPPORT - // TODO: 'Accept-Encoding' has gzip, not gzip;q=0 - ret = s.find("gzip") != std::string::npos; - if (ret) { return EncodingType::Gzip; } + if (type == EncodingType::None && case_ignore::equal(name, "gzip")) { + type = EncodingType::Gzip; + } #endif - #ifdef CPPHTTPLIB_ZSTD_SUPPORT - // TODO: 'Accept-Encoding' has zstd, not zstd;q=0 - ret = s.find("zstd") != std::string::npos; - if (ret) { return EncodingType::Zstd; } + if (type == EncodingType::None && case_ignore::equal(name, "zstd")) { + type = EncodingType::Zstd; + } #endif - return EncodingType::None; + if (type == EncodingType::None) { return; } + + // Higher q-value wins; for equal q, server preference breaks ties + if (quality > best_q || + (quality == best_q && priority(type) < priority(best))) { + best_q = quality; + best = type; + } + }); + + return best; } bool nocompressor::compress(const char *data, size_t data_length, @@ -2937,6 +3042,21 @@ create_decompressor(const std::string &encoding) { return decompressor; } +// Returns the best available compressor and its Content-Encoding name. +// Priority: Brotli > Gzip > Zstd (matches server-side preference). +std::pair, const char *> +create_compressor() { +#ifdef CPPHTTPLIB_BROTLI_SUPPORT + return {detail::make_unique(), "br"}; +#elif defined(CPPHTTPLIB_ZLIB_SUPPORT) + return {detail::make_unique(), "gzip"}; +#elif defined(CPPHTTPLIB_ZSTD_SUPPORT) + return {detail::make_unique(), "zstd"}; +#else + return {nullptr, nullptr}; +#endif +} + bool is_prohibited_header_name(const std::string &name) { using udl::operator""_t; @@ -3769,7 +3889,7 @@ bool parse_accept_header(const std::string &s, struct AcceptEntry { std::string media_type; double quality; - int order; // Original order in header + int order; }; std::vector entries; @@ -3787,48 +3907,12 @@ bool parse_accept_header(const std::string &s, } AcceptEntry accept_entry; - accept_entry.quality = 1.0; // Default quality accept_entry.order = order++; - // Find q= parameter - auto q_pos = entry.find(";q="); - if (q_pos == std::string::npos) { q_pos = entry.find("; q="); } - - if (q_pos != std::string::npos) { - // Extract media type (before q parameter) - accept_entry.media_type = trim_copy(entry.substr(0, q_pos)); - - // Extract quality value - auto q_start = entry.find('=', q_pos) + 1; - auto q_end = entry.find(';', q_start); - if (q_end == std::string::npos) { q_end = entry.length(); } - - std::string quality_str = - trim_copy(entry.substr(q_start, q_end - q_start)); - if (quality_str.empty()) { - has_invalid_entry = true; - return; - } - - { - double v = 0.0; - auto res = detail::from_chars( - quality_str.data(), quality_str.data() + quality_str.size(), v); - if (res.ec == std::errc{}) { - accept_entry.quality = v; - } else { - has_invalid_entry = true; - return; - } - } - // Check if quality is in valid range [0.0, 1.0] - if (accept_entry.quality < 0.0 || accept_entry.quality > 1.0) { - has_invalid_entry = true; - return; - } - } else { - // No quality parameter, use entire entry as media type - accept_entry.media_type = entry; + if (!parse_quality(entry.data(), entry.data() + entry.size(), + accept_entry.media_type, accept_entry.quality)) { + has_invalid_entry = true; + return; } // Remove additional parameters from media type @@ -5481,7 +5565,8 @@ std::string decode_path_component(const std::string &component) { // Unicode %uXXXX encoding auto val = 0; if (detail::from_hex_to_i(component, i + 2, 4, val)) { - // 4 digits Unicode codes + // 4 digits Unicode codes: val is 0x0000-0xFFFF (from 4 hex digits), + // so to_utf8 writes at most 3 bytes. buff[4] is safe. char buff[4]; size_t len = detail::to_utf8(val, buff); if (len > 0) { result.append(buff, len); } @@ -5586,6 +5671,30 @@ std::string decode_query_component(const std::string &component, return result; } +std::string sanitize_filename(const std::string &filename) { + // Extract basename: find the last path separator (/ or \) + auto pos = filename.find_last_of("/\\"); + auto result = + (pos != std::string::npos) ? filename.substr(pos + 1) : filename; + + // Strip null bytes + result.erase(std::remove(result.begin(), result.end(), '\0'), result.end()); + + // Trim whitespace + { + auto start = result.find_first_not_of(" \t"); + auto end = result.find_last_not_of(" \t"); + result = (start == std::string::npos) + ? "" + : result.substr(start, end - start + 1); + } + + // Reject . and .. + if (result == "." || result == "..") { return ""; } + + return result; +} + std::string append_query_params(const std::string &path, const Params ¶ms) { std::string path_with_query = path; @@ -6714,7 +6823,18 @@ bool Server::set_mount_point(const std::string &mount_point, if (stat.is_dir()) { std::string mnt = !mount_point.empty() ? mount_point : "/"; if (!mnt.empty() && mnt[0] == '/') { - base_dirs_.push_back({std::move(mnt), dir, std::move(headers)}); + std::string resolved_base; + if (detail::canonicalize_path(dir.c_str(), resolved_base)) { +#if defined(_WIN32) + if (resolved_base.back() != '\\' && resolved_base.back() != '/') { + resolved_base += '\\'; + } +#else + if (resolved_base.back() != '/') { resolved_base += '/'; } +#endif + } + base_dirs_.push_back( + {std::move(mnt), dir, std::move(resolved_base), std::move(headers)}); return true; } } @@ -6874,6 +6994,20 @@ Server &Server::set_payload_max_length(size_t length) { return *this; } +Server &Server::set_websocket_ping_interval(time_t sec) { + websocket_ping_interval_sec_ = sec; + return *this; +} + +template +Server &Server::set_websocket_ping_interval( + const std::chrono::duration &duration) { + detail::duration_to_sec_and_usec(duration, [&](time_t sec, time_t /*usec*/) { + set_websocket_ping_interval(sec); + }); + return *this; +} + bool Server::bind_to_port(const std::string &host, int port, int socket_flags) { auto ret = bind_internal(host, port, socket_flags); @@ -7294,6 +7428,18 @@ bool Server::handle_file_request(Request &req, Response &res) { auto path = entry.base_dir + sub_path; if (path.back() == '/') { path += "index.html"; } + // Defense-in-depth: is_valid_path blocks ".." traversal in the URL, + // but symlinks/junctions can still escape the base directory. + if (!entry.resolved_base_dir.empty()) { + std::string resolved_path; + if (detail::canonicalize_path(path.c_str(), resolved_path) && + !detail::is_path_within_base(resolved_path, + entry.resolved_base_dir)) { + res.status = StatusCode::Forbidden_403; + return true; + } + } + detail::FileStat stat(path); if (stat.is_dir()) { @@ -8012,7 +8158,7 @@ Server::process_request(Stream &strm, const std::string &remote_addr, { // Use WebSocket-specific read timeout instead of HTTP timeout strm.set_read_timeout(CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND, 0); - ws::WebSocket ws(strm, req, true); + ws::WebSocket ws(strm, req, true, websocket_ping_interval_sec_); entry.handler(req, ws); } return true; @@ -8256,6 +8402,13 @@ bool ClientImpl::ensure_socket_connection(Socket &socket, Error &error) { return create_and_connect_socket(socket, error); } +bool ClientImpl::setup_proxy_connection( + Socket & /*socket*/, + std::chrono::time_point /*start_time*/, + Response & /*res*/, bool & /*success*/, Error & /*error*/) { + return true; +} + void ClientImpl::shutdown_ssl(Socket & /*socket*/, bool /*shutdown_gracefully*/) { // If there are any requests in flight from threads other than us, then it's @@ -8377,27 +8530,14 @@ bool ClientImpl::send_(Request &req, Response &res, Error &error) { return false; } -#ifdef CPPHTTPLIB_SSL_ENABLED - // TODO: refactoring - if (is_ssl()) { - auto &scli = static_cast(*this); - if (!proxy_host_.empty() && proxy_port_ != -1) { - auto success = false; - if (!scli.connect_with_proxy(socket_, req.start_time_, res, success, - error)) { - if (!success) { output_error_log(error, &req); } - return success; - } - } - - if (!proxy_host_.empty() && proxy_port_ != -1) { - if (!scli.initialize_ssl(socket_, error)) { - output_error_log(error, &req); - return false; - } + { + auto success = true; + if (!setup_proxy_connection(socket_, req.start_time_, res, success, + error)) { + if (!success) { output_error_log(error, &req); } + return success; } } -#endif } // Mark the current socket as being in use so that it cannot be closed by @@ -8558,17 +8698,15 @@ ClientImpl::open_stream(const std::string &method, const std::string &path, return handle; } -#ifdef CPPHTTPLIB_SSL_ENABLED - if (is_ssl()) { - auto &scli = static_cast(*this); - if (!proxy_host_.empty() && proxy_port_ != -1) { - if (!scli.initialize_ssl(socket_, handle.error)) { - handle.response.reset(); - return handle; - } + { + auto success = true; + auto start_time = std::chrono::steady_clock::now(); + if (!setup_proxy_connection(socket_, start_time, *handle.response, + success, handle.error)) { + if (!success) { handle.response.reset(); } + return handle; } } -#endif } transfer_socket_ownership_to_handle(handle); @@ -8847,7 +8985,7 @@ bool ClientImpl::handle_request(Stream &strm, Request &req, if (res.get_header_value("Connection") == "close" || (res.version == "HTTP/1.0" && res.reason != "Connection established")) { - // TODO this requires a not-entirely-obvious chain of calls to be correct + // NOTE: this requires a not-entirely-obvious chain of calls to be correct // for this to be safe. // This is safe to call because handle_request is only called by send_ @@ -9086,14 +9224,9 @@ bool ClientImpl::write_content_with_provider(Stream &strm, auto is_shutting_down = []() { return false; }; if (req.is_chunked_content_provider_) { - // TODO: Brotli support - std::unique_ptr compressor; -#ifdef CPPHTTPLIB_ZLIB_SUPPORT - if (compress_) { - compressor = detail::make_unique(); - } else -#endif - { + auto compressor = compress_ ? detail::create_compressor().first + : std::unique_ptr(); + if (!compressor) { compressor = detail::make_unique(); } @@ -9324,14 +9457,15 @@ ClientImpl::send_with_content_provider_and_receiver( Error &error) { if (!content_type.empty()) { req.set_header("Content-Type", content_type); } -#ifdef CPPHTTPLIB_ZLIB_SUPPORT - if (compress_) { req.set_header("Content-Encoding", "gzip"); } -#endif + auto enc = compress_ + ? detail::create_compressor() + : std::pair, const char *>( + nullptr, nullptr); -#ifdef CPPHTTPLIB_ZLIB_SUPPORT - if (compress_ && !content_provider_without_length) { - // TODO: Brotli support - detail::gzip_compressor compressor; + if (enc.second) { req.set_header("Content-Encoding", enc.second); } + + if (enc.first && !content_provider_without_length) { + auto &compressor = enc.first; if (content_provider) { auto ok = true; @@ -9342,7 +9476,7 @@ ClientImpl::send_with_content_provider_and_receiver( if (ok) { auto last = offset + data_len == content_length; - auto ret = compressor.compress( + auto ret = compressor->compress( data, data_len, last, [&](const char *compressed_data, size_t compressed_data_len) { req.body.append(compressed_data, compressed_data_len); @@ -9366,19 +9500,17 @@ ClientImpl::send_with_content_provider_and_receiver( } } } else { - if (!compressor.compress(body, content_length, true, - [&](const char *data, size_t data_len) { - req.body.append(data, data_len); - return true; - })) { + if (!compressor->compress(body, content_length, true, + [&](const char *data, size_t data_len) { + req.body.append(data, data_len); + return true; + })) { error = Error::Compression; output_error_log(error, &req); return nullptr; } } - } else -#endif - { + } else { if (content_provider) { req.content_length_ = content_length; req.content_provider_ = std::move(content_provider); @@ -11545,6 +11677,24 @@ bool SSLClient::create_and_connect_socket(Socket &socket, Error &error) { return ClientImpl::create_and_connect_socket(socket, error); } +bool SSLClient::setup_proxy_connection( + Socket &socket, + std::chrono::time_point start_time, + Response &res, bool &success, Error &error) { + if (proxy_host_.empty() || proxy_port_ == -1) { return true; } + + if (!connect_with_proxy(socket, start_time, res, success, error)) { + return false; + } + + if (!initialize_ssl(socket, error)) { + success = false; + return false; + } + + return true; +} + // Assumes that socket_mutex_ is locked and that there are no requests in // flight bool SSLClient::connect_with_proxy( @@ -16061,11 +16211,11 @@ WebSocket::~WebSocket() { } void WebSocket::start_heartbeat() { + if (ping_interval_sec_ == 0) { return; } ping_thread_ = std::thread([this]() { std::unique_lock lock(ping_mutex_); while (!closed_) { - ping_cv_.wait_for(lock, std::chrono::seconds( - CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND)); + ping_cv_.wait_for(lock, std::chrono::seconds(ping_interval_sec_)); if (closed_) { break; } lock.unlock(); if (!send_frame(Opcode::Ping, nullptr, 0)) { @@ -16203,7 +16353,8 @@ bool WebSocketClient::connect() { Request req; req.method = "GET"; req.path = path_; - ws_ = std::unique_ptr(new WebSocket(std::move(strm), req, false)); + ws_ = std::unique_ptr( + new WebSocket(std::move(strm), req, false, websocket_ping_interval_sec_)); return true; } @@ -16243,6 +16394,10 @@ void WebSocketClient::set_write_timeout(time_t sec, time_t usec) { write_timeout_usec_ = usec; } +void WebSocketClient::set_websocket_ping_interval(time_t sec) { + websocket_ping_interval_sec_ = sec; +} + #ifdef CPPHTTPLIB_SSL_ENABLED void WebSocketClient::set_ca_cert_path(const std::string &path) { diff --git a/vendor/cpp-httplib/httplib.h b/vendor/cpp-httplib/httplib.h index cdde8014d9..6ec949ac51 100644 --- a/vendor/cpp-httplib/httplib.h +++ b/vendor/cpp-httplib/httplib.h @@ -8,8 +8,8 @@ #ifndef CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H -#define CPPHTTPLIB_VERSION "0.37.2" -#define CPPHTTPLIB_VERSION_NUM "0x002502" +#define CPPHTTPLIB_VERSION "0.38.0" +#define CPPHTTPLIB_VERSION_NUM "0x002600" #ifdef _WIN32 #if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00 @@ -1666,6 +1666,11 @@ public: Server &set_payload_max_length(size_t length); + Server &set_websocket_ping_interval(time_t sec); + template + Server &set_websocket_ping_interval( + const std::chrono::duration &duration); + bool bind_to_port(const std::string &host, int port, int socket_flags = 0); int bind_to_any_port(const std::string &host, int socket_flags = 0); bool listen_after_bind(); @@ -1700,6 +1705,8 @@ protected: time_t idle_interval_sec_ = CPPHTTPLIB_IDLE_INTERVAL_SECOND; time_t idle_interval_usec_ = CPPHTTPLIB_IDLE_INTERVAL_USECOND; size_t payload_max_length_ = CPPHTTPLIB_PAYLOAD_MAX_LENGTH; + time_t websocket_ping_interval_sec_ = + CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND; private: using Handlers = @@ -1769,6 +1776,7 @@ private: struct MountPointEntry { std::string mount_point; std::string base_dir; + std::string resolved_base_dir; Headers headers; }; std::vector base_dirs_; @@ -2186,6 +2194,10 @@ protected: virtual bool create_and_connect_socket(Socket &socket, Error &error); virtual bool ensure_socket_connection(Socket &socket, Error &error); + virtual bool setup_proxy_connection( + Socket &socket, + std::chrono::time_point start_time, + Response &res, bool &success, Error &error); // All of: // shutdown_ssl @@ -2712,6 +2724,10 @@ private: std::function callback) override; bool is_ssl() const override; + bool setup_proxy_connection( + Socket &socket, + std::chrono::time_point start_time, + Response &res, bool &success, Error &error) override; bool connect_with_proxy( Socket &sock, std::chrono::time_point start_time, @@ -2911,6 +2927,8 @@ std::string encode_query_component(const std::string &component, std::string decode_query_component(const std::string &component, bool plus_as_space = true); +std::string sanitize_filename(const std::string &filename); + std::string append_query_params(const std::string &path, const Params ¶ms); std::pair make_range_header(const Ranges &ranges); @@ -3714,15 +3732,19 @@ private: friend class httplib::Server; friend class WebSocketClient; - WebSocket(Stream &strm, const Request &req, bool is_server) - : strm_(strm), req_(req), is_server_(is_server) { + WebSocket( + Stream &strm, const Request &req, bool is_server, + time_t ping_interval_sec = CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND) + : strm_(strm), req_(req), is_server_(is_server), + ping_interval_sec_(ping_interval_sec) { start_heartbeat(); } - WebSocket(std::unique_ptr &&owned_strm, const Request &req, - bool is_server) + WebSocket( + std::unique_ptr &&owned_strm, const Request &req, bool is_server, + time_t ping_interval_sec = CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND) : strm_(*owned_strm), owned_strm_(std::move(owned_strm)), req_(req), - is_server_(is_server) { + is_server_(is_server), ping_interval_sec_(ping_interval_sec) { start_heartbeat(); } @@ -3733,6 +3755,7 @@ private: std::unique_ptr owned_strm_; Request req_; bool is_server_; + time_t ping_interval_sec_; std::atomic closed_{false}; std::mutex write_mutex_; std::thread ping_thread_; @@ -3761,6 +3784,7 @@ public: const std::string &subprotocol() const; void set_read_timeout(time_t sec, time_t usec = 0); void set_write_timeout(time_t sec, time_t usec = 0); + void set_websocket_ping_interval(time_t sec); #ifdef CPPHTTPLIB_SSL_ENABLED void set_ca_cert_path(const std::string &path); @@ -3784,6 +3808,8 @@ private: time_t read_timeout_usec_ = 0; time_t write_timeout_sec_ = CPPHTTPLIB_CLIENT_WRITE_TIMEOUT_SECOND; time_t write_timeout_usec_ = CPPHTTPLIB_CLIENT_WRITE_TIMEOUT_USECOND; + time_t websocket_ping_interval_sec_ = + CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND; #ifdef CPPHTTPLIB_SSL_ENABLED bool is_ssl_ = false;