Compare commits

...
Author SHA1 Message Date
yhirose 086a648364 Pass the server's intermediates to Windows certificate verification
CryptoAPI got only the leaf, so it fetched an issuer from the leaf's AIA
URL instead of using the intermediates the server sent. For
accounts.spotify.com that issuer chains to Certainly Root R1, which
Windows does not trust, while the server's own chain ends at Starfield
Root G2. Add the server's intermediates to the store CryptoAPI builds
the chain from. This covers the OpenSSL backend.

Refs #2596
2026-10-02 09:25:55 -04:00
yhirose 639391ad7f Reject an invalid Content-Length and honor Connection: close
A request whose Content-Length was present but not a valid decimal length
(e.g. "42, 42", "+42", "0x2e" or empty) was treated as having no body
unless a handler read it: no 400 was returned, the body was not drained,
and the bytes after the header block were parsed as the next request on
the keep-alive connection. Reject such a request with 400 and close the
connection before routing, as RFC 9112 Section 6.3 requires.

The server also kept reading after a response that announced
Connection: close. A rejected request line or header block left the rest
of the message to be parsed as a new request, and an error response to a
bodyless request did the same with whatever followed. Close the
connection whenever the final response carries Connection: close
(RFC 9112 Section 9.6), and mark the two request-head rejection paths
closed explicitly as the other rejection paths already do.
2026-10-02 00:22:24 -04:00
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 573 additions and 110 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
+176 -59
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,
@@ -10644,10 +10698,9 @@ inline bool match_hostname(const std::string &pattern,
// Verify certificate using Windows CertGetCertificateChain API.
// This provides real-time certificate validation with Windows Update
// integration, independent of the TLS backend (OpenSSL or MbedTLS).
inline bool
verify_cert_with_windows_schannel(const std::vector<unsigned char> &der_cert,
const std::string &hostname,
bool verify_hostname, uint64_t &out_error) {
inline bool verify_cert_with_windows_schannel(
const std::vector<unsigned char> &der_cert, const std::string &hostname,
bool verify_hostname, uint64_t &out_error, tls::const_session_t session) {
if (der_cert.empty()) { return false; }
out_error = 0;
@@ -10665,6 +10718,24 @@ verify_cert_with_windows_schannel(const std::vector<unsigned char> &der_cert,
auto cert_guard =
scope_exit([&] { CertFreeCertificateContext(cert_context); });
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
// Add the intermediates the server sent. Without them CryptoAPI follows the
// leaf's AIA URL, which may lead to an issuer under an untrusted root.
auto store = CertOpenStore(CERT_STORE_PROV_MEMORY, 0, 0, 0, nullptr);
auto store_guard = scope_exit([&] { CertCloseStore(store, 0); });
auto sk = SSL_get_peer_cert_chain(static_cast<const SSL *>(session));
for (int i = 1; sk && i < sk_X509_num(sk); i++) {
std::vector<unsigned char> der;
tls::get_cert_der(sk_X509_value(sk, i), der);
CertAddEncodedCertificateToStore(store, X509_ASN_ENCODING, der.data(),
static_cast<DWORD>(der.size()),
CERT_STORE_ADD_USE_EXISTING, nullptr);
}
#else
(void)session;
auto store = cert_context->hCertStore;
#endif
// Setup chain parameters
CERT_CHAIN_PARA chain_para = {};
chain_para.cbSize = sizeof(chain_para);
@@ -10672,7 +10743,7 @@ verify_cert_with_windows_schannel(const std::vector<unsigned char> &der_cert,
// Build certificate chain with revocation checking
PCCERT_CHAIN_CONTEXT chain_context = nullptr;
auto chain_result = CertGetCertificateChain(
nullptr, cert_context, nullptr, cert_context->hCertStore, &chain_para,
nullptr, cert_context, nullptr, store, &chain_para,
CERT_CHAIN_CACHE_END_CERT | CERT_CHAIN_REVOCATION_CHECK_END_CERT |
CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT,
nullptr, &chain_context);
@@ -10895,8 +10966,8 @@ inline bool setup_client_tls_session(
if (get_cert_der(server_cert, der)) {
uint64_t wincrypt_error = 0;
if (!verify_cert_with_windows_schannel(
der, host, options.server_hostname_verification,
wincrypt_error)) {
der, host, options.server_hostname_verification, wincrypt_error,
session)) {
return fail(Error::SSLServerVerification, 0, wincrypt_error);
}
}
@@ -11660,6 +11731,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 +11743,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 +11755,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 +13322,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 +13411,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_) {
bstrm.write(res.body.data(), res.body.size());
// 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 +13442,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
@@ -14300,8 +14397,21 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
res.version = "HTTP/1.1";
res.headers = default_headers_;
// Request line and headers
// RFC 9112 §9.6: a server that sends the "close" connection option must
// close the connection after that response, whichever path wrote it (an
// error status, a handler, or a rejected request). Reading on would also
// parse whatever the client sent next on a connection it considers done.
auto honor_connection_close = detail::scope_exit([&] {
if (detail::has_header_token(res.headers, "Connection", "close")) {
connection_closed = true;
}
});
// Request line and headers. A rejected message leaves the rest of it (and
// any body) unread, so the connection cannot be reused: the leftover bytes
// would be parsed as the next request.
if (!parse_request_line(line_reader.ptr(), req)) {
connection_closed = true;
res.status = StatusCode::BadRequest_400;
output_error_log(Error::InvalidRequestLine, &req);
return write_response(strm, close_connection, req, res);
@@ -14309,20 +14419,28 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
// Request headers
if (!detail::read_headers(strm, req.headers)) {
connection_closed = true;
res.status = StatusCode::BadRequest_400;
output_error_log(Error::InvalidHeaders, &req);
return write_response(strm, close_connection, req, res);
}
// RFC 9112 §6.3: Reject requests whose framing is ambiguous, which would
// otherwise let an intermediary and this parser disagree on where the body
// ends and enable request smuggling. Two cases: a non-zero Content-Length
// alongside any Transfer-Encoding (Content-Length: 0 is tolerated for
// compatibility with existing clients), and a Transfer-Encoding whose final
// coding is not chunked, which leaves the body length undeterminable. The
// latter must not fall through to the "no body" path, or the body bytes are
// parsed as the next request on a persistent connection.
if (detail::has_conflicting_content_length(req.headers) ||
// RFC 9112 §6.3: Reject requests whose framing is invalid or ambiguous,
// which would otherwise let an intermediary and this parser disagree on
// where the body ends and enable request smuggling. Three cases: a
// Content-Length that is not a valid decimal length (e.g. "42, 42", "+42"
// or empty), which would otherwise be read as "no body"; a non-zero
// Content-Length alongside any Transfer-Encoding (Content-Length: 0 is
// tolerated for compatibility with existing clients); and a
// Transfer-Encoding whose final coding is not chunked, which leaves the body
// length undeterminable. None of them may fall through to the "no body"
// path, or the body bytes are parsed as the next request on a persistent
// connection.
auto is_invalid_content_length = false;
detail::get_header_value_u64(req.headers, "Content-Length", 0, 0,
is_invalid_content_length);
if (is_invalid_content_length ||
detail::has_conflicting_content_length(req.headers) ||
(req.has_header("Transfer-Encoding") &&
!detail::is_chunked_transfer_encoding(req.headers))) {
connection_closed = true;
@@ -14595,17 +14713,13 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
// keep-alive. Without framing there is no body to drain — reading would
// consume the next request (issue #2450). If the response has committed the
// connection to close, there is no next request to protect.
if (!req.body_consumed_ && detail::has_framed_body(req)) {
if (detail::has_header_token(res.headers, "Connection", "close")) {
if (!req.body_consumed_ && detail::has_framed_body(req) &&
!detail::has_header_token(res.headers, "Connection", "close")) {
int dummy_status;
if (!detail::read_content(
strm, req, payload_max_length_, dummy_status, nullptr,
[](const char *, size_t, size_t, size_t) { return true; }, false)) {
connection_closed = true;
} else {
int dummy_status;
if (!detail::read_content(
strm, req, payload_max_length_, dummy_status, nullptr,
[](const char *, size_t, size_t, size_t) { return true; },
false)) {
connection_closed = true;
}
}
}
@@ -15506,7 +15620,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") {
+346 -41
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;
@@ -13894,6 +13958,15 @@ TEST(SSLClientTest, WindowsCertificateVerification_Disabled) {
auto res = cli.Get("/");
if (res) { EXPECT_NE(StatusCode::InternalServerError_500, res->status); }
}
// The server sends an intermediate cross-signed by a root Windows trusts,
// while the leaf's AIA URL leads to one under a root Windows does not trust.
TEST(SSLClientTest, WindowsCertificateVerification_ServerIntermediates_Online) {
SSLClient cli("accounts.spotify.com", 443);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error())
<< " ssl_backend_error=" << res.ssl_backend_error();
}
#endif
TEST(SSLClientTest, ServerCertificateVerification1_Online) {
@@ -17281,6 +17354,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 +22656,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};
@@ -25077,14 +25298,15 @@ TEST(SymlinkTest, SymlinkEscapeFromBaseDirectory) {
}
#endif
TEST(RequestSmugglingTest, UnconsumedGETBodyOnFileHandler) {
// A GET request with Content-Length to a static file handler must have its
// body drained before the keep-alive connection is reused. Otherwise the
// unread body bytes are interpreted as the next HTTP request.
//
// The body is sent AFTER receiving the first response (as in the original
// PoC) so that the stream_line_reader cannot buffer it together with the
// headers of the first request.
// Sends `outer_head` (with every "{len}" replaced by the length of an embedded
// "GET /smuggled" request), reads the first response, and only then sends the
// embedded request as the body. Returns how many times /smuggled ran.
//
// The body is sent AFTER receiving the first response (as in the original
// PoC) so that the stream_line_reader cannot buffer it together with the
// headers of the first request.
static int count_smuggled_requests(const std::string &outer_head,
std::string &first_response) {
Server svr;
svr.set_mount_point("/", "./www");
@@ -25093,6 +25315,9 @@ TEST(RequestSmugglingTest, UnconsumedGETBodyOnFileHandler) {
smuggled_count++;
res.set_content("oops", "text/plain");
});
svr.Post("/post", [&](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
auto port = svr.bind_to_any_port("localhost");
thread t = thread([&] { svr.listen_after_bind(); });
@@ -25108,28 +25333,28 @@ TEST(RequestSmugglingTest, UnconsumedGETBodyOnFileHandler) {
/*connection_timeout_sec=*/2, 0,
/*read_timeout_sec=*/2, 0,
/*write_timeout_sec=*/2, 0, std::string(), error);
ASSERT_NE(INVALID_SOCKET, sock);
EXPECT_NE(INVALID_SOCKET, sock);
if (sock == INVALID_SOCKET) { return -1; }
auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
// The "smuggled" request will be sent as the body of the outer GET
// The "smuggled" request will be sent as the body of the outer request
std::string smuggled = "GET /smuggled HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n";
auto head = outer_head;
auto len = std::to_string(smuggled.size());
for (auto pos = head.find("{len}"); pos != std::string::npos;
pos = head.find("{len}", pos + len.size())) {
head.replace(pos, 5, len);
}
// Step 1: Send only the outer request headers (no body yet)
std::string outer_headers = "GET /file HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: " +
std::to_string(smuggled.size()) +
"\r\n"
"\r\n";
auto sent = send(sock, head.data(), head.size(), 0);
EXPECT_EQ(static_cast<ssize_t>(head.size()), sent);
auto sent = send(sock, outer_headers.data(), outer_headers.size(), 0);
ASSERT_EQ(static_cast<ssize_t>(outer_headers.size()), sent);
// Step 2: Read the first response (server serves file without reading body)
std::string first_response;
// Step 2: Read the first response
char buf[4096];
for (;;) {
auto n = recv(sock, buf, sizeof(buf), 0);
@@ -25151,23 +25376,103 @@ TEST(RequestSmugglingTest, UnconsumedGETBodyOnFileHandler) {
}
}
}
ASSERT_TRUE(first_response.find("HTTP/1.1 200") != std::string::npos);
// Step 3: Now send the body, which looks like a new HTTP request.
// On a vulnerable server the keep-alive loop reads this as a second request.
sent = send(sock, smuggled.data(), smuggled.size(), 0);
ASSERT_EQ(static_cast<ssize_t>(smuggled.size()), sent);
// Step 3: Now send the body, which looks like a new HTTP request. On a
// vulnerable server the keep-alive loop reads this as a second request. The
// server may already have closed the connection, so the result is ignored.
send(sock, smuggled.data(), smuggled.size(), 0);
// Step 4: Try to read a second response (should NOT exist after fix)
std::string second_response;
// Half-close so that a server which drained the body sees EOF and closes,
// instead of the read below waiting for the read timeout.
#ifdef _WIN32
::shutdown(sock, SD_SEND);
#else
::shutdown(sock, SHUT_WR);
#endif
// Step 4: Read until the server closes the connection
for (;;) {
auto n = recv(sock, buf, sizeof(buf), 0);
if (n <= 0) break;
second_response.append(buf, static_cast<size_t>(n));
}
// The smuggled request must NOT have been processed
EXPECT_EQ(0, smuggled_count.load());
return smuggled_count.load();
}
TEST(RequestSmugglingTest, UnconsumedGETBodyOnFileHandler) {
// A GET request with Content-Length to a static file handler must have its
// body drained before the keep-alive connection is reused. Otherwise the
// unread body bytes are interpreted as the next HTTP request.
std::string first_response;
EXPECT_EQ(0, count_smuggled_requests("GET /file HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: {len}\r\n"
"\r\n",
first_response));
EXPECT_EQ(0u, first_response.find("HTTP/1.1 200"));
}
TEST(RequestSmugglingTest, InvalidContentLengthRejected) {
// RFC 9112 §6.3: a Content-Length that is not a valid decimal length must
// be answered with 400 and the connection closed. Treating it as "no body"
// leaves the body to be parsed as the next request.
const char *values[] = {"{len}, {len}", "+{len}", "0x2e", "", " {len}x"};
const char *targets[] = {
"GET /file", // handler that never reads the body
"GET /not-found", // no handler matches (404)
"POST /post", // handler that reads the body
};
for (auto target : targets) {
for (auto value : values) {
std::string first_response;
EXPECT_EQ(0, count_smuggled_requests(std::string(target) +
" HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: " +
value + "\r\n\r\n",
first_response))
<< target << " with Content-Length: " << value;
EXPECT_EQ(0u, first_response.find("HTTP/1.1 400"))
<< target << " with Content-Length: " << value;
}
}
}
TEST(RequestSmugglingTest, RejectedRequestLineClosesConnection) {
// A 400 for an unparseable request line leaves the rest of the message
// unread, so the connection must be closed rather than reused.
std::string first_response;
EXPECT_EQ(0, count_smuggled_requests("FOO /file HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: {len}\r\n"
"\r\n",
first_response));
EXPECT_EQ(0u, first_response.find("HTTP/1.1 400"));
}
TEST(RequestSmugglingTest, RejectedHeadersCloseConnection) {
// Same as above for a header block that fails to parse.
std::string first_response;
EXPECT_EQ(0, count_smuggled_requests("GET /file HTTP/1.1\r\n"
"Host: localhost\r\n"
"Bad Header\r\n"
"Content-Length: {len}\r\n"
"\r\n",
first_response));
EXPECT_EQ(0u, first_response.find("HTTP/1.1 400"));
}
TEST(RequestSmugglingTest, ErrorResponseClosesConnection) {
// RFC 9112 §9.6: an error response carries "Connection: close", so the
// server must not read another request on that connection, even when the
// request had no body to drain.
std::string first_response;
EXPECT_EQ(0, count_smuggled_requests("GET /not-found HTTP/1.1\r\n"
"Host: localhost\r\n"
"\r\n",
first_response));
EXPECT_EQ(0u, first_response.find("HTTP/1.1 404"));
EXPECT_NE(std::string::npos, first_response.find("Connection: close"));
}
TEST(RequestSmugglingTest, ContentLengthAndTransferEncodingRejected) {