Compare commits

...
5 Commits
Author SHA1 Message Date
yhirose 4f3f9ef19b Release v0.58.0 2026-09-21 20:01:43 -04:00
yhirose 6d59d1e2df Build open_stream's request head in memory before sending
open_stream wrote the request line and each header straight to the
socket, so a header rejected by check_and_write_headers left the request
line and the headers before it on the wire. Build them in a BufferStream
first and flush once, as write_request and the WebSocket handshake do.
2026-09-21 19:15:39 -04:00
yhirose 9386b25dd7 Don't wait for a response to a request rejected before sending
process_request reads the response even after write_request fails, so
that an early response (e.g. 413/414) sent while the body is still being
uploaded is not lost. A request line or header rejected while the
request is being built in memory never reaches the socket, though, so
no response will come and the client blocked until the read timeout (or
until the server closed the idle connection).

write_request now reports such a local rejection, and process_request
returns immediately in that case. Socket write failures still read the
response as before.
2026-09-21 19:15:30 -04:00
yhirose 91c55a4385 Release v0.57.1 2026-09-21 12:47:22 -04:00
yhirose ad88645a83 Reject non-token methods in write_request_line
write_request_line checked the request target for CR/LF but concatenated
the method verbatim. A method carrying CR/LF could smuggle a whole
request ahead of the real one, and the client would take the smuggled
request's response as its own. A method with a space or an empty method
put a malformed request line on the wire.

Require the method to be a token (RFC 9110 Section 9.1) before anything
is written. All three callers (the buffered client path, open_stream and
the WebSocket handshake) go through this function and fail with
Error::Write, as they already do for a rejected target.

Claude-Session: https://claude.ai/code/session_01NTDesJQTQPEuu4o4XCu69g
2026-09-21 12:25:12 -04:00
3 changed files with 226 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.57.0"
version = "0.58.0"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+51 -22
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.57.0"
#define CPPHTTPLIB_VERSION_NUM "0x003900"
#define CPPHTTPLIB_VERSION "0.58.0"
#define CPPHTTPLIB_VERSION_NUM "0x003a00"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -2986,7 +2986,7 @@ private:
bool read_response_line(Stream &strm, const Request &req, Response &res,
bool skip_100_continue = true) const;
bool write_request(Stream &strm, Request &req, bool close_connection,
Error &error, bool skip_body = false);
Error &error, bool skip_body, bool &rejected_locally);
bool write_request_body(Stream &strm, Request &req, Error &error);
void prepare_default_headers(Request &r, bool for_stream,
const std::string &ct);
@@ -8517,11 +8517,13 @@ 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) {
// A request target must not carry CR/LF (or other control octets); otherwise
// a value smuggled into it splits the request line and injects headers or a
// whole request. The same field-value check already guards header values in
// check_and_write_headers and the request target in
// perform_websocket_handshake; apply it here too.
// 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.
if (!fields::is_token(method)) { return -1; }
if (!fields::is_field_value(path)) { return -1; }
std::string s = method;
@@ -15117,16 +15119,30 @@ ClientImpl::open_stream(const std::string &method, const std::string &path,
prepare_default_headers(req, true, content_type);
auto &strm = *handle.stream_;
if (detail::write_request_line(strm, req.method, req.path) < 0) {
handle.error = Error::Write;
handle.response.reset();
return handle;
}
if (!detail::check_and_write_headers(strm, req.headers, header_writer_,
handle.error)) {
handle.response.reset();
return handle;
// Build the request line and headers in memory first, like write_request()
// does, so that a rejected header leaves nothing on the wire.
{
detail::BufferStream bstrm;
if (detail::write_request_line(bstrm, req.method, req.path) < 0) {
handle.error = Error::Write;
handle.response.reset();
return handle;
}
if (!detail::check_and_write_headers(bstrm, req.headers, header_writer_,
handle.error)) {
handle.response.reset();
return handle;
}
const auto &data = bstrm.get_buffer();
if (!detail::write_data(strm, data.data(), data.size())) {
handle.error = Error::Write;
handle.response.reset();
return handle;
}
}
if (!body.empty()) {
@@ -15679,7 +15695,9 @@ inline bool ClientImpl::write_content_with_provider(Stream &strm,
inline bool ClientImpl::write_request(Stream &strm, Request &req,
bool close_connection, Error &error,
bool skip_body) {
bool skip_body, bool &rejected_locally) {
rejected_locally = false;
// Prepare additional headers
if (close_connection) {
if (!req.has_header("Connection")) {
@@ -15768,15 +15786,18 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req,
// Write request line and headers
if (detail::write_request_line(bstrm, req.method, path_with_query) < 0) {
// A rejected target (e.g. CR/LF smuggled in via a decoded redirect
// Location under set_path_encode(false)) must fail the request cleanly
// instead of emitting a request-line-less, header-injecting request.
// A rejected method (not a token, e.g. carrying CR/LF) or target (e.g.
// CR/LF smuggled in via a decoded redirect Location under
// set_path_encode(false)) must fail the request cleanly instead of
// emitting a request-line-less, header-injecting request.
error = Error::Write;
rejected_locally = true;
output_error_log(error, &req);
return false;
}
if (!detail::check_and_write_headers(bstrm, req.headers, header_writer_,
error)) {
rejected_locally = true;
output_error_log(error, &req);
return false;
}
@@ -16045,8 +16066,16 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
detail::has_header_token(req.headers, "Expect", "100-continue");
// Send request (skip body if using Expect: 100-continue)
auto rejected_locally = false;
auto write_request_success =
write_request(strm, req, close_connection, error, expect_100_continue);
write_request(strm, req, close_connection, error, expect_100_continue,
rejected_locally);
// A failed write normally still reads the response below, since the server
// may have answered early (e.g. 413/414) and closed while the body was being
// sent. A request rejected before any byte reached the socket gets no such
// response, and waiting for one would block until the read timeout.
if (rejected_locally) { return false; }
#ifdef CPPHTTPLIB_SSL_ENABLED
if (is_ssl() && !expect_100_continue) {
+174
View File
@@ -10141,6 +10141,90 @@ TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) {
}
}
TEST(RequestLineInjectionTest, RejectsNonTokenMethod) {
// Methods that are tokens (RFC 9110 Section 9.1) are written verbatim,
// including extension methods.
const std::string good_methods[] = {"GET", "PROPFIND", "M-SEARCH"};
for (const auto &method : good_methods) {
detail::BufferStream strm;
auto n = detail::write_request_line(strm, method, "/");
EXPECT_GT(n, 0);
EXPECT_EQ(method + " / HTTP/1.1\r\n", strm.get_buffer());
}
// A method carrying CR/LF would split the request line and smuggle a whole
// request ahead of the real one. A space or an empty method corrupts the
// request line. All must be rejected before anything reaches the wire.
const std::string evil_methods[] = {
"GET /smuggled HTTP/1.1\r\nHost: x\r\n\r\nGET",
"GET\r\nInjected: pwned",
"GET\r",
"GET\n",
"GE T",
"",
};
for (const auto &evil : evil_methods) {
detail::BufferStream strm;
auto n = detail::write_request_line(strm, evil, "/");
EXPECT_LT(n, 0);
EXPECT_TRUE(strm.get_buffer().empty());
}
}
TEST(RequestLineInjectionTest, ClientRejectsNonTokenMethodEndToEnd) {
// End-to-end counterpart to RejectsNonTokenMethod: a smuggling method passed
// through Client::send must fail with Error::Write and no request, neither
// the smuggled one nor the real one, may reach the server.
Server svr;
std::atomic<int> request_count(0);
svr.set_pre_routing_handler([&](const Request &, Response &res) {
request_count++;
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);
const std::string evil_methods[] = {
"GET /smuggled HTTP/1.1\r\nHost: x\r\n\r\nGET",
"GE T",
"",
};
for (const auto &evil : evil_methods) {
Request req;
req.method = evil;
req.path = "/";
auto res = cli.send(req);
EXPECT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
}
auto handle =
cli.open_stream("GET /smuggled HTTP/1.1\r\nHost: x\r\n\r\nGET", "/");
EXPECT_FALSE(handle.is_valid());
EXPECT_EQ(Error::Write, handle.error);
// A valid request on the same client still goes through.
auto res = cli.Get("/");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
EXPECT_EQ(1, request_count.load());
}
// Sends a raw request and verifies that there isn't a crash or exception.
static void test_raw_request(const std::string &req,
std::string *out = nullptr) {
@@ -17328,6 +17412,96 @@ TEST(VulnerabilityTest, CRLFInjectionInHeaders) {
server_thread.join();
}
// A request rejected before any byte reaches the socket must fail right away
// instead of waiting for a response the server will never send.
TEST(ClientRejectedRequestTest, DoesNotWaitForResponse) {
// The kernel completes the TCP handshake from the listen backlog, so the
// client connects, but nothing ever reads, responds or closes.
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 1));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
ASSERT_EQ(0, ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, ::listen(srv, 8));
auto cli = Client("127.0.0.1", PORT + 1);
cli.set_read_timeout(10, 0);
auto elapsed_ms = [](std::chrono::steady_clock::time_point start) {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
};
{
Request req;
req.method = "GE T";
req.path = "/";
auto start = std::chrono::steady_clock::now();
auto res = cli.send(req);
EXPECT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
EXPECT_LT(elapsed_ms(start), 1000);
}
{
auto start = std::chrono::steady_clock::now();
auto res = cli.Get("/", Headers{{"A", "B\r\nEvil: 1"}});
EXPECT_FALSE(res);
EXPECT_EQ(Error::InvalidHeaders, res.error());
EXPECT_LT(elapsed_ms(start), 1000);
}
EXPECT_FALSE(cli.is_socket_open());
detail::close_socket(srv);
}
TEST(ClientRejectedRequestTest, OpenStreamSendsNothingOnInvalidHeader) {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 1));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
ASSERT_EQ(0, ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, ::listen(srv, 1));
std::string received;
auto server_thread = std::thread([&] {
auto sock = ::accept(srv, nullptr, nullptr);
if (sock == INVALID_SOCKET) { return; }
detail::set_socket_opt_time(sock, SOL_SOCKET, SO_RCVTIMEO, 2, 0);
char buf[2048];
ssize_t n;
while ((n = ::recv(sock, buf, sizeof(buf), 0)) > 0) {
received.append(buf, static_cast<size_t>(n));
}
detail::close_socket(sock);
});
{
auto cli = Client("127.0.0.1", PORT + 1);
// "Z" sorts after the default headers, so writing straight to the socket
// would have sent the request line and those headers before the rejection.
auto handle =
cli.open_stream("GET", "/", Params{}, Headers{{"Z", "B\r\nEvil: 1"}});
EXPECT_FALSE(handle.is_valid());
EXPECT_EQ(Error::InvalidHeaders, handle.error);
}
server_thread.join();
detail::close_socket(srv);
EXPECT_TRUE(received.empty()) << received;
}
TEST(PathParamsTest, StaticMatch) {
const auto pattern = "/users/all";
detail::PathParamsMatcher matcher(pattern);