mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-11-03 13:38:36 +00:00
Compare commits
5
Commits
v0.57.0
...
4f3f9ef19b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3f9ef19b | ||
|
|
6d59d1e2df | ||
|
|
9386b25dd7 | ||
|
|
91c55a4385 | ||
|
|
ad88645a83 |
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user