Compare commits

..
9 Commits
Author SHA1 Message Date
yhirose 0715c2739e Enforce a minimum SSE reconnect wait to avoid a busy loop (#2592)
SSEClient::wait_for_reconnect() sleeps in 100ms steps until the
reconnect interval has elapsed. With an interval of 0 (for example
"retry: 0" from the server, or set_reconnect_interval(0)) it never
slept at all, so a server that sends "retry: 0" and closes the stream
made the client reconnect in a tight loop. set_max_reconnect_attempts()
does not stop this either, because each successful connection resets
the attempt counter.

Always wait at least one step (100ms). Intervals of 1-99ms already
waited 100ms because of the step size, so only 0 and negative values
change behavior.
2026-09-28 17:25:37 -04:00
KBS 3330d0eb06 Ignore an SSE retry field that is not all digits (#2591)
parse_sse_line checked only the error code of from_chars, which accepts
a leading '-' and stops at the first non-digit, so retry: -1 made the
client reconnect without waiting and retry: 10s set 10 ms. The SSE spec
ignores a retry value that is not all ASCII digits.
2026-09-28 15:31:20 -04:00
yhirose c1c2b1f4b4 Apply the request-target check to the client and encode control chars
Share the server's request-target check as fields::is_request_target()
and use it in write_request_line too. The client previously used
is_field_value(), which let an embedded SP or HTAB through.

encode_path() only escaped CR/LF among the control characters, so with
path encoding enabled a path like "/a\tb" would now be rejected instead
of sent. Percent-encode every control character (0x00-0x1F, 0x7F).
2026-09-28 03:37:11 -04:00
yhirose e11dbec7b3 Reject control characters in the request-target
RFC 9112 §3.2 does not allow control characters in the request-target,
and §2.2 requires a bare CR to be treated as invalid. parse_request_line
accepted them, so e.g. "GET /a\rb HTTP/1.1" was routed normally. Reject
any byte that is not VCHAR or obs-text with 400 Bad Request. obs-text is
still allowed since some clients send raw UTF-8 in the target.
2026-09-27 23:35:09 -04:00
yhirose 174bce5ccf Escape request data in the docker server's access and error logs
req.path is percent-decoded, so a request like GET /%0D%0A... put a
literal CR/LF into the NGINX-style log lines and let a client forge
extra entries. Log the raw req.target (matching NGINX's $request) and
escape '"', '\', control and non-ASCII bytes as \xHH the way NGINX
does. Also note in the README logging section that req.path may
contain control characters and should be escaped before logging.
2026-09-27 22:50:26 -04:00
DosX 57c4f7f385 Reject trailing characters in HTTP quality values (#2590)
parse_quality accepted values such as q=0.5junk because it checked only the conversion error and ignored the returned end pointer. Require the numeric parser to consume the complete q parameter so malformed Accept values are rejected and invalid Accept-Encoding weights are ignored. Add regression cases for both headers.
2026-09-27 19:20:14 -04:00
yhirose 2fb2dbbe1e Send small static files with the headers and large bodies without a copy (#2589)
A file served from a mount point or through set_file_content() left in two
writes, one for the status line and headers and one for the body, because the
body came from a content provider. A small file is now read into the header
buffer so the whole response leaves in a single write. Only file-backed
providers are coalesced this way: a user-supplied provider may produce its data
over time, and holding the headers back until it finishes would stall the
client.

A large set_content() body was copied into the header buffer before being
sent. A body of CPPHTTPLIB_SEND_BUFSIZ or more is now written directly after
the headers, which saves the copy at the cost of one extra write.
2026-09-26 22:19:15 -04:00
VecSzn 8b6ab24159 Resolve relative Location references on redirect (#2586) 2026-09-26 19:49:21 -04:00
Tobias WallnerandTobias Wallner 5a202d3d5f Added a feature test to auto enable/disable CPPHTTPLIB_USE_NON_BLOCKI… (#2578)
* Added a feature test to auto enable/disable CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO

* turned status: WARNING 'GetAddrInfoExCancel is unavailable; disabling non-blocking getaddrinfo.' into a warning

* added ws2_32 for the GetAddrInfoExCancel. this catches previous false negatives

---------

Co-authored-by: Tobias Wallner <tobias.wallner@qtlabs.at>
2026-09-25 15:20:00 -04:00
6 changed files with 388 additions and 52 deletions
+21 -3
View File
@@ -15,7 +15,7 @@
* HTTPLIB_REQUIRE_BROTLI (default off)
* HTTPLIB_REQUIRE_ZSTD (default off)
* HTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES (default off)
* HTTPLIB_USE_NON_BLOCKING_GETADDRINFO (default on)
* HTTPLIB_USE_NON_BLOCKING_GETADDRINFO (default on when supported)
* HTTPLIB_COMPILE (default off)
* HTTPLIB_INSTALL (default on)
* HTTPLIB_SHARED (default off) builds as a shared library (if HTTPLIB_COMPILE is ON)
@@ -181,6 +181,24 @@ if(HTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES)
set(HTTPLIB_IS_USING_MACOSX_AUTOMATIC_ROOT_CERTIFICATES FALSE)
endif()
set(HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO ${HTTPLIB_USE_NON_BLOCKING_GETADDRINFO})
if(HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO AND WIN32)
include(CheckCXXSymbolExists)
set(_httplib_cmake_required_definitions ${CMAKE_REQUIRED_DEFINITIONS})
set(_httplib_cmake_required_libraries ${CMAKE_REQUIRED_LIBRARIES})
list(APPEND CMAKE_REQUIRED_DEFINITIONS -D_WIN32_WINNT=0x0A00)
list(APPEND CMAKE_REQUIRED_LIBRARIES ws2_32)
check_cxx_symbol_exists(GetAddrInfoExCancel "winsock2.h;ws2tcpip.h" HTTPLIB_HAVE_GETADDRINFOEXCANCEL)
set(CMAKE_REQUIRED_DEFINITIONS ${_httplib_cmake_required_definitions})
set(CMAKE_REQUIRED_LIBRARIES ${_httplib_cmake_required_libraries})
unset(_httplib_cmake_required_definitions)
unset(_httplib_cmake_required_libraries)
if(NOT HTTPLIB_HAVE_GETADDRINFOEXCANCEL)
set(HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO FALSE)
message(WARNING "GetAddrInfoExCancel is unavailable; disabling non-blocking getaddrinfo.")
endif()
endif()
# Threads needed for <thread> on some systems, and for <pthread.h> on Linux
set(THREADS_PREFER_PTHREAD_FLAG TRUE)
@@ -367,7 +385,7 @@ target_link_libraries(${PROJECT_NAME} ${_INTERFACE_OR_PUBLIC}
# Needed for API from MacOS Security framework
"$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_IS_USING_OPENSSL}>,$<BOOL:${HTTPLIB_IS_USING_MACOSX_AUTOMATIC_ROOT_CERTIFICATES}>>:-framework CFNetwork -framework CoreFoundation -framework Security>"
# Needed for non-blocking getaddrinfo on MacOS
"$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_USE_NON_BLOCKING_GETADDRINFO}>>:-framework CFNetwork -framework CoreFoundation>"
"$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO}>>:-framework CFNetwork -framework CoreFoundation>"
# Can't put multiple targets in a single generator expression or it bugs out.
$<$<BOOL:${HTTPLIB_IS_USING_BROTLI}>:Brotli::common>
$<$<BOOL:${HTTPLIB_IS_USING_BROTLI}>:Brotli::encoder>
@@ -390,7 +408,7 @@ target_compile_definitions(${PROJECT_NAME} ${_INTERFACE_OR_PUBLIC}
$<$<BOOL:${HTTPLIB_IS_USING_WOLFSSL}>:CPPHTTPLIB_WOLFSSL_SUPPORT>
$<$<BOOL:${HTTPLIB_IS_USING_MBEDTLS}>:CPPHTTPLIB_MBEDTLS_SUPPORT>
$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES}>>:CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES>
$<$<BOOL:${HTTPLIB_USE_NON_BLOCKING_GETADDRINFO}>:CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO>
$<$<BOOL:${HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO}>:CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO>
)
# CMake configuration files installation directory
+1 -1
View File
@@ -69,7 +69,7 @@ sse.on_error([](httplib::Error err) { });
#### Configuration
```cpp
// Set reconnect interval (default: 3000ms)
// Set reconnect interval (default: 3000ms, minimum: 100ms)
sse.set_reconnect_interval(5000);
// Set max reconnect attempts (default: 0 = unlimited)
+3
View File
@@ -452,6 +452,9 @@ svr.set_logger([](const httplib::Request& req, const httplib::Response& res) {
});
```
> [!NOTE]
> `req.path` is percent-decoded and may contain control characters such as CR/LF. Escape request data before writing it to a log file (see [docker/main.cc](docker/main.cc) for an example).
#### Pre-compression Logging
You can also set a pre-compression logger to capture request/response data before compression is applied:
+26 -6
View File
@@ -48,6 +48,23 @@ std::string get_error_time_format() {
return ss.str();
}
// Escape a value for a log line the way NGINX does: '"', '\\', control
// bytes and non-ASCII bytes become \xHH. Request fields are attacker-controlled
// (e.g. a raw CR in the request target or a decoded %0D%0A in req.path), so
// writing them verbatim would let a client forge extra log lines.
std::string escape_log(const std::string &s) {
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if (c == '"' || c == '\\' || c < 0x20 || c >= 0x7f) {
out += std::format("\\x{:02X}", c);
} else {
out += static_cast<char>(c);
}
}
return out;
}
// NGINX Combined log format:
// $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent
// "$http_referer" "$http_user_agent"
@@ -55,7 +72,9 @@ void nginx_access_logger(const Request &req, const Response &res) {
std::string remote_user =
"-"; // cpp-httplib doesn't have built-in auth user tracking
auto time_local = get_time_format();
auto request = std::format("{} {} {}", req.method, req.path, req.version);
// $request is the original request line, so log the raw target rather than
// the percent-decoded req.path.
auto request = std::format("{} {} {}", req.method, req.target, req.version);
auto status = res.status;
auto body_bytes_sent = res.body.size();
auto http_referer = req.get_header_value("Referer");
@@ -64,9 +83,9 @@ void nginx_access_logger(const Request &req, const Response &res) {
if (http_user_agent.empty()) http_user_agent = "-";
std::cout << std::format("{} - {} [{}] \"{}\" {} {} \"{}\" \"{}\"",
req.remote_addr, remote_user, time_local, request,
status, body_bytes_sent, http_referer,
http_user_agent)
req.remote_addr, remote_user, time_local,
escape_log(request), status, body_bytes_sent,
escape_log(http_referer), escape_log(http_user_agent))
<< std::endl;
}
@@ -79,14 +98,15 @@ void nginx_error_logger(const Error &err, const Request *req) {
if (req) {
auto request =
std::format("{} {} {}", req->method, req->path, req->version);
std::format("{} {} {}", req->method, req->target, req->version);
auto host = req->get_header_value("Host");
if (host.empty()) host = "-";
std::cerr << std::format("{} [{}] {}, client: {}, request: "
"\"{}\", host: \"{}\"",
time_local, level, to_string(err),
req->remote_addr, request, host)
req->remote_addr, escape_log(request),
escape_log(host))
<< std::endl;
} else {
// If no request context, just log the error
+114 -31
View File
@@ -939,6 +939,48 @@ inline bool parse_url(const std::string &url, UrlComponents &uc) {
return true;
}
// 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.
inline std::string resolve_relative_location(const std::string &location,
const std::string &base) {
if (location.empty() || location[0] == '/') { return location; }
// A ':' in the first segment means the value has a scheme.
if (location.find(':') < location.find_first_of("/?#")) { return location; }
if (location[0] == '#') { return base.substr(0, base.find('#')) + location; }
auto base_path = base.substr(0, base.find_first_of("?#"));
if (location[0] == '?') { return base_path + location; }
if (base_path.empty() || base_path[0] != '/') { base_path = "/"; }
auto merged = base_path.substr(0, base_path.rfind('/') + 1) + 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);
}
} // namespace detail
enum class SSLVerifierResponse {
@@ -1842,6 +1884,7 @@ struct Response {
ContentProvider content_provider_;
ContentProviderResourceReleaser content_provider_resource_releaser_;
bool is_chunked_content_provider_ = false;
bool is_file_content_provider_ = false;
bool content_provider_success_ = false;
std::string file_content_path_;
std::string file_content_content_type_;
@@ -3914,6 +3957,7 @@ bool is_field_vchar(char c);
bool is_field_content(const std::string &s);
bool is_field_value(const std::string &s);
bool is_field_valid(const std::string &name, const std::string &value);
bool is_request_target(const std::string &s);
} // namespace fields
} // namespace detail
@@ -4870,7 +4914,8 @@ inline bool SSEClient::parse_sse_line(const std::string &line, SSEMessage &msg,
msg.id = value;
} else if (field == "retry") {
// Parse retry interval in milliseconds
{
// Per the SSE spec, a value that is not all ASCII digits is ignored.
if (detail::is_numeric(value)) {
int v = 0;
auto res =
detail::from_chars(value.data(), value.data() + value.size(), v);
@@ -5006,11 +5051,15 @@ inline bool SSEClient::should_reconnect(int count) const {
}
inline void SSEClient::wait_for_reconnect() {
// Use small increments to check running_ flag frequently
// Use small increments to check running_ flag frequently.
// Always wait at least one increment, so that a zero interval (e.g.
// "retry: 0" from the server) cannot cause a busy reconnect loop.
const auto step_ms = 100;
auto interval_ms = (std::max)(reconnect_interval_ms_, step_ms);
auto waited = 0;
while (running_.load() && waited < reconnect_interval_ms_) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
waited += 100;
while (running_.load() && waited < interval_ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(step_ms));
waited += step_ms;
}
}
@@ -5778,15 +5827,15 @@ inline std::string encode_path(const std::string &s) {
switch (s[i]) {
case ' ': result += "%20"; break;
case '+': result += "%2B"; break;
case '\r': result += "%0D"; break;
case '\n': result += "%0A"; break;
case '\'': result += "%27"; break;
case ',': result += "%2C"; break;
// case ':': result += "%3A"; break; // ok? probably...
case ';': result += "%3B"; break;
default:
auto c = static_cast<uint8_t>(s[i]);
if (c >= 0x80) {
// Control characters (incl. CR/LF) and non-ASCII bytes are not allowed
// in a request-target as-is.
if (c < 0x20 || c == 0x7f || c >= 0x80) {
result += '%';
char hex[4];
auto len = snprintf(hex, sizeof(hex) - 1, "%02X", c);
@@ -7580,7 +7629,8 @@ inline bool parse_quality(const char *b, const char *e, std::string &token,
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) {
if (res.ec != std::errc{} || res.ptr != pb + r.second ||
v < 0.0 || v > 1.0) {
invalid = true;
return true;
}
@@ -8517,14 +8567,11 @@ bool read_content(Stream &strm, T &x, size_t payload_max_length, int &status,
inline ssize_t write_request_line(Stream &strm, const std::string &method,
const std::string &path) {
// Neither the method nor the request target may carry CR/LF (or other
// control octets); otherwise a value smuggled into either splits the request
// line and injects headers or a whole request. The method must be a token
// (RFC 9110 Section 9.1), which also rejects an empty method and embedded
// spaces. The target gets the same field-value check that already guards
// header values in check_and_write_headers.
// Neither the method nor the request target may carry CR/LF, SP or other
// control octets; otherwise a value smuggled into either splits the request
// line and injects headers or a whole request.
if (!fields::is_token(method)) { return -1; }
if (!fields::is_field_value(path)) { return -1; }
if (!fields::is_request_target(path)) { return -1; }
std::string s = method;
s += ' ';
@@ -8746,9 +8793,9 @@ inline bool compress_content_provider(const ContentProvider &content_provider,
return cmp.compress(nullptr, 0, true, append);
}
// Serves `m` as the response body. `set_content_provider()` clears the coding,
// so recording it has to come after; keeping both here means a third
// file-serving path cannot get that order wrong.
// Serves `m` as the response body. `set_content_provider()` clears the coding
// and the file flag, so recording them has to come after; keeping all of it
// here means a third file-serving path cannot get that order wrong.
inline void set_file_content_provider(Response &res,
const std::shared_ptr<mmap> &m,
const std::string &content_type,
@@ -8760,6 +8807,7 @@ inline void set_file_content_provider(Response &res,
return true;
});
res.is_file_content_provider_ = true;
res.content_coding_ = encoding;
}
@@ -10177,6 +10225,12 @@ inline bool is_field_valid(const std::string &name, const std::string &value) {
return is_field_name(name) && is_field_value(value);
}
// RFC 9112 §2.2/§3.2: the request-target has no SP, HTAB or other control
// characters (incl. bare CR). obs-text (raw UTF-8) is allowed.
inline bool is_request_target(const std::string &s) {
return std::all_of(s.begin(), s.end(), is_field_vchar);
}
} // namespace fields
inline bool perform_websocket_handshake(Stream &strm, Request &req,
@@ -11660,6 +11714,7 @@ inline void Response::set_content_provider(
if (in_length > 0) { content_provider_ = std::move(provider); }
content_provider_resource_releaser_ = std::move(resource_releaser);
is_chunked_content_provider_ = false;
is_file_content_provider_ = false;
content_coding_ = detail::EncodingType::None;
}
@@ -11671,6 +11726,7 @@ inline void Response::set_content_provider(
content_provider_ = detail::ContentProviderAdapter(std::move(provider));
content_provider_resource_releaser_ = std::move(resource_releaser);
is_chunked_content_provider_ = false;
is_file_content_provider_ = false;
content_coding_ = detail::EncodingType::None;
}
@@ -11682,6 +11738,7 @@ inline void Response::set_chunked_content_provider(
content_provider_ = detail::ContentProviderAdapter(std::move(provider));
content_provider_resource_releaser_ = std::move(resource_releaser);
is_chunked_content_provider_ = true;
is_file_content_provider_ = false;
content_coding_ = detail::EncodingType::None;
}
@@ -13248,6 +13305,8 @@ inline bool Server::parse_request_line(const char *s, Request &req) const {
return false;
}
if (!detail::fields::is_request_target(req.target)) { return false; }
{
// Skip URL fragment
for (size_t i = 0; i < req.target.size(); i++) {
@@ -13335,9 +13394,27 @@ inline bool Server::write_response_core(Stream &strm, bool close_connection,
if (!detail::write_response_line(bstrm, res.status)) { return false; }
if (header_writer_(bstrm, res.headers) <= 0) { return false; }
// Combine small body with headers to reduce write syscalls
if (req.method != "HEAD" && !res.body.empty() && !res.content_provider_) {
// Combine a small body with the headers so the whole response leaves in a
// single write. A large body is written on its own instead: a copy of it
// costs more than the extra write saves.
auto send_body = req.method != "HEAD";
auto body_is_separate = false;
auto provider_done = false;
if (send_body && !res.body.empty() && !res.content_provider_) {
if (res.body.size() < CPPHTTPLIB_SEND_BUFSIZ) {
bstrm.write(res.body.data(), res.body.size());
} else {
body_is_separate = true;
}
} else if (send_body && res.content_provider_ &&
res.is_file_content_provider_ &&
res.content_length_ < CPPHTTPLIB_SEND_BUFSIZ) {
// A small file is read into the same buffer. Other providers may produce
// their data over time, so they are never held back.
if (!write_content_with_provider(bstrm, req, res, boundary, content_type)) {
return false;
}
provider_done = true;
}
// Log before writing to avoid race condition with client-side code that
@@ -13348,17 +13425,20 @@ inline bool Server::write_response_core(Stream &strm, bool close_connection,
auto &data = bstrm.get_buffer();
if (!detail::write_data(strm, data.data(), data.size())) { return false; }
// Streaming body
auto ret = true;
if (req.method != "HEAD" && res.content_provider_) {
if (write_content_with_provider(strm, req, res, boundary, content_type)) {
res.content_provider_success_ = true;
} else {
ret = false;
}
if (body_is_separate) {
return detail::write_data(strm, res.body.data(), res.body.size());
}
return ret;
// Streaming body
if (send_body && res.content_provider_) {
if (!provider_done &&
!write_content_with_provider(strm, req, res, boundary, content_type)) {
return false;
}
res.content_provider_success_ = true;
}
return true;
}
inline bool
@@ -15506,7 +15586,10 @@ inline bool ClientImpl::redirect(Request &req, Response &res, Error &error) {
if (location.empty()) { return false; }
detail::UrlComponents uc;
if (!detail::parse_url(location, uc)) { return false; }
if (!detail::parse_url(detail::resolve_relative_location(location, req.path),
uc)) {
return false;
}
// Only follow http/https redirects
if (!uc.scheme.empty() && uc.scheme != "http" && uc.scheme != "https") {
+222 -10
View File
@@ -1059,6 +1059,10 @@ TEST(ParseAcceptHeaderTest, InvalidCases) {
EXPECT_FALSE(detail::parse_accept_header(
"text/html;q=invalid,application/json", result));
// A valid numeric prefix does not make the entire quality value valid.
EXPECT_FALSE(detail::parse_accept_header(
"text/html;q=0.5junk,application/json", result));
// Empty quality value
EXPECT_FALSE(
detail::parse_accept_header("text/html;q=,application/json", result));
@@ -2112,6 +2116,16 @@ TEST(ParseAcceptEncoding5, AcceptEncodingQZeroVariants) {
EXPECT_TRUE(ret == detail::EncodingType::None);
}
TEST(ParseAcceptEncodingTest, RejectsTrailingQualityCharacters) {
Request req;
req.set_header("Accept-Encoding", "gzip;q=0.5junk");
Response res;
res.set_header("Content-Type", "text/plain");
EXPECT_EQ(detail::EncodingType::None, detail::encoding_type(req, res));
}
TEST(ParseAcceptEncoding6, AcceptEncodingXGzipQZero) {
// x-gzip;q=0 should not cause "gzip" to be incorrectly detected
Request req;
@@ -4046,6 +4060,37 @@ TEST(PathUrlEncodeTest, StreamingCRLFInTargetIsEncoded) {
}
}
TEST(PathUrlEncodeTest, ControlCharsInPathAreEncoded) {
// Every control character is percent-encoded, not just CR/LF, so the target
// passes the request-target check in write_request_line.
Server svr;
std::string target;
svr.set_pre_routing_handler([&](const Request &req, Response &res) {
target = req.target;
res.status = StatusCode::OK_200;
return Server::HandlerResponse::Handled;
});
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();
{
Client cli(HOST, port);
auto res = cli.Get("/a\tb\x1b\x7f");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("/a%09b%1B%7F", target);
}
}
TEST(PathUrlEncodeTest, StreamingCRLFRejectedWhenPathEncodeDisabled) {
// Nothing may reach the wire: a raw CR/LF target would split the request
// line and inject headers.
@@ -10078,7 +10123,7 @@ ssize_t write_request_line(Stream &strm, const std::string &method,
} // namespace detail
} // namespace httplib
TEST(RequestLineInjectionTest, RejectsCRLFInTarget) {
TEST(RequestLineInjectionTest, RejectsInvalidCharsInTarget) {
// A well-formed target is written verbatim.
{
detail::BufferStream strm;
@@ -10087,15 +10132,18 @@ TEST(RequestLineInjectionTest, RejectsCRLFInTarget) {
EXPECT_EQ("GET /path?a=b HTTP/1.1\r\n", strm.get_buffer());
}
// A target carrying CR/LF must be rejected 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 Location ("%0D%0A") turns into
// when path encoding is disabled.
// 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
// injects a header or a whole request. This is what a decoded redirect
// Location ("%0D%0A") turns into when path encoding is disabled.
const std::string evil_targets[] = {
"/a\r\nInjected: pwned",
"/a\rInjected",
"/a\nInjected",
"/a\r\n\r\nGET /evil HTTP/1.1\r\nHost: victim\r\n\r\n",
"/a b",
"/a\tb",
"/a\x7f",
};
for (const auto &evil : evil_targets) {
detail::BufferStream strm;
@@ -10106,11 +10154,11 @@ TEST(RequestLineInjectionTest, RejectsCRLFInTarget) {
}
TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) {
// End-to-end counterpart to RejectsCRLFInTarget. With path encoding disabled
// the client transmits the target verbatim, so a CR/LF-bearing target -- what
// a redirect Location "%0D%0A" decodes to -- reaches write_request. The
// client must fail cleanly with Error::Write instead of putting a
// request-line-less, header-injecting request on the wire.
// End-to-end counterpart to RejectsInvalidCharsInTarget. With path encoding
// disabled the client transmits the target verbatim, so a CR/LF-bearing
// target -- what a redirect Location "%0D%0A" decodes to -- reaches
// write_request. The client must fail cleanly with Error::Write instead of
// putting a request-line-less, header-injecting request on the wire.
Server svr;
svr.Get("/a", [](const Request &, Response &res) {
@@ -10376,6 +10424,22 @@ TEST(ServerRequestParsingTest, InvalidSpaceInURL) {
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
TEST(ServerRequestParsingTest, InvalidControlCharInURL) {
for (auto target : {"/h\ri", "/h\x7fi"}) {
std::string out;
test_raw_request(std::string("GET ") + target + " HTTP/1.1\r\n\r\n", &out);
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24)) << target;
}
}
TEST(ServerRequestParsingTest, NonAsciiInURLAccepted) {
std::string out;
test_raw_request("GET /hi?q=\xE3\x81\x82 HTTP/1.1\r\n"
"Connection: close\r\n\r\n",
&out);
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
}
TEST(ServerRequestParsingTest, RemoteAddrSetOnBadRequest) {
Server svr;
@@ -17281,6 +17345,93 @@ TEST(RedirectTest, RedirectWithPlusInPath) {
}
}
TEST(RedirectTest, ResolveRelativeLocation) {
// Examples from RFC 3986 section 5.4, base "http://a/b/c/d;p?q".
const std::vector<std::pair<std::string, std::string>> cases = {
{"g", "/b/c/g"},
{"./g", "/b/c/g"},
{"g/", "/b/c/g/"},
{"?y", "/b/c/d;p?y"},
{"g?y", "/b/c/g?y"},
{"#s", "/b/c/d;p?q#s"},
{"g#s", "/b/c/g#s"},
{"g?y#s", "/b/c/g?y#s"},
{";x", "/b/c/;x"},
{"g;x", "/b/c/g;x"},
{".", "/b/c/"},
{"./", "/b/c/"},
{"..", "/b/"},
{"../", "/b/"},
{"../g", "/b/g"},
{"../..", "/"},
{"../../", "/"},
{"../../g", "/g"},
{"../../../g", "/g"},
{"g.", "/b/c/g."},
{".g", "/b/c/.g"},
{"g..", "/b/c/g.."},
{"..g", "/b/c/..g"},
{"./../g", "/b/g"},
{"./g/.", "/b/c/g/"},
{"g/./h", "/b/c/g/h"},
{"g/../h", "/b/c/h"},
{"g;x=1/./y", "/b/c/g;x=1/y"},
{"g;x=1/../y", "/b/c/y"},
{"g?y/./x", "/b/c/g?y/./x"},
{"g#s/../x", "/b/c/g#s/../x"},
// Not relative-path references, so left for parse_url.
{"/g", "/g"},
{"//g", "//g"},
{"http://g/x", "http://g/x"},
{"g:h", "g:h"},
};
for (const auto &c : cases) {
EXPECT_EQ(c.second,
detail::resolve_relative_location(c.first, "/b/c/d;p?q"))
<< c.first;
}
}
TEST(RedirectTest, RelativeLocationWithoutLeadingSlash) {
Server svr;
svr.Get("/dir/page", [](const Request &req, Response &res) {
res.set_redirect(req.get_param_value("to"));
});
svr.Get("/dir/next", [](const Request &req, Response &res) {
res.set_content(req.target, "text/plain");
});
svr.Get("/other", [](const Request &req, Response &res) {
res.set_content(req.target, "text/plain");
});
auto thread = std::thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::vector<std::pair<std::string, std::string>> cases = {
{"next", "/dir/next"},
{"./next?x=1", "/dir/next?x=1"},
{"../other", "/other"},
};
for (const auto &c : cases) {
Client cli(HOST, PORT);
cli.set_follow_location(true);
auto res = cli.Get("/dir/page?to=" + encode_query_component(c.first));
ASSERT_TRUE(res) << c.first << ": " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status) << c.first;
EXPECT_EQ(c.second, res->body) << c.first;
}
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(RedirectTest, Issue2185_Online) {
SSLClient client("github.com");
@@ -22496,6 +22647,67 @@ TEST_F(SSEIntegrationTest, AutoReconnectAfterDisconnect) {
EXPECT_GE(message_count.load(), 2);
}
// Test: A retry field that is not all ASCII digits is ignored
TEST_F(SSEIntegrationTest, NonDigitRetryFieldIgnored) {
std::atomic<int> connection_count{0};
server_->Get("/bad-retry",
[&connection_count](const Request &, Response &res) {
connection_count.fetch_add(1);
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "retry: -1\ndata: hello\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/bad-retry");
sse.set_reconnect_interval(10000);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
EXPECT_EQ(connection_count.load(), 1);
}
// Test: A retry field of all ASCII digits sets the reconnection time
TEST_F(SSEIntegrationTest, DigitRetryFieldApplied) {
std::atomic<int> connection_count{0};
server_->Get("/zero-retry",
[&connection_count](const Request &, Response &res) {
connection_count.fetch_add(1);
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "retry: 0\ndata: hello\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/zero-retry");
sse.set_reconnect_interval(10000);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
// The server-supplied interval is applied, but never below 100ms, so
// "retry: 0" does not cause a busy reconnect loop
EXPECT_GE(connection_count.load(), 2);
EXPECT_LE(connection_count.load(), 10);
}
// Test: Last-Event-ID sent on reconnect
TEST_F(SSEIntegrationTest, LastEventIdSentOnReconnect) {
std::atomic<int> connection_count{0};