Files
cpp-httplib/test/test.cc
T
yhirose 0acbee16dc Reject an ambiguous or invalid Host in a request
A request with more than one Host field line, or with a Host value
holding a byte that cannot appear in uri-host [ ":" port ], is now
answered with 400 (RFC 9112 §3.2). The handler sees only the first
Host line, so such a request could reach it with an authority that
differs from the one an intermediary acted on.

Only the byte set is checked, not the full grammar. An empty value
stays valid, and a request without a Host line is still accepted.

Close #2623
2026-10-11 23:39:50 -04:00

27386 lines
886 KiB
C++

// NOTE: This file should be saved as UTF-8 w/ BOM
#include <httplib.h>
#include <signal.h>
#ifndef _WIN32
#include <arpa/inet.h>
#include <ctime>
#include <curl/curl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#endif
#include <gtest/gtest.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <clocale>
#include <cstdio>
#include <fstream>
#include <future>
#include <limits>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <vector>
#if __cplusplus >= 202002L
inline std::string u8_to_string(const char8_t *s) {
return std::string(reinterpret_cast<const char *>(s));
}
#define U8(x) u8_to_string(u8##x)
#else
#define U8(x) u8##x
#endif
#define SERVER_CERT_FILE "./cert.pem"
#define SERVER_CERT2_FILE "./cert2.pem"
#define SERVER_CERT_IP_CN_FILE "./cert_ip_cn.pem"
#define SERVER_CERT_IPV6_FILE "./cert_ipv6.pem"
#define SERVER_CERT_SAN_TYPES_FILE "./cert_san_types.pem"
#define SERVER_CERT_WILDCARD_SAN_FILE "./cert_wildcard_san.pem"
#define SERVER_PRIVATE_KEY_FILE "./key.pem"
#define CA_CERT_FILE "./ca-bundle.crt"
#define CLIENT_CA_CERT_FILE "./rootCA.cert.pem"
#define CLIENT_CA_CERT_DIR "."
#define CLIENT_CERT_FILE "./client.cert.pem"
#define CLIENT_PRIVATE_KEY_FILE "./client.key.pem"
#define CLIENT_ENCRYPTED_CERT_FILE "./client_encrypted.cert.pem"
// Mbed TLS < 3.6 (e.g. Ubuntu's 2.28) has no PBES2-AES and needs the PBES1-3DES
// key; 3.6+/4.x (4.x dropped DES) and OpenSSL/wolfSSL use the PBES2 AES key.
#if defined(CPPHTTPLIB_MBEDTLS_SUPPORT) && (MBEDTLS_VERSION_NUMBER < 0x03060000)
#define CLIENT_ENCRYPTED_PRIVATE_KEY_FILE "./client_encrypted_pbes1.key.pem"
#else
#define CLIENT_ENCRYPTED_PRIVATE_KEY_FILE "./client_encrypted.key.pem"
#endif
#define CLIENT_ENCRYPTED_PRIVATE_KEY_PASS "test012!"
#define SERVER_ENCRYPTED_CERT_FILE "./cert_encrypted.pem"
#define SERVER_ENCRYPTED_PRIVATE_KEY_FILE "./key_encrypted.pem"
#define SERVER_ENCRYPTED_PRIVATE_KEY_PASS "test123!"
#ifdef CPPHTTPLIB_SSL_ENABLED
namespace httplib {
namespace tls {
// Declared here for the split build, where the TLS abstraction declarations
// live below the split border; the definition is linked from httplib.cc.
ca_store_t create_ca_store(const char *pem, size_t len);
} // namespace tls
} // namespace httplib
#endif
using namespace std;
using namespace httplib;
const char *HOST = "localhost";
static int get_base_port() {
const char *shard = getenv("GTEST_SHARD_INDEX");
return shard ? 11234 + std::atoi(shard) * 100 : 1234;
}
// NOTE: PORT is only for legacy fixtures (ServerTest, etc.).
// New standalone tests MUST use svr.bind_to_any_port() instead.
const int PORT = get_base_port();
const string LONG_QUERY_VALUE = string(25000, '@');
const string LONG_QUERY_URL = "/long-query-value?key=" + LONG_QUERY_VALUE;
const string TOO_LONG_QUERY_VALUE = string(35000, '@');
const string TOO_LONG_QUERY_URL =
"/too-long-query-value?key=" + TOO_LONG_QUERY_VALUE;
const std::string JSON_DATA = "{\"hello\":\"world\"}";
const string LARGE_DATA = string(1024 * 1024 * 100, '@'); // 100MB
FormData &get_file_value(std::vector<FormData> &items, const char *key) {
auto it = std::find_if(items.begin(), items.end(), [&](const FormData &file) {
return file.name == key;
});
#ifdef CPPHTTPLIB_NO_EXCEPTIONS
return *it;
#else
if (it != items.end()) { return *it; }
throw std::runtime_error("invalid multipart form data name error");
#endif
}
static void read_file(const std::string &path, std::string &out) {
std::ifstream fs(path, std::ios_base::binary);
if (!fs) {
#ifdef CPPHTTPLIB_NO_EXCEPTIONS
return;
#else
throw std::runtime_error("File not found: " + path);
#endif
}
fs.seekg(0, std::ios_base::end);
auto size = fs.tellg();
fs.seekg(0);
out.resize(static_cast<size_t>(size));
fs.read(&out[0], static_cast<std::streamsize>(size));
}
class UnixSocketTest : public ::testing::Test {
protected:
void TearDown() override { std::remove(pathname_.c_str()); }
void client_GET(const std::string &addr) {
httplib::Client cli{addr};
cli.set_address_family(AF_UNIX);
ASSERT_TRUE(cli.is_valid());
const auto &result = cli.Get(pattern_);
ASSERT_TRUE(result) << "error: " << result.error();
const auto &resp = result.value();
EXPECT_EQ(resp.status, StatusCode::OK_200);
EXPECT_EQ(resp.body, content_);
}
static std::string make_sock_path() {
const char *shard = getenv("GTEST_SHARD_INDEX");
return shard ? std::string("./httplib-server-") + shard + ".sock"
: "./httplib-server.sock";
}
const std::string pathname_{make_sock_path()};
const std::string pattern_{"/hi"};
const std::string content_{"Hello World!"};
};
TEST_F(UnixSocketTest, pathname) {
httplib::Server svr;
svr.Get(pattern_, [&](const httplib::Request &, httplib::Response &res) {
res.set_content(content_, "text/plain");
});
std::thread t{[&] {
ASSERT_TRUE(svr.set_address_family(AF_UNIX).listen(pathname_, 80));
}};
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
ASSERT_TRUE(svr.is_running());
client_GET(pathname_);
}
#if defined(__linux__) || \
/* __APPLE__ */ (defined(SOL_LOCAL) && defined(SO_PEERPID))
TEST_F(UnixSocketTest, PeerPid) {
httplib::Server svr;
std::string remote_port_val;
svr.Get(pattern_, [&](const httplib::Request &req, httplib::Response &res) {
res.set_content(content_, "text/plain");
remote_port_val = std::to_string(req.remote_port);
});
std::thread t{[&] {
ASSERT_TRUE(svr.set_address_family(AF_UNIX).listen(pathname_, 80));
}};
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
ASSERT_TRUE(svr.is_running());
client_GET(pathname_);
EXPECT_EQ(std::to_string(getpid()), remote_port_val);
}
#endif
#ifdef __linux__
TEST_F(UnixSocketTest, abstract) {
constexpr char svr_path[]{"\x00httplib-server.sock"};
const std::string abstract_addr{svr_path, sizeof(svr_path) - 1};
httplib::Server svr;
svr.Get(pattern_, [&](const httplib::Request &, httplib::Response &res) {
res.set_content(content_, "text/plain");
});
std::thread t{[&] {
ASSERT_TRUE(svr.set_address_family(AF_UNIX).listen(abstract_addr, 80));
}};
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
ASSERT_TRUE(svr.is_running());
client_GET(abstract_addr);
}
#endif
TEST_F(UnixSocketTest, HostHeaderAutoSet) {
httplib::Server svr;
std::string received_host_header;
svr.Get(pattern_, [&](const httplib::Request &req, httplib::Response &res) {
// Capture the Host header sent by the client
auto host_iter = req.headers.find("Host");
if (host_iter != req.headers.end()) {
received_host_header = host_iter->second;
}
res.set_content(content_, "text/plain");
});
std::thread t{[&] {
ASSERT_TRUE(svr.set_address_family(AF_UNIX).listen(pathname_, 80));
}};
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
ASSERT_TRUE(svr.is_running());
// Test that Host header is automatically set to "localhost" for Unix socket
// connections
httplib::Client cli{pathname_};
cli.set_address_family(AF_UNIX);
ASSERT_TRUE(cli.is_valid());
const auto &result = cli.Get(pattern_);
ASSERT_TRUE(result) << "error: " << result.error();
const auto &resp = result.value();
EXPECT_EQ(resp.status, StatusCode::OK_200);
EXPECT_EQ(resp.body, content_);
// Verify that Host header was automatically set to "localhost"
EXPECT_EQ(received_host_header, "localhost");
}
#ifndef _WIN32
TEST(SocketStream, wait_writable_UNIX) {
int fds[2];
ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds));
const auto asSocketStream = [&](socket_t fd,
std::function<bool(Stream &)> func) {
return detail::process_client_socket(
fd, 0, 0, 0, 0, 0, std::chrono::steady_clock::time_point::min(), func);
};
asSocketStream(fds[0], [&](Stream &s0) {
EXPECT_EQ(s0.socket(), fds[0]);
EXPECT_TRUE(s0.wait_writable());
EXPECT_TRUE(s0.is_peer_alive());
EXPECT_EQ(0, close(fds[1]));
EXPECT_FALSE(s0.is_peer_alive());
return true;
});
EXPECT_EQ(0, close(fds[0]));
}
TEST(SocketStream, wait_writable_INET) {
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 1));
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int disconnected_svr_sock = -1;
std::thread svr{[&] {
const int s = socket(AF_INET, SOCK_STREAM, 0);
ASSERT_LE(0, s);
// PORT + 1 is shared with the SSL redirect tests and with
// VulnerabilityTest.CRLFInjectionInHeaders, all of which set this. Without
// it, a TIME_WAIT one of them left behind makes bind() fail here, and
// because that happens on a worker thread the ASSERT does not stop the
// test: it runs on and fails at the disconnected_svr_sock check instead.
default_socket_options(s);
ASSERT_EQ(0, ::bind(s, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, listen(s, 1));
ASSERT_LE(0, disconnected_svr_sock = accept(s, nullptr, nullptr));
ASSERT_EQ(0, close(s));
}};
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::thread cli{[&] {
const int s = socket(AF_INET, SOCK_STREAM, 0);
ASSERT_LE(0, s);
ASSERT_EQ(0, connect(s, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, close(s));
}};
cli.join();
svr.join();
ASSERT_NE(disconnected_svr_sock, -1);
const auto asSocketStream = [&](socket_t fd,
std::function<bool(Stream &)> func) {
return detail::process_client_socket(
fd, 0, 0, 0, 0, 0, std::chrono::steady_clock::time_point::min(), func);
};
asSocketStream(disconnected_svr_sock, [&](Stream &ss) {
EXPECT_EQ(ss.socket(), disconnected_svr_sock);
// wait_writable() returns true because select_write() only checks if the
// send buffer has space. Peer disconnection is detected later by send().
EXPECT_TRUE(ss.wait_writable());
return true;
});
ASSERT_EQ(0, close(disconnected_svr_sock));
}
#endif // #ifndef _WIN32
TEST(SetSocketOptTest, TcpNoDelay) {
auto sock = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_NE(sock, INVALID_SOCKET);
EXPECT_TRUE(set_socket_opt(sock, IPPROTO_TCP, TCP_NODELAY, 1));
int val = 0;
socklen_t len = sizeof(val);
ASSERT_EQ(0, ::getsockopt(sock, IPPROTO_TCP, TCP_NODELAY,
reinterpret_cast<char *>(&val), &len));
EXPECT_NE(val, 0);
detail::close_socket(sock);
}
TEST(AcceptErrorTest, RetryableFailuresAreClassifiedPerPlatform) {
// accept() reports failures through WSAGetLastError() on Windows and through
// errno everywhere else. Asking the wrong one is what made the accept loop
// treat every recoverable failure as fatal on Windows.
struct Classification {
bool resource;
bool transient;
};
auto classify = [](int err) {
#ifdef _WIN32
WSASetLastError(err);
#else
errno = err;
#endif
// Read both before asserting: an assertion may clobber the error slot.
Classification c;
c.resource = detail::is_accept_resource_error();
c.transient = detail::is_accept_transient_error();
return c;
};
#ifdef _WIN32
const std::vector<int> resource_errors = {WSAEMFILE, WSAENOBUFS};
const std::vector<int> transient_errors = {WSAEINTR, WSAEWOULDBLOCK,
WSAECONNRESET, WSAECONNABORTED};
const std::vector<int> fatal_errors = {WSAENOTSOCK, WSAEINVAL, WSAEOPNOTSUPP};
#else
const std::vector<int> resource_errors = {EMFILE, ENFILE, ENOBUFS, ENOMEM};
const std::vector<int> transient_errors = {EINTR, EAGAIN, EWOULDBLOCK,
ECONNABORTED};
const std::vector<int> fatal_errors = {EBADF, EINVAL, ENOTSOCK};
#endif
for (auto err : resource_errors) {
const auto c = classify(err);
EXPECT_TRUE(c.resource) << "error " << err;
EXPECT_FALSE(c.transient) << "error " << err;
}
for (auto err : transient_errors) {
const auto c = classify(err);
EXPECT_TRUE(c.transient) << "error " << err;
EXPECT_FALSE(c.resource) << "error " << err;
}
for (auto err : fatal_errors) {
const auto c = classify(err);
EXPECT_FALSE(c.resource) << "error " << err;
EXPECT_FALSE(c.transient) << "error " << err;
}
#ifdef _WIN32
WSASetLastError(0);
#else
errno = 0;
#endif
}
TEST(ClientTest, MoveConstructible) {
EXPECT_FALSE(std::is_copy_constructible<Client>::value);
EXPECT_TRUE(std::is_nothrow_move_constructible<Client>::value);
}
TEST(ClientTest, MoveAssignable) {
EXPECT_FALSE(std::is_copy_assignable<Client>::value);
EXPECT_TRUE(std::is_nothrow_move_assignable<Client>::value);
}
#ifdef _WIN32
TEST(StartupTest, WSAStartup) {
WSADATA wsaData;
int ret = WSAStartup(0x0002, &wsaData);
ASSERT_EQ(0, ret);
}
#endif
TEST(DecodePathTest, PercentCharacter) {
EXPECT_EQ(
decode_path_component(
R"(descrip=Gastos%20%C3%A1%C3%A9%C3%AD%C3%B3%C3%BA%C3%B1%C3%91%206)"),
U8("descrip=Gastos áéíóúñÑ 6"));
}
TEST(DecodePathTest, PercentCharacterNUL) {
string expected;
expected.push_back('x');
expected.push_back('\0');
expected.push_back('x');
EXPECT_EQ(decode_path_component("x%00x"), expected);
}
TEST(DecodePathTest, UnicodeEncoding) {
// %u0041 = 'A' (1-byte UTF-8)
EXPECT_EQ("A", decode_path_component("%u0041"));
// %u00E9 = 'é' (2-byte UTF-8)
EXPECT_EQ(U8("é"), decode_path_component("%u00E9"));
// %u3042 = 'あ' (3-byte UTF-8)
EXPECT_EQ(U8("あ"), decode_path_component("%u3042"));
// %uFFFF = max 4-digit hex (3-byte UTF-8, must not overflow buff[4])
EXPECT_FALSE(decode_path_component("%uFFFF").empty());
// %uD800 = surrogate (invalid, silently dropped)
EXPECT_EQ("", decode_path_component("%uD800"));
}
TEST(DecodeQueryTest, RejectsNonHexEscapes) {
// A sign or whitespace inside the two-character escape window must not be
// accepted as a valid percent-encoding; the sequence is passed through
// literally, matching decode_uri_component / decode_path_component.
EXPECT_EQ("%-1", decode_query_component("%-1", false));
EXPECT_EQ("%-0", decode_query_component("%-0", false));
EXPECT_EQ("%+5", decode_query_component("%+5", false));
EXPECT_EQ("% 5", decode_query_component("% 5", false));
// Well-formed escapes still decode.
EXPECT_EQ("-", decode_query_component("%2D", false));
EXPECT_EQ("A", decode_query_component("%41", false));
}
TEST(SanitizeFilenameTest, VariousPatterns) {
// Path traversal
EXPECT_EQ("passwd", httplib::sanitize_filename("../../../etc/passwd"));
EXPECT_EQ("passwd", httplib::sanitize_filename("..\\..\\etc\\passwd"));
EXPECT_EQ("file.txt", httplib::sanitize_filename("path/to\\..\\file.txt"));
// Normal and edge cases
EXPECT_EQ("photo.jpg", httplib::sanitize_filename("photo.jpg"));
EXPECT_EQ("filename.txt",
httplib::sanitize_filename("/path/to/filename.txt"));
EXPECT_EQ(".gitignore", httplib::sanitize_filename(".gitignore"));
EXPECT_EQ("", httplib::sanitize_filename(".."));
EXPECT_EQ("", httplib::sanitize_filename(""));
// Null bytes stripped
EXPECT_EQ("safe.txt",
httplib::sanitize_filename(std::string("safe\0.txt", 9)));
// Whitespace-only rejected
EXPECT_EQ("", httplib::sanitize_filename(" "));
}
// Forward declaration (see the base64_encode note below): split builds move the
// definition into httplib.cc, so re-declaring it here lets the test link.
namespace httplib {
namespace detail {
bool is_chunked_transfer_encoding(const Headers &headers);
} // namespace detail
} // namespace httplib
TEST(ChunkedTransferEncodingTest, DetectsChunkedAsFinalCoding) {
// Build a Headers with the given Transfer-Encoding lines (nullptr = none).
auto make = [](std::initializer_list<const char *> lines) {
Headers h;
for (auto te : lines) {
if (te) { h.emplace("Transfer-Encoding", te); }
}
return h;
};
// Sole coding, matched case-insensitively.
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"Chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"CHUNKED"})));
// RFC 9112 6.1: chunked as the final coding of a list still frames the body.
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip, chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip,chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip, Chunked "})));
// Single line: chunked absent, or not the final coding -> not chunk-framed.
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"gzip"})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"chunked, gzip"})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({""})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({nullptr})));
// RFC 9110 5.3: multiple Transfer-Encoding lines combine, in the order they
// were received, into one list. Headers preserves that order, so the answer
// is decided by the last coding of the last line and the order the lines
// arrived in is significant.
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip", "chunked"})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"chunked", "gzip"})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"gzip", "deflate"})));
// The split can fall anywhere in the list.
EXPECT_TRUE(
detail::is_chunked_transfer_encoding(make({"deflate", "gzip, chunked"})));
EXPECT_FALSE(
detail::is_chunked_transfer_encoding(make({"gzip, chunked", "deflate"})));
// A trailing line naming no coding leaves the list ending in nothing, so it
// must not inherit the chunked from the line before it.
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"chunked", ""})));
}
// Forward declaration: in split builds split.py strips `inline` and moves the
// definition into httplib.cc, so detail::base64_encode is not visible from the
// public httplib.h. Re-declaring it here lets the tests link against the symbol
// in both header-only and split builds.
namespace httplib {
namespace detail {
std::string base64_encode(const std::string &in);
} // namespace detail
} // namespace httplib
TEST(Base64EncodeTest, KnownAnswers) {
// RFC 4648 test vectors. Inputs of four bytes or more exercise the round
// where the accumulator's top bit is already set before the next shift.
EXPECT_EQ("", detail::base64_encode(""));
EXPECT_EQ("Zg==", detail::base64_encode("f"));
EXPECT_EQ("Zm8=", detail::base64_encode("fo"));
EXPECT_EQ("Zm9v", detail::base64_encode("foo"));
EXPECT_EQ("Zm9vYg==", detail::base64_encode("foob"));
EXPECT_EQ("Zm9vYmE=", detail::base64_encode("fooba"));
EXPECT_EQ("Zm9vYmFy", detail::base64_encode("foobar"));
// High bytes keep the top bit set across several rounds.
EXPECT_EQ("AAECA//+wIB/", detail::base64_encode(std::string(
"\x00\x01\x02\x03\xff\xfe\xc0\x80\x7f", 9)));
}
TEST(EncodeQueryParamTest, ParseUnescapedChararactersTest) {
string unescapedCharacters = "-_.!~*'()";
EXPECT_EQ(httplib::encode_uri_component(unescapedCharacters), "-_.!~*'()");
}
TEST(EncodeQueryParamTest, ParseReservedCharactersTest) {
string reservedCharacters = ";,/?:@&=+$";
EXPECT_EQ(httplib::encode_uri_component(reservedCharacters),
"%3B%2C%2F%3F%3A%40%26%3D%2B%24");
}
TEST(ClientQueryOrder, PreserveOrder) {
// This test reproduces Issue #2259: client may reorder query parameters
// when sending a GET request. The expected behavior is that the client
// preserves the original query string order when the caller supplied it
// as part of the path.
Server svr;
svr.Get("/", [&](const Request &req, Response &res) {
// Echo back the raw target so the test can assert ordering
res.set_content(req.target, "text/plain");
});
std::thread t{[&] { svr.listen(HOST, PORT); }};
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
ASSERT_TRUE(cli.is_valid());
const std::string original = "/?z=1&y=2&x=3&c=7&b=8&a=9";
auto res = cli.Get(original);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
// Expect the echoed target to exactly match the original path (order
// preserved)
EXPECT_EQ(res->body, original);
}
TEST(EncodeQueryParamTest, TestUTF8Characters) {
string chineseCharacters = U8("中国語");
string russianCharacters = U8("дом");
string brazilianCharacters = U8("óculos");
EXPECT_EQ(httplib::encode_uri_component(chineseCharacters),
"%E4%B8%AD%E5%9B%BD%E8%AA%9E");
EXPECT_EQ(httplib::encode_uri_component(russianCharacters),
"%D0%B4%D0%BE%D0%BC");
EXPECT_EQ(httplib::encode_uri_component(brazilianCharacters), "%C3%B3culos");
}
TEST(EncodeUriComponentTest, ParseUnescapedChararactersTest) {
string unescapedCharacters = "-_.!~*'()";
EXPECT_EQ(httplib::encode_uri_component(unescapedCharacters), "-_.!~*'()");
}
TEST(EncodeUriComponentTest, ParseReservedCharactersTest) {
string reservedCharacters = ";,/?:@&=+$";
EXPECT_EQ(httplib::encode_uri_component(reservedCharacters),
"%3B%2C%2F%3F%3A%40%26%3D%2B%24");
}
TEST(EncodeUriComponentTest, TestUTF8Characters) {
string chineseCharacters = U8("中国語");
string russianCharacters = U8("дом");
string brazilianCharacters = U8("óculos");
EXPECT_EQ(httplib::encode_uri_component(chineseCharacters),
"%E4%B8%AD%E5%9B%BD%E8%AA%9E");
EXPECT_EQ(httplib::encode_uri_component(russianCharacters),
"%D0%B4%D0%BE%D0%BC");
EXPECT_EQ(httplib::encode_uri_component(brazilianCharacters), "%C3%B3culos");
}
TEST(EncodeUriComponentTest, TestPathComponentEncoding) {
// Issue #2082 use case: encoding path component with ampersand
string pathWithAmpersand = "Piri Tommy Villiers - on & on";
EXPECT_EQ(httplib::encode_uri_component(pathWithAmpersand),
"Piri%20Tommy%20Villiers%20-%20on%20%26%20on");
}
TEST(EncodeUriTest, ParseUnescapedChararactersTest) {
string unescapedCharacters = "-_.!~*'()";
EXPECT_EQ(httplib::encode_uri(unescapedCharacters), "-_.!~*'()");
}
TEST(EncodeUriTest, ParseReservedCharactersTest) {
string reservedCharacters = ";,/?:@&=+$#";
EXPECT_EQ(httplib::encode_uri(reservedCharacters), ";,/?:@&=+$#");
}
TEST(EncodeUriTest, TestUTF8Characters) {
string chineseCharacters = U8("中国語");
string russianCharacters = U8("дом");
string brazilianCharacters = U8("óculos");
EXPECT_EQ(httplib::encode_uri(chineseCharacters),
"%E4%B8%AD%E5%9B%BD%E8%AA%9E");
EXPECT_EQ(httplib::encode_uri(russianCharacters), "%D0%B4%D0%BE%D0%BC");
EXPECT_EQ(httplib::encode_uri(brazilianCharacters), "%C3%B3culos");
}
TEST(EncodeUriTest, TestCompleteUri) {
string uri =
"https://example.com/path/to/resource?query=value&param=test#fragment";
EXPECT_EQ(
httplib::encode_uri(uri),
"https://example.com/path/to/resource?query=value&param=test#fragment");
}
TEST(EncodeUriTest, TestUriWithSpacesAndSpecialChars) {
string uri =
"https://example.com/path with spaces/file name.html?q=hello world";
EXPECT_EQ(httplib::encode_uri(uri),
"https://example.com/path%20with%20spaces/"
"file%20name.html?q=hello%20world");
}
TEST(DecodeUriComponentTest, ParseEncodedChararactersTest) {
string encodedString = "%3B%2C%2F%3F%3A%40%26%3D%2B%24";
EXPECT_EQ(httplib::decode_uri_component(encodedString), ";,/?:@&=+$");
}
TEST(DecodeUriComponentTest, ParseUnescapedChararactersTest) {
string unescapedCharacters = "-_.!~*'()";
EXPECT_EQ(httplib::decode_uri_component(unescapedCharacters), "-_.!~*'()");
}
TEST(DecodeUriComponentTest, TestUTF8Characters) {
string encodedChinese = "%E4%B8%AD%E5%9B%BD%E8%AA%9E";
string encodedRussian = "%D0%B4%D0%BE%D0%BC";
string encodedBrazilian = "%C3%B3culos";
EXPECT_EQ(httplib::decode_uri_component(encodedChinese), U8("中国語"));
EXPECT_EQ(httplib::decode_uri_component(encodedRussian), U8("дом"));
EXPECT_EQ(httplib::decode_uri_component(encodedBrazilian), U8("óculos"));
}
TEST(DecodeUriComponentTest, TestPathComponentDecoding) {
string encodedPath = "Piri%20Tommy%20Villiers%20-%20on%20%26%20on";
EXPECT_EQ(httplib::decode_uri_component(encodedPath),
"Piri Tommy Villiers - on & on");
}
TEST(DecodeUriTest, ParseEncodedChararactersTest) {
string encodedString = "%20%22%3C%3E%5C%5E%60%7B%7D%7C";
EXPECT_EQ(httplib::decode_uri(encodedString), " \"<>\\^`{}|");
}
TEST(DecodeUriTest, ParseUnescapedChararactersTest) {
string unescapedCharacters = "-_.!~*'();,/?:@&=+$#";
EXPECT_EQ(httplib::decode_uri(unescapedCharacters), "-_.!~*'();,/?:@&=+$#");
}
TEST(DecodeUriTest, TestUTF8Characters) {
string encodedChinese = "%E4%B8%AD%E5%9B%BD%E8%AA%9E";
string encodedRussian = "%D0%B4%D0%BE%D0%BC";
string encodedBrazilian = "%C3%B3culos";
EXPECT_EQ(httplib::decode_uri(encodedChinese), U8("中国語"));
EXPECT_EQ(httplib::decode_uri(encodedRussian), U8("дом"));
EXPECT_EQ(httplib::decode_uri(encodedBrazilian), U8("óculos"));
}
TEST(DecodeUriTest, TestCompleteUri) {
string encodedUri = "https://example.com/path%20with%20spaces/"
"file%20name.html?q=hello%20world";
EXPECT_EQ(
httplib::decode_uri(encodedUri),
"https://example.com/path with spaces/file name.html?q=hello world");
}
TEST(DecodeUriTest, TestRoundTripWithEncodeUri) {
string original =
"https://example.com/path with spaces/file name.html?q=hello world";
string encoded = httplib::encode_uri(original);
string decoded = httplib::decode_uri(encoded);
EXPECT_EQ(decoded, original);
}
TEST(DecodeUriTest, KeepsReservedCharacterEscapes) {
// decode_uri is the inverse of encode_uri: an escaped reserved character
// stays encoded so it is not promoted into a real delimiter, while
// non-reserved escapes still decode (like JS decodeURI).
EXPECT_EQ(httplib::decode_uri("%2F"), "%2F");
EXPECT_EQ(httplib::decode_uri("%23"), "%23");
EXPECT_EQ(httplib::decode_uri("%3F%3A%40%26%3D%2B%24%2C%3B"),
"%3F%3A%40%26%3D%2B%24%2C%3B");
EXPECT_EQ(httplib::decode_uri("%2D"), "-");
EXPECT_EQ(httplib::decode_uri("%20"), " ");
EXPECT_EQ(httplib::decode_uri("http://example.com/a%2Fb"),
"http://example.com/a%2Fb");
// decode_uri_component still decodes the reserved character.
EXPECT_EQ(httplib::decode_uri_component("%2F"), "/");
}
TEST(DecodeUriComponentTest, TestRoundTripWithEncodeUriComponent) {
string original = "Piri Tommy Villiers - on & on";
string encoded = httplib::encode_uri_component(original);
string decoded = httplib::decode_uri_component(encoded);
EXPECT_EQ(decoded, original);
}
TEST(TrimTests, TrimStringTests) {
EXPECT_EQ("abc", detail::trim_copy("abc"));
EXPECT_EQ("abc", detail::trim_copy(" abc "));
EXPECT_TRUE(detail::trim_copy("").empty());
}
TEST(AsciiTest, LocaleIndependentClassification) {
// detail::is_ascii_digit/alpha/alnum replace the <cctype> classifiers,
// which consult the global C locale: std::isalnum(0xC5) can return true
// once an embedder calls setlocale() (observed on macOS). The is_ascii_*
// helpers must classify every byte by the ASCII grammar alone.
for (int i = 0; i < 256; i++) {
auto c = static_cast<char>(i);
auto is_digit = i >= '0' && i <= '9';
auto is_upper = i >= 'A' && i <= 'Z';
auto is_lower = i >= 'a' && i <= 'z';
EXPECT_EQ(is_digit, detail::is_ascii_digit(c)) << "byte " << i;
EXPECT_EQ(is_upper || is_lower, detail::is_ascii_alpha(c)) << "byte " << i;
EXPECT_EQ(is_digit || is_upper || is_lower, detail::is_ascii_alnum(c))
<< "byte " << i;
}
// Regression check for the reported byte: 0xC5 is not alphanumeric.
EXPECT_FALSE(detail::is_ascii_alnum(static_cast<char>(0xC5)));
// Non-ASCII bytes must be percent-encoded, never passed through as
// alphanumeric.
EXPECT_EQ("%C5", httplib::encode_uri_component("\xC5"));
}
TEST(FromCharsTest, Double) {
// detail::from_chars(double) recognizes exactly the HTTP quality-value
// grammar (RFC 9110 12.4.2): a non-negative decimal "1*DIGIT [ '.' *DIGIT ]"
// with no sign, exponent, or "inf"/"nan", parsed locale-independently.
auto parse = [](const std::string &s, double &v) {
return detail::from_chars(s.data(), s.data() + s.size(), v);
};
double v = -1.0;
// Representative quality values.
EXPECT_EQ(parse("0", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.0);
EXPECT_EQ(parse("1", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 1.0);
EXPECT_EQ(parse("0.8", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.8);
EXPECT_EQ(parse("0.001", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.001);
EXPECT_EQ(parse("1.000", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 1.0);
// A missing integer or fractional part is tolerated.
EXPECT_EQ(parse(".5", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.5);
EXPECT_EQ(parse("5.", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 5.0);
// Values outside [0, 1] still parse; the caller range-checks them.
EXPECT_EQ(parse("123.456", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 123.456);
// Stops at the first byte that is not part of the number and reports where.
std::string trailing = "0.9, text/html";
auto r = parse(trailing, v);
EXPECT_EQ(r.ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.9);
EXPECT_EQ(*r.ptr, ',');
// Sign and exponent are NOT part of the grammar: '+'/'-' are rejected
// outright, and an 'e'/'E' simply ends the number.
EXPECT_EQ(parse("-3.25", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("+2.5", v).ec, std::errc::invalid_argument);
std::string exp_input = "1.5e3";
r = parse(exp_input, v);
EXPECT_EQ(r.ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 1.5);
EXPECT_EQ(*r.ptr, 'e');
// Invalid inputs.
EXPECT_EQ(parse("", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse(".", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("abc", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("nan", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("inf", v).ec, std::errc::invalid_argument);
// Pathological but well-formed inputs must stay bounded (no overflow, no
// out-of-bounds table access) and stay within [0, 1] for the caller.
EXPECT_EQ(parse(std::string("0.") + std::string(500, '0'), v).ec,
std::errc{});
EXPECT_DOUBLE_EQ(v, 0.0);
EXPECT_EQ(parse(std::string("0.") + std::string(500, '9'), v).ec,
std::errc{});
EXPECT_GE(v, 0.0);
EXPECT_LE(v, 1.0);
EXPECT_EQ(parse(std::string(500, '9'), v).ec, std::errc{});
EXPECT_GT(v, 1.0); // huge integer: finite, > 1, so the caller rejects it
}
TEST(ParseAcceptHeaderTest, BasicAcceptParsing) {
// Simple case without quality values
std::vector<std::string> result1;
EXPECT_TRUE(detail::parse_accept_header(
"text/html,application/json,text/plain", result1));
EXPECT_EQ(result1.size(), 3U);
EXPECT_EQ(result1[0], "text/html");
EXPECT_EQ(result1[1], "application/json");
EXPECT_EQ(result1[2], "text/plain");
// With quality values
std::vector<std::string> result2;
EXPECT_TRUE(detail::parse_accept_header(
"text/html;q=0.9,application/json;q=1.0,text/plain;q=0.8", result2));
EXPECT_EQ(result2.size(), 3U);
EXPECT_EQ(result2[0], "application/json"); // highest q value
EXPECT_EQ(result2[1], "text/html");
EXPECT_EQ(result2[2], "text/plain"); // lowest q value
}
TEST(ParseAcceptHeaderTest, MixedQualityValues) {
// Mixed with and without quality values
std::vector<std::string> result;
EXPECT_TRUE(detail::parse_accept_header(
"text/html,application/json;q=0.5,text/plain;q=0.8", result));
EXPECT_EQ(result.size(), 3U);
EXPECT_EQ(result[0], "text/html"); // no q value means 1.0
EXPECT_EQ(result[1], "text/plain"); // q=0.8
EXPECT_EQ(result[2], "application/json"); // q=0.5
}
TEST(ParseAcceptHeaderTest, EdgeCases) {
// Empty header
std::vector<std::string> empty_result;
EXPECT_TRUE(detail::parse_accept_header("", empty_result));
EXPECT_TRUE(empty_result.empty());
// Single type
std::vector<std::string> single_result;
EXPECT_TRUE(detail::parse_accept_header("application/json", single_result));
EXPECT_EQ(single_result.size(), 1U);
EXPECT_EQ(single_result[0], "application/json");
// Wildcard types
std::vector<std::string> wildcard_result;
EXPECT_TRUE(detail::parse_accept_header(
"text/*;q=0.5,*/*;q=0.1,application/json", wildcard_result));
EXPECT_EQ(wildcard_result.size(), 3U);
EXPECT_EQ(wildcard_result[0], "application/json");
EXPECT_EQ(wildcard_result[1], "text/*");
EXPECT_EQ(wildcard_result[2], "*/*");
}
TEST(ParseAcceptHeaderTest, RealWorldExamples) {
// Common browser Accept header
std::vector<std::string> browser_result;
EXPECT_TRUE(
detail::parse_accept_header("text/html,application/xhtml+xml,application/"
"xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
browser_result));
EXPECT_EQ(browser_result.size(), 6U);
EXPECT_EQ(browser_result[0], "text/html"); // q=1.0 (default)
EXPECT_EQ(browser_result[1], "application/xhtml+xml"); // q=1.0 (default)
EXPECT_EQ(browser_result[2], "image/webp"); // q=1.0 (default)
EXPECT_EQ(browser_result[3], "image/apng"); // q=1.0 (default)
EXPECT_EQ(browser_result[4], "application/xml"); // q=0.9
EXPECT_EQ(browser_result[5], "*/*"); // q=0.8
// API client header
std::vector<std::string> api_result;
EXPECT_TRUE(detail::parse_accept_header(
"application/json;q=0.9,application/xml;q=0.8,text/plain;q=0.1",
api_result));
EXPECT_EQ(api_result.size(), 3U);
EXPECT_EQ(api_result[0], "application/json");
EXPECT_EQ(api_result[1], "application/xml");
EXPECT_EQ(api_result[2], "text/plain");
}
TEST(ParseAcceptHeaderTest, SpecialCases) {
// Quality value with 3 decimal places
std::vector<std::string> decimal_result;
EXPECT_TRUE(detail::parse_accept_header(
"text/html;q=0.123,application/json;q=0.456", decimal_result));
EXPECT_EQ(decimal_result.size(), 2U);
EXPECT_EQ(decimal_result[0], "application/json"); // Higher q value
EXPECT_EQ(decimal_result[1], "text/html");
// Zero quality (should still be included but with lowest priority)
std::vector<std::string> zero_q_result;
EXPECT_TRUE(detail::parse_accept_header("text/html;q=0,application/json;q=1",
zero_q_result));
EXPECT_EQ(zero_q_result.size(), 2U);
EXPECT_EQ(zero_q_result[0], "application/json"); // q=1
EXPECT_EQ(zero_q_result[1], "text/html"); // q=0
// No spaces around commas
std::vector<std::string> no_space_result;
EXPECT_TRUE(detail::parse_accept_header(
"text/html;q=0.9,application/json;q=0.8,text/plain;q=0.7",
no_space_result));
EXPECT_EQ(no_space_result.size(), 3U);
EXPECT_EQ(no_space_result[0], "text/html");
EXPECT_EQ(no_space_result[1], "application/json");
EXPECT_EQ(no_space_result[2], "text/plain");
}
TEST(ParseAcceptHeaderTest, QualityValueLocaleIndependence) {
// Quality values always use '.' as the decimal separator, so parsing must
// not depend on the process locale. An embedding application may have
// switched to a locale that uses ',' via setlocale(LC_ALL, "").
const char *cur = std::setlocale(LC_NUMERIC, nullptr);
std::string saved = cur ? cur : "C";
const char *comma_locales[] = {"de_DE.UTF-8", "de_DE.utf8", "nl_NL.UTF-8",
"fr_FR.UTF-8"};
bool switched = false;
for (const auto loc : comma_locales) {
if (std::setlocale(LC_NUMERIC, loc) != nullptr &&
std::localeconv()->decimal_point[0] == ',') {
switched = true;
break;
}
}
if (!switched) {
std::setlocale(LC_NUMERIC, saved.c_str());
GTEST_SKIP() << "no comma-decimal locale available on this host";
}
// The higher-weighted type appears later in the list, so a correct parse
// must reorder it ahead of the earlier, lower-weighted one. A locale-
// sensitive parse reads both weights as 0 and leaves the original order.
std::vector<std::string> result;
EXPECT_TRUE(detail::parse_accept_header(
"application/json;q=0.1,text/html;q=0.9", result));
ASSERT_EQ(result.size(), 2U);
EXPECT_EQ(result[0], "text/html");
EXPECT_EQ(result[1], "application/json");
std::setlocale(LC_NUMERIC, saved.c_str());
}
TEST(ParseAcceptHeaderTest, InvalidCases) {
std::vector<std::string> result;
// Invalid quality value (> 1.0)
EXPECT_FALSE(
detail::parse_accept_header("text/html;q=1.5,application/json", result));
// Invalid quality value (< 0.0)
EXPECT_FALSE(
detail::parse_accept_header("text/html;q=-0.1,application/json", result));
// Invalid quality value (not a number)
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));
// Invalid media type format (no slash and not wildcard)
EXPECT_FALSE(
detail::parse_accept_header("invalidtype,application/json", result));
// Valid cases should still work
EXPECT_TRUE(detail::parse_accept_header("*/*", result));
EXPECT_EQ(result.size(), 1U);
EXPECT_EQ(result[0], "*/*");
EXPECT_TRUE(detail::parse_accept_header("*", result));
EXPECT_EQ(result.size(), 1U);
EXPECT_EQ(result[0], "*");
EXPECT_TRUE(detail::parse_accept_header("text/*", result));
EXPECT_EQ(result.size(), 1U);
EXPECT_EQ(result[0], "text/*");
}
// RFC 9110 Section 5.6.1.2: a recipient has to parse and ignore empty list
// elements, so a leading, trailing or doubled comma is a legal Accept value
// and must not be answered with 400 Bad Request.
TEST(ParseAcceptHeaderTest, EmptyListElementsAreIgnored) {
struct {
const char *value;
std::vector<std::string> expected;
} cases[] = {
{",application/json", {"application/json"}},
{"text/html,", {"text/html"}},
{"text/html,,*/*", {"text/html", "*/*"}},
// An empty element may be spelled with whitespace in it
{"text/html, , application/json", {"text/html", "application/json"}},
// Nothing but empty elements is an empty list, which means the same
// thing as no Accept header at all
{",,,", {}},
// Quality values still apply across ignored empty elements
{",text/html;q=0.5,,application/json", {"application/json", "text/html"}},
};
for (const auto &c : cases) {
std::vector<std::string> result;
EXPECT_TRUE(detail::parse_accept_header(c.value, result))
<< "value: " << c.value;
EXPECT_EQ(c.expected, result) << "value: " << c.value;
}
// An invalid entry is still rejected when empty elements surround it
std::vector<std::string> result;
EXPECT_FALSE(detail::parse_accept_header(",invalidtype,", result));
}
TEST(ParseAcceptHeaderTest, ContentTypesPopulatedAndInvalidHeaderHandling) {
Server svr;
svr.Get("/accept_ok", [&](const Request &req, Response &res) {
EXPECT_EQ(req.accept_content_types.size(), 3U);
EXPECT_EQ(req.accept_content_types[0], "application/json");
EXPECT_EQ(req.accept_content_types[1], "text/html");
EXPECT_EQ(req.accept_content_types[2], "*/*");
res.set_content("ok", "text/plain");
});
svr.Get("/accept_bad_request", [&](const Request & /*req*/, Response &res) {
EXPECT_TRUE(false);
res.set_content("bad request", "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
{
auto res = cli.Get(
"/accept_ok",
Headers{{"Accept", "application/json, text/html;q=0.8, */*;q=0.1"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
auto res = cli.Get("/accept_bad_request",
Headers{{"Accept", "text/html;q=abc,application/json"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
}
}
TEST(ServerStartHandlerTest, CalledOnceWhenReady) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
std::atomic<int> start_count{0};
std::atomic<bool> running_when_called{false};
svr.set_start_handler([&]() {
running_when_called = svr.is_running();
start_count++;
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// A successful request proves the accept loop is running, which the start
// handler precedes; so by now the handler must have run exactly once.
Client cli(HOST, port);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1, start_count.load());
EXPECT_TRUE(running_when_called.load());
}
TEST(DivideTest, DivideStringTests) {
auto divide = [](const std::string &str, char d) {
std::string lhs;
std::string rhs;
detail::divide(str, d,
[&](const char *lhs_data, std::size_t lhs_size,
const char *rhs_data, std::size_t rhs_size) {
lhs.assign(lhs_data, lhs_size);
rhs.assign(rhs_data, rhs_size);
});
return std::make_pair(std::move(lhs), std::move(rhs));
};
{
const auto res = divide("", '=');
EXPECT_EQ(res.first, "");
EXPECT_EQ(res.second, "");
}
{
const auto res = divide("=", '=');
EXPECT_EQ(res.first, "");
EXPECT_EQ(res.second, "");
}
{
const auto res = divide(" ", '=');
EXPECT_EQ(res.first, " ");
EXPECT_EQ(res.second, "");
}
{
const auto res = divide("a", '=');
EXPECT_EQ(res.first, "a");
EXPECT_EQ(res.second, "");
}
{
const auto res = divide("a=", '=');
EXPECT_EQ(res.first, "a");
EXPECT_EQ(res.second, "");
}
{
const auto res = divide("=b", '=');
EXPECT_EQ(res.first, "");
EXPECT_EQ(res.second, "b");
}
{
const auto res = divide("a=b", '=');
EXPECT_EQ(res.first, "a");
EXPECT_EQ(res.second, "b");
}
{
const auto res = divide("a=b=", '=');
EXPECT_EQ(res.first, "a");
EXPECT_EQ(res.second, "b=");
}
{
const auto res = divide("a=b=c", '=');
EXPECT_EQ(res.first, "a");
EXPECT_EQ(res.second, "b=c");
}
}
TEST(SplitTest, ParseQueryString) {
string s = "key1=val1&key2=val2&key3=val3";
Params dic;
detail::split(s.c_str(), s.c_str() + s.size(), '&',
[&](const char *b, const char *e) {
string key, val;
detail::split(b, e, '=', [&](const char *b2, const char *e2) {
if (key.empty()) {
key.assign(b2, e2);
} else {
val.assign(b2, e2);
}
});
dic.emplace(key, val);
});
EXPECT_EQ("val1", dic.find("key1")->second);
EXPECT_EQ("val2", dic.find("key2")->second);
EXPECT_EQ("val3", dic.find("key3")->second);
}
TEST(SplitTest, ParseInvalidQueryTests) {
{
string s = " ";
Params dict;
detail::parse_query_text(s, dict);
EXPECT_TRUE(dict.empty());
}
{
string s = " = =";
Params dict;
detail::parse_query_text(s, dict);
EXPECT_TRUE(dict.empty());
}
}
TEST(ParseQueryTest, ParseQueryString) {
{
std::string s = "key1=val1&key2=val2&key3=val3";
Params dic;
detail::parse_query_text(s, dic);
EXPECT_EQ("val1", dic.find("key1")->second);
EXPECT_EQ("val2", dic.find("key2")->second);
EXPECT_EQ("val3", dic.find("key3")->second);
}
{
std::string s = "key1&key2=val1&key3=val1=val2&key4=val1=val2=val3";
Params dic;
detail::parse_query_text(s, dic);
EXPECT_EQ("", dic.find("key1")->second);
EXPECT_EQ("val1", dic.find("key2")->second);
EXPECT_EQ("val1=val2", dic.find("key3")->second);
EXPECT_EQ("val1=val2=val3", dic.find("key4")->second);
}
}
TEST(ParamsToQueryTest, ConvertParamsToQuery) {
Params dic;
EXPECT_EQ(detail::params_to_query_str(dic), "");
dic.emplace("key1", "val1");
EXPECT_EQ(detail::params_to_query_str(dic), "key1=val1");
dic.emplace("key2", "val2");
dic.emplace("key3", "val3");
EXPECT_EQ(detail::params_to_query_str(dic), "key1=val1&key2=val2&key3=val3");
}
// Joins every entry of a Headers or Params into "key=value " so a whole
// traversal can be compared in one assertion. Both are instantiations of the
// same insertion-ordered container, so one helper serves both.
template <typename Map> static std::string entries_to_string(const Map &m) {
std::string s;
for (const auto &entry : m) {
s += entry.first + "=" + entry.second + " ";
}
return s;
}
TEST(ParamsOrderTest, QueryIsBuiltInInsertionOrderNotSorted) {
// Params used to be a std::multimap, which sorted by name and handed the
// caller's query back alphabetised. A client signing its query string could
// not reproduce the order it asked for.
Params dic;
dic.emplace("zulu", "1");
dic.emplace("alpha", "2");
dic.emplace("mike", "3");
EXPECT_EQ("zulu=1&alpha=2&mike=3", detail::params_to_query_str(dic));
EXPECT_EQ("/p?zulu=1&alpha=2&mike=3", append_query_params("/p", dic));
}
TEST(ParamsOrderTest, ParsingKeepsTheOrderReceived) {
Params dic;
detail::parse_query_text("zulu=1&alpha=2&mike=3", dic);
EXPECT_EQ("zulu=1 alpha=2 mike=3 ", entries_to_string(dic));
}
TEST(ParamsOrderTest, RepeatedNamesKeepTheirOrder) {
Request req;
detail::parse_query_text("tag=first&other=x&tag=second&tag=third",
req.params);
EXPECT_EQ(3U, req.get_param_value_count("tag"));
EXPECT_EQ("first", req.get_param_value("tag"));
EXPECT_EQ("first", req.get_param_value("tag", 0));
EXPECT_EQ("second", req.get_param_value("tag", 1));
EXPECT_EQ("third", req.get_param_value("tag", 2));
EXPECT_EQ("", req.get_param_value("tag", 3));
}
TEST(ParamsOrderTest, LookupStaysCaseSensitive) {
// Params shares its container with Headers, which matches field names
// case-insensitively. Parameter names must not pick that up.
Params dic;
dic.emplace("Key", "upper");
dic.emplace("key", "lower");
EXPECT_EQ(2U, dic.size());
EXPECT_EQ(1U, dic.count("Key"));
EXPECT_EQ(1U, dic.count("key"));
EXPECT_EQ("upper", dic.find("Key")->second);
EXPECT_EQ("lower", dic.find("key")->second);
EXPECT_TRUE(dic.find("KEY") == dic.end());
}
TEST(ParamsOrderTest, ServerSeesTheOrderTheClientSent) {
Server svr;
std::string order;
svr.Get("/order", [&](const Request &req, Response &res) {
order = entries_to_string(req.params);
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Get("/order?zulu=1&alpha=2&tag=x&mike=3&tag=y");
ASSERT_TRUE(res);
EXPECT_EQ("zulu=1 alpha=2 tag=x mike=3 tag=y ", order);
}
TEST(MultipartOrderTest, PartsKeepTheOrderSent) {
Server svr;
std::string field_order, file_order;
svr.Post("/order", [&](const Request &req, Response &res) {
for (const auto &field : req.form.fields) {
field_order += field.first + "=" + field.second.content + " ";
}
for (const auto &file : req.form.files) {
file_order += file.first + "=" + file.second.filename + " ";
}
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
UploadFormDataItems items = {
{"zulu", "1", "", ""},
{"alpha", "2", "", ""},
{"mike", "3", "", ""},
{"zebra", "z", "z.txt", "text/plain"},
{"apple", "a", "a.txt", "text/plain"},
};
Client cli(HOST, port);
auto res = cli.Post("/order", items);
ASSERT_TRUE(res);
EXPECT_EQ("zulu=1 alpha=2 mike=3 ", field_order);
EXPECT_EQ("zebra=z.txt apple=a.txt ", file_order);
}
TEST(MultipartOrderTest, RepeatedNamesKeepTheirOrder) {
Server svr;
std::vector<std::string> values;
std::string first, second, out_of_range, order;
svr.Post("/repeated", [&](const Request &req, Response &res) {
values = req.form.get_fields("tag");
first = req.form.get_field("tag", 0);
second = req.form.get_field("tag", 1);
out_of_range = req.form.get_field("tag", 2);
for (const auto &field : req.form.fields) {
order += field.first + "=" + field.second.content + " ";
}
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// "other" sits between the two "tag" parts, so the entries sharing a name are
// not adjacent in the container.
UploadFormDataItems items = {
{"tag", "first", "", ""},
{"other", "x", "", ""},
{"tag", "second", "", ""},
};
Client cli(HOST, port);
auto res = cli.Post("/repeated", items);
ASSERT_TRUE(res);
ASSERT_EQ(2U, values.size());
EXPECT_EQ("first", values[0]);
EXPECT_EQ("second", values[1]);
EXPECT_EQ("first", first);
EXPECT_EQ("second", second);
// std::multimap already kept entries sharing a name in insertion order, so
// everything above passes without this change too. The whole traversal is
// what tells the two apart: sorting by name would hoist "other" to the front
// and the two "tag" parts would end up adjacent.
EXPECT_EQ("tag=first other=x tag=second ", order);
// Advancing past the last entry of a name used to run off the container;
// the container's saturating increment makes it return the default instead.
EXPECT_EQ("", out_of_range);
}
TEST(MultipartOrderTest, ContentSurvivesContainerGrowth) {
// Server::read_content() keeps a FormFields::iterator alive across the
// content callbacks that fill the part it points at. The container is
// vector-backed, so a later emplace can reallocate and invalidate an older
// iterator; 64 parts grow the vector through seven reallocations, which
// checks that every part's content still lands in its own entry.
const size_t part_count = 64;
// One formula for both the parts sent and the values expected back, so the
// two cannot drift apart.
auto name_of = [](size_t i) { return "f" + std::to_string(i); };
auto content_of = [](size_t i) {
return std::string(64, static_cast<char>('a' + (i % 26)));
};
Server svr;
std::vector<std::pair<std::string, std::string>> received;
svr.Post("/many", [&](const Request &req, Response &res) {
for (const auto &field : req.form.fields) {
received.emplace_back(field.first, field.second.content);
}
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
UploadFormDataItems items;
for (size_t i = 0; i < part_count; i++) {
items.push_back({name_of(i), content_of(i), "", ""});
}
Client cli(HOST, port);
auto res = cli.Post("/many", items);
ASSERT_TRUE(res);
ASSERT_EQ(part_count, received.size());
for (size_t i = 0; i < part_count; i++) {
EXPECT_EQ(name_of(i), received[i].first) << "part " << i;
EXPECT_EQ(content_of(i), received[i].second) << "part " << i;
}
}
TEST(ParseMultipartBoundaryTest, DefaultValue) {
string content_type = "multipart/form-data; boundary=something";
string boundary;
auto ret = detail::parse_multipart_boundary(content_type, boundary);
EXPECT_TRUE(ret);
EXPECT_EQ(boundary, "something");
}
TEST(ParseMultipartBoundaryTest, ValueWithQuote) {
string content_type = "multipart/form-data; boundary=\"gc0pJq0M:08jU534c0p\"";
string boundary;
auto ret = detail::parse_multipart_boundary(content_type, boundary);
EXPECT_TRUE(ret);
EXPECT_EQ(boundary, "gc0pJq0M:08jU534c0p");
}
TEST(ParseMultipartBoundaryTest, ValueWithCharset) {
string content_type =
"multipart/mixed; boundary=THIS_STRING_SEPARATES;charset=UTF-8";
string boundary;
auto ret = detail::parse_multipart_boundary(content_type, boundary);
EXPECT_TRUE(ret);
EXPECT_EQ(boundary, "THIS_STRING_SEPARATES");
}
TEST(ParseMultipartBoundaryTest, ValueWithQuotesAndCharset) {
string content_type =
"multipart/mixed; boundary=\"cpp-httplib-multipart-data\"; charset=UTF-8";
string boundary;
auto ret = detail::parse_multipart_boundary(content_type, boundary);
EXPECT_TRUE(ret);
EXPECT_EQ(boundary, "cpp-httplib-multipart-data");
}
TEST(ParseMultipartBoundaryTest, LongestAllowedValue) {
const string boundary(70, 'a');
string content_type = "multipart/form-data; boundary=" + boundary;
string parsed;
auto ret = detail::parse_multipart_boundary(content_type, parsed);
EXPECT_TRUE(ret);
EXPECT_EQ(parsed, boundary);
}
TEST(ParseMultipartBoundaryTest, ValueLongerThanRFCLimit) {
// RFC 2046 5.1.1 caps a boundary at 70 characters.
string content_type = "multipart/form-data; boundary=" + string(71, 'a');
string parsed;
auto ret = detail::parse_multipart_boundary(content_type, parsed);
EXPECT_FALSE(ret);
}
TEST(ParseMultipartBoundaryTest, QuotedValueIsMeasuredAfterUnquoting) {
// The limit applies to the unquoted value, so a quoted 70 character boundary
// is 72 characters on the wire and still valid.
const string boundary(70, 'a');
string content_type = "multipart/form-data; boundary=\"" + boundary + "\"";
string parsed;
EXPECT_TRUE(detail::parse_multipart_boundary(content_type, parsed));
EXPECT_EQ(parsed, boundary);
content_type = "multipart/form-data; boundary=\"" + string(71, 'a') + "\"";
parsed.clear();
EXPECT_FALSE(detail::parse_multipart_boundary(content_type, parsed));
}
TEST(ParseMultipartBoundaryTest, QuotedValueWithEqualsSign) {
// RFC 2046 5.1.1 allows '=' in a boundary, so a MIME sender has to quote it.
// The parameter parser must not treat that '=' as the key/value separator.
string content_type =
"multipart/mixed; boundary=\"----=_NextPart_000_0000_01D9\"; "
"charset=UTF-8";
string parsed;
EXPECT_TRUE(detail::parse_multipart_boundary(content_type, parsed));
EXPECT_EQ(parsed, "----=_NextPart_000_0000_01D9");
}
TEST(ParseMultipartBoundaryTest, QuotedValueWithSemicolon) {
// A ';' inside the quoted-string is part of the value, not a parameter
// separator, so it must not truncate the boundary.
string content_type = "multipart/mixed; boundary=\"a;b\"; charset=UTF-8";
string parsed;
EXPECT_TRUE(detail::parse_multipart_boundary(content_type, parsed));
EXPECT_EQ(parsed, "a;b");
}
TEST(ParseDispositionParamsTest, QuotedValues) {
// RFC 9110 Section 5.6.6: a parameter value may be a quoted-string, and
// ';' and '=' are ordinary characters inside one.
struct {
const char *value;
std::vector<std::pair<const char *, const char *>> expected;
} cases[] = {
{"name=\"file1\"; filename=\"a.txt\"",
{{"name", "file1"}, {"filename", "a.txt"}}},
// Whitespace around '=' and ';' is still trimmed
{"name=\"file2\" ;filename = \"a.html\"",
{{"name", "file2"}, {"filename", "a.html"}}},
// '=' inside the value used to leave only the text after the last one
{"name=\"file1\"; filename=\"report=v2.pdf\"",
{{"name", "file1"}, {"filename", "report=v2.pdf"}}},
{"name=\"x=y\"", {{"name", "x=y"}}},
// ';' inside the value used to truncate the pair and leave a bogus one
{"name=\"file3\"; filename=\"a;b.txt\"",
{{"name", "file3"}, {"filename", "a;b.txt"}}},
// An unquoted value keeps working, and so does filename*
{"filename*=UTF-8''%41.txt; filename=\"a.txt\"",
{{"filename*", "UTF-8''%41.txt"}, {"filename", "a.txt"}}},
// A parameter with no key is dropped rather than turned into one whose
// key is the value
{"=nokey; name=\"file5\"", {{"name", "file5"}}},
};
for (const auto &c : cases) {
Params params;
detail::parse_disposition_params(c.value, params);
for (const auto &kv : c.expected) {
auto it = params.find(kv.first);
ASSERT_NE(it, params.end())
<< "value: " << c.value << ", key: " << kv.first;
EXPECT_EQ(kv.second, it->second) << "value: " << c.value;
}
EXPECT_EQ(c.expected.size(), params.size()) << "value: " << c.value;
}
}
TEST(ParseDispositionParamsTest, QuotedValuesSurviveTheRoundTrip) {
// escape_multipart_field() only escapes '"', CR and LF, so a name or
// filename holding '=' or ';' reaches the server's parameter parser as is.
Server svr;
std::string field_value, file_name, file_filename;
bool has_field = false, has_file = false;
svr.Post("/quoted", [&](const Request &req, Response &res) {
has_field = req.form.has_field("x=y");
field_value = req.form.get_field("x=y");
has_file = req.form.has_file("file1");
const auto &file = req.form.get_file("file1");
file_name = file.name;
file_filename = file.filename;
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
UploadFormDataItems items = {
{"x=y", "field-value", "", ""},
{"file1", "pdf-bytes", "a;b=report.pdf", "application/pdf"},
};
Client cli(HOST, port);
auto res = cli.Post("/quoted", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
EXPECT_TRUE(has_field);
EXPECT_EQ("field-value", field_value);
EXPECT_TRUE(has_file);
EXPECT_EQ("file1", file_name);
EXPECT_EQ("a;b=report.pdf", file_filename);
}
TEST(GetHeaderValueTest, DefaultValue) {
Headers headers = {{"Dummy", "Dummy"}};
auto val = detail::get_header_value(headers, "Content-Type", "text/plain", 0);
EXPECT_STREQ("text/plain", val);
}
TEST(GetHeaderValueTest, DefaultValueInt) {
Headers headers = {{"Dummy", "Dummy"}};
auto val = detail::get_header_value_u64(headers, "Content-Length", 100, 0);
EXPECT_EQ(100ull, val);
}
TEST(GetHeaderValueTest, RegularValue) {
Headers headers = {{"Content-Type", "text/html"}, {"Dummy", "Dummy"}};
auto val = detail::get_header_value(headers, "Content-Type", "text/plain", 0);
EXPECT_STREQ("text/html", val);
}
TEST(GetHeaderValueTest, RegularValueWithDifferentCase) {
Headers headers = {{"Content-Type", "text/html"}, {"Dummy", "Dummy"}};
auto val = detail::get_header_value(headers, "content-type", "text/plain", 0);
EXPECT_STREQ("text/html", val);
}
TEST(GetHeaderValueTest, SetContent) {
Response res;
res.set_content("html", "text/html");
EXPECT_EQ("text/html", res.get_header_value("Content-Type"));
res.set_content("text", "text/plain");
EXPECT_EQ(1U, res.get_header_value_count("Content-Type"));
EXPECT_EQ("text/plain", res.get_header_value("Content-Type"));
}
TEST(GetHeaderValueTest, RegularValueInt) {
Headers headers = {{"Content-Length", "100"}, {"Dummy", "Dummy"}};
auto val = detail::get_header_value_u64(headers, "Content-Length", 0, 0);
EXPECT_EQ(100ull, val);
}
TEST(GetHeaderValueTest, RegularInvalidValueInt) {
Headers headers = {{"Content-Length", "x"}};
auto is_invalid_value = false;
auto val = detail::get_header_value_u64(headers, "Content-Length", 0, 0,
is_invalid_value);
EXPECT_EQ(0ull, val);
EXPECT_TRUE(is_invalid_value);
}
TEST(GetHeaderValueTest, OutOfRangeValueInt) {
// An all-digit value that overflows size_t must be reported as invalid, not
// silently saturated/truncated: parsing at size_t width would otherwise wrap
// a large length to a small one on 32-bit builds, leaving the framing length
// wrong while is_invalid_value stayed false.
Headers headers = {{"Content-Length", "99999999999999999999999999"}};
auto is_invalid_value = false;
detail::get_header_value_u64(headers, "Content-Length", 0, 0,
is_invalid_value);
EXPECT_TRUE(is_invalid_value);
// A well-formed length is unaffected.
Headers ok = {{"Content-Length", "1234"}};
is_invalid_value = false;
auto val = detail::get_header_value_u64(ok, "Content-Length", 0, 0,
is_invalid_value);
EXPECT_EQ(1234ull, val);
EXPECT_FALSE(is_invalid_value);
}
TEST(GetHeaderValueTest, Range) {
{
Headers headers = {make_range_header({{1, -1}})};
auto val = detail::get_header_value(headers, "Range", 0, 0);
EXPECT_STREQ("bytes=1-", val);
}
{
Headers headers = {make_range_header({{-1, 1}})};
auto val = detail::get_header_value(headers, "Range", 0, 0);
EXPECT_STREQ("bytes=-1", val);
}
{
Headers headers = {make_range_header({{1, 10}})};
auto val = detail::get_header_value(headers, "Range", 0, 0);
EXPECT_STREQ("bytes=1-10", val);
}
{
Headers headers = {make_range_header({{1, 10}, {100, -1}})};
auto val = detail::get_header_value(headers, "Range", 0, 0);
EXPECT_STREQ("bytes=1-10, 100-", val);
}
{
Headers headers = {make_range_header({{1, 10}, {100, 200}})};
auto val = detail::get_header_value(headers, "Range", 0, 0);
EXPECT_STREQ("bytes=1-10, 100-200", val);
}
{
Headers headers = {make_range_header({{0, 0}, {-1, 1}})};
auto val = detail::get_header_value(headers, "Range", 0, 0);
EXPECT_STREQ("bytes=0-0, -1", val);
}
}
TEST(BearerTokenAuthTest, SchemeValidation) {
// A value shorter than "Bearer " must not throw from the fixed-length
// substr, and a non-Bearer scheme must not be reported as a token.
struct {
const char *value;
const char *expected;
} cases[] = {
{"x", ""},
{"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", ""},
{"Bearer abc123", "abc123"},
{"bearer abc123", "abc123"}, // scheme match is case-insensitive
};
for (const auto &c : cases) {
Request req;
req.set_header("Authorization", c.value);
EXPECT_EQ(c.expected, get_bearer_token_auth(req)) << "value: " << c.value;
}
}
TEST(HeadersOrderTest, DuplicateFieldsKeepInsertionOrder) {
// RFC 9110 5.3: the order of fields sharing a name is significant. This used
// to depend on the standard library (libstdc++ handed back duplicates in
// reverse insertion order, libc++ in insertion order).
Request req;
req.set_header("Accept-Encoding", "gzip");
req.set_header("Accept-Encoding", "deflate");
req.set_header("Accept-Encoding", "br");
EXPECT_EQ(3U, req.get_header_value_count("Accept-Encoding"));
EXPECT_EQ("gzip", req.get_header_value("Accept-Encoding"));
EXPECT_EQ("gzip", req.get_header_value("Accept-Encoding", "", 0));
EXPECT_EQ("deflate", req.get_header_value("Accept-Encoding", "", 1));
EXPECT_EQ("br", req.get_header_value("Accept-Encoding", "", 2));
}
TEST(HeadersOrderTest, IdBeyondTheLastDuplicateYieldsDefault) {
Request req;
req.set_header("X-Test", "only");
EXPECT_EQ("only", req.get_header_value("X-Test", "def", 0));
EXPECT_EQ("def", req.get_header_value("X-Test", "def", 1));
EXPECT_EQ("def", req.get_header_value("X-Test", "def", 99));
EXPECT_EQ("def", req.get_header_value("X-Missing", "def", 3));
}
TEST(HeadersOrderTest, TraversalFollowsInsertionOrder) {
Headers headers;
headers.emplace("Host", "example.com");
headers.emplace("Accept-Encoding", "gzip");
headers.emplace("User-Agent", "test");
headers.emplace("Accept-Encoding", "deflate");
EXPECT_EQ("Host=example.com Accept-Encoding=gzip User-Agent=test "
"Accept-Encoding=deflate ",
entries_to_string(headers));
}
TEST(HeadersOrderTest, LookupIsCaseInsensitiveAndPicksTheFirstField) {
Headers headers = {{"Content-Type", "text/html"},
{"CONTENT-TYPE", "text/xml"}};
EXPECT_EQ(2U, headers.count("content-type"));
auto it = headers.find("content-type");
ASSERT_TRUE(it != headers.end());
EXPECT_EQ("text/html", it->second);
}
TEST(HeadersOrderTest, ErasingAnEqualRangeSparesInterleavedFields) {
// The fields sharing a name are not adjacent, so an equal_range() erase must
// drop only those fields and leave everything positioned between them.
Headers headers = {{"A", "1"}, {"X", "x"}, {"A", "2"},
{"Y", "y"}, {"A", "3"}, {"Z", "z"}};
auto rng = headers.equal_range("a");
headers.erase(rng.first, rng.second);
EXPECT_EQ("X=x Y=y Z=z ", entries_to_string(headers));
}
TEST(HeadersOrderTest, ErasingByNameKeepsTheRemainingOrder) {
Headers headers = {
{"A", "1"}, {"B", "2"}, {"a", "3"}, {"C", "4"}, {"A", "5"}};
EXPECT_EQ(3U, headers.erase("A"));
EXPECT_EQ(0U, headers.count("A"));
EXPECT_EQ("B=2 C=4 ", entries_to_string(headers));
}
TEST(HeadersOrderTest, EmplaceFrontPrepends) {
Headers headers = {{"Accept", "*/*"}, {"User-Agent", "test"}};
headers.emplace_front("Host", "example.com");
EXPECT_EQ("Host=example.com Accept=*/* User-Agent=test ",
entries_to_string(headers));
}
TEST(ParseHeaderValueTest, Range) {
{
Ranges ranges;
auto ret = detail::parse_range_header("bytes=1-", ranges);
EXPECT_TRUE(ret);
EXPECT_EQ(1u, ranges.size());
EXPECT_EQ(1u, ranges[0].first);
EXPECT_EQ(-1, ranges[0].second);
}
{
Ranges ranges;
auto ret = detail::parse_range_header("bytes=-1", ranges);
EXPECT_TRUE(ret);
EXPECT_EQ(1u, ranges.size());
EXPECT_EQ(-1, ranges[0].first);
EXPECT_EQ(1u, ranges[0].second);
}
{
Ranges ranges;
auto ret = detail::parse_range_header("bytes=1-10", ranges);
EXPECT_TRUE(ret);
EXPECT_EQ(1u, ranges.size());
EXPECT_EQ(1u, ranges[0].first);
EXPECT_EQ(10u, ranges[0].second);
}
{
Ranges ranges;
auto ret = detail::parse_range_header("bytes=10-1", ranges);
EXPECT_FALSE(ret);
}
{
Ranges ranges;
auto ret = detail::parse_range_header("bytes=1-10, 100-", ranges);
EXPECT_TRUE(ret);
EXPECT_EQ(2u, ranges.size());
EXPECT_EQ(1u, ranges[0].first);
EXPECT_EQ(10u, ranges[0].second);
EXPECT_EQ(100u, ranges[1].first);
EXPECT_EQ(-1, ranges[1].second);
}
{
Ranges ranges;
auto ret =
detail::parse_range_header("bytes=1-10, 100-200, 300-400", ranges);
EXPECT_TRUE(ret);
EXPECT_EQ(3u, ranges.size());
EXPECT_EQ(1u, ranges[0].first);
EXPECT_EQ(10u, ranges[0].second);
EXPECT_EQ(100u, ranges[1].first);
EXPECT_EQ(200u, ranges[1].second);
EXPECT_EQ(300u, ranges[2].first);
EXPECT_EQ(400u, ranges[2].second);
}
{
Ranges ranges;
EXPECT_FALSE(detail::parse_range_header("bytes", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=0", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=-", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes= ", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=,", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=,,", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=,,,", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=a-b", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=1-0", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=0--1", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=0- 1", ranges));
EXPECT_FALSE(detail::parse_range_header("bytes=0 -1", ranges));
// Overflows ssize_t; must not be read as the suffix range "bytes=-100".
EXPECT_FALSE(
detail::parse_range_header("bytes=9223372036854775808-100", ranges));
EXPECT_TRUE(ranges.empty());
}
{
// RFC 9110 14.1.2: a last-byte-pos greater than the content length is the
// remainder of the representation, so it stays accepted.
Ranges ranges;
auto ret =
detail::parse_range_header("bytes=0-99999999999999999999", ranges);
EXPECT_TRUE(ret);
ASSERT_EQ(1u, ranges.size());
EXPECT_EQ(0, ranges[0].first);
EXPECT_EQ(-1, ranges[0].second);
}
}
TEST(ParseAcceptEncoding1, AcceptEncoding) {
Request req;
req.set_header("Accept-Encoding", "gzip");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Gzip);
#else
EXPECT_TRUE(ret == detail::EncodingType::None);
#endif
}
TEST(ParseAcceptEncoding2, AcceptEncoding) {
Request req;
req.set_header("Accept-Encoding", "gzip, deflate, br, zstd");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Brotli);
#elif CPPHTTPLIB_ZLIB_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Gzip);
#elif CPPHTTPLIB_ZSTD_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Zstd);
#else
EXPECT_TRUE(ret == detail::EncodingType::None);
#endif
}
TEST(ParseAcceptEncoding3, AcceptEncoding) {
Request req;
req.set_header("Accept-Encoding",
"br;q=1.0, gzip;q=0.8, zstd;q=0.8, *;q=0.1");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Brotli);
#elif CPPHTTPLIB_ZLIB_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Gzip);
#elif CPPHTTPLIB_ZSTD_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Zstd);
#else
EXPECT_TRUE(ret == detail::EncodingType::None);
#endif
}
TEST(ParseAcceptEncoding4, AcceptEncodingQZero) {
// All supported encodings rejected with q=0 should return None
Request req;
req.set_header("Accept-Encoding", "gzip;q=0, br;q=0, zstd;q=0, deflate");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
EXPECT_TRUE(ret == detail::EncodingType::None);
}
TEST(ParseAcceptEncoding5, AcceptEncodingQZeroVariants) {
// q=0.0, q=0.00, q=0.000 should also be treated as rejected
Request req;
req.set_header("Accept-Encoding", "gzip;q=0.000, br;q=0.0, zstd;q=0.00");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
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;
req.set_header("Accept-Encoding", "x-gzip;q=0");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
EXPECT_TRUE(ret == detail::EncodingType::None);
}
TEST(ParseAcceptEncoding7, AcceptEncodingCaseInsensitive) {
// RFC 7231: Accept-Encoding values are case-insensitive
Request req;
req.set_header("Accept-Encoding", "GZIP, BR, ZSTD");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Brotli);
#elif CPPHTTPLIB_ZLIB_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Gzip);
#elif CPPHTTPLIB_ZSTD_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Zstd);
#else
EXPECT_TRUE(ret == detail::EncodingType::None);
#endif
}
TEST(ParseAcceptEncoding8, AcceptEncodingQValuePriority) {
// q value should determine priority, not hardcoded order
Request req;
req.set_header("Accept-Encoding", "br;q=0.5, gzip;q=1.0, zstd;q=0.8");
Response res;
res.set_header("Content-Type", "text/plain");
auto ret = detail::encoding_type(req, res);
// gzip has highest q=1.0, so it should be selected even though
// br and zstd are also supported
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Gzip);
#elif CPPHTTPLIB_ZSTD_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Zstd);
#elif CPPHTTPLIB_BROTLI_SUPPORT
EXPECT_TRUE(ret == detail::EncodingType::Brotli);
#else
EXPECT_TRUE(ret == detail::EncodingType::None);
#endif
}
TEST(BufferStreamTest, read) {
detail::BufferStream strm1;
Stream &strm = strm1;
EXPECT_EQ(5, strm.write("hello"));
char buf[512];
EXPECT_EQ(2, strm.read(buf, 2));
EXPECT_EQ('h', buf[0]);
EXPECT_EQ('e', buf[1]);
EXPECT_EQ(2, strm.read(buf, 2));
EXPECT_EQ('l', buf[0]);
EXPECT_EQ('l', buf[1]);
EXPECT_EQ(1, strm.read(buf, 1));
EXPECT_EQ('o', buf[0]);
EXPECT_EQ(0, strm.read(buf, 1));
}
TEST(HostnameToIPConversionTest, HTTPWatch_Online) {
auto host = "www.httpwatch.com";
auto ip = hosted_at(host);
EXPECT_EQ("23.96.13.243", ip);
std::vector<std::string> addrs;
hosted_at(host, addrs);
EXPECT_EQ(1u, addrs.size());
}
#if 0 // It depends on each test environment...
TEST(HostnameToIPConversionTest, YouTube_Online) {
auto host = "www.youtube.com";
std::vector<std::string> addrs;
hosted_at(host, addrs);
EXPECT_EQ(20u, addrs.size());
auto it = std::find(addrs.begin(), addrs.end(), "2607:f8b0:4006:809::200e");
EXPECT_TRUE(it != addrs.end());
}
#endif
class ChunkedEncodingTest : public ::testing::Test {
protected:
ChunkedEncodingTest()
: cli_(HOST, PORT)
#ifdef CPPHTTPLIB_SSL_ENABLED
,
svr_(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE)
#endif
{
cli_.set_connection_timeout(2);
#ifdef CPPHTTPLIB_SSL_ENABLED
cli_.enable_server_certificate_verification(false);
#endif
}
virtual void SetUp() {
read_file("./image.jpg", image_data_);
svr_.Get("/hi", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
});
svr_.Get(
"/chunked", [this](const httplib::Request &, httplib::Response &res) {
res.set_chunked_content_provider(
"image/jpeg", [this](size_t offset, httplib::DataSink &sink) {
size_t remaining = image_data_.size() - offset;
if (remaining == 0) {
sink.done();
} else {
constexpr size_t CHUNK_SIZE = 1024;
size_t send_size = std::min(CHUNK_SIZE, remaining);
sink.write(&image_data_[offset], send_size);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return true;
});
});
t_ = thread([&]() { ASSERT_TRUE(svr_.listen(HOST, PORT)); });
svr_.wait_until_ready();
}
virtual void TearDown() {
svr_.stop();
if (!request_threads_.empty()) {
std::this_thread::sleep_for(std::chrono::seconds(1));
for (auto &t : request_threads_) {
t.join();
}
}
t_.join();
}
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli_;
SSLServer svr_;
#else
Client cli_;
Server svr_;
#endif
thread t_;
std::vector<thread> request_threads_;
std::string image_data_;
};
TEST_F(ChunkedEncodingTest, NormalGet) {
auto res = cli_.Get("/chunked");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
std::string out;
read_file("./image.jpg", out);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(out, res->body);
}
TEST_F(ChunkedEncodingTest, WithContentReceiver) {
std::string body;
auto res = cli_.Get("/chunked", [&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
std::string out;
read_file("./image.jpg", out);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(out, body);
}
TEST_F(ChunkedEncodingTest, WithResponseHandlerAndContentReceiver) {
std::string body;
auto res = cli_.Get(
"/chunked",
[&](const Response &response) {
EXPECT_EQ(StatusCode::OK_200, response.status);
return true;
},
[&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
std::string out;
read_file("./image.jpg", out);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(out, body);
}
TEST(RangeTest, FromHTTPBin_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/range/32"};
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 443;
SSLClient cli(host, port);
#else
auto port = 80;
Client cli(host, port);
#endif
cli.set_connection_timeout(5);
{
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("abcdefghijklmnopqrstuvwxyzabcdef", res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
Headers headers = {make_range_header({{1, -1}})};
auto res = cli.Get(path, headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("bcdefghijklmnopqrstuvwxyzabcdef", res->body);
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
}
{
Headers headers = {make_range_header({{1, 10}})};
auto res = cli.Get(path, headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("bcdefghijk", res->body);
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
}
// go-httpbin (httpbingo.org) returns 206 even when the range covers the
// entire resource, while the original httpbin returned 200. Both are
// acceptable per RFC 9110 §15.3.7, so we accept either status code.
{
Headers headers = {make_range_header({{0, 31}})};
auto res = cli.Get(path, headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("abcdefghijklmnopqrstuvwxyzabcdef", res->body);
EXPECT_TRUE(res->status == StatusCode::OK_200 ||
res->status == StatusCode::PartialContent_206);
}
{
Headers headers = {make_range_header({{0, -1}})};
auto res = cli.Get(path, headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("abcdefghijklmnopqrstuvwxyzabcdef", res->body);
EXPECT_TRUE(res->status == StatusCode::OK_200 ||
res->status == StatusCode::PartialContent_206);
}
// go-httpbin returns 206 with clamped range for over-range requests,
// while the original httpbin returned 416. Both behaviors are observed
// in real servers, so we only verify the request succeeds.
{
Headers headers = {make_range_header({{0, 32}})};
auto res = cli.Get(path, headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
}
}
TEST(GetAddrInfoDanglingRefTest, LongTimeout) {
auto host = "unresolvableaddress.local";
auto path = std::string{"/"};
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 443;
SSLClient cli(host, port);
#else
auto port = 80;
Client cli(host, port);
#endif
cli.set_connection_timeout(1);
{
auto res = cli.Get(path);
ASSERT_FALSE(res);
}
std::this_thread::sleep_for(std::chrono::seconds(8));
}
#if defined(__linux__) && defined(__GLIBC__) && \
defined(CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO)
// Forward declaration: in split builds split.py strips `inline` and moves the
// definition into httplib.cc, so detail::getaddrinfo_with_timeout is not
// visible from the public httplib.h. Re-declaring it here lets the tests link
// against the symbol in both header-only and split builds.
namespace httplib {
namespace detail {
int getaddrinfo_with_timeout(const char *node, const char *service,
const struct addrinfo *hints,
struct addrinfo **res, time_t timeout_sec);
} // namespace detail
} // namespace httplib
// Reproducer for https://github.com/yhirose/cpp-httplib/issues/2431.
//
// On Linux/glibc, getaddrinfo_with_timeout() runs the lookup via
// getaddrinfo_a(GAI_NOWAIT) using a stack-local `struct gaicb`. When the
// gai_suspend() call hits the connection timeout the function calls
// gai_cancel() and returns immediately. gai_cancel() is non-blocking and
// can return EAI_NOTCANCELED, in which case the resolver worker thread is
// still alive and still references the now-destroyed stack frame.
//
// Triggering the bug requires DNS to actually hang (UDP/53 dropped, etc.),
// so these tests are gated on CPPHTTPLIB_TEST_ISSUE_2431=1 and are skipped
// during normal runs. test/run_issue_2431_repro.sh sets up the environment
// and runs them in a container.
namespace {
bool should_run_issue_2431_tests() {
const char *v = getenv("CPPHTTPLIB_TEST_ISSUE_2431");
return v && *v && std::string(v) != "0";
}
std::string unique_unresolvable_host(int n) {
// .invalid is reserved (RFC 6761) and is never served by real DNS, but
// glibc still asks the configured nameserver — which is exactly the path
// we want to exercise. A unique label per call avoids the resolver cache.
auto t = std::chrono::steady_clock::now().time_since_epoch().count();
return "h-" + std::to_string(::getpid()) + "-" + std::to_string(t) + "-" +
std::to_string(n) + ".invalid";
}
} // namespace
TEST(GetAddrInfoAsyncCancelTest, DirectCallSingleThread) {
if (!should_run_issue_2431_tests()) {
GTEST_SKIP()
<< "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run";
}
for (int i = 0; i < 8; ++i) {
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
auto host = unique_unresolvable_host(i);
struct addrinfo *result = nullptr;
int rc = detail::getaddrinfo_with_timeout(host.c_str(), "80", &hints,
&result, /*timeout_sec=*/1);
if (rc == 0 && result) { freeaddrinfo(result); }
}
// Give orphaned getaddrinfo_a worker threads a chance to write into the
// stack region they still believe holds their gaicb.
std::this_thread::sleep_for(std::chrono::seconds(3));
}
TEST(GetAddrInfoAsyncCancelTest, DirectCallMultiThread) {
if (!should_run_issue_2431_tests()) {
GTEST_SKIP()
<< "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run";
}
std::atomic<bool> stop{false};
std::vector<std::thread> threads;
for (int t = 0; t < 8; ++t) {
threads.emplace_back([t, &stop] {
int i = 0;
while (!stop.load(std::memory_order_relaxed)) {
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
auto host = unique_unresolvable_host(t * 100000 + i++);
struct addrinfo *result = nullptr;
int rc = detail::getaddrinfo_with_timeout(host.c_str(), "80", &hints,
&result, /*timeout_sec=*/1);
if (rc == 0 && result) { freeaddrinfo(result); }
}
});
}
std::this_thread::sleep_for(std::chrono::seconds(8));
stop.store(true, std::memory_order_relaxed);
for (auto &th : threads) {
th.join();
}
std::this_thread::sleep_for(std::chrono::seconds(3));
}
TEST(GetAddrInfoAsyncCancelTest, ClientGetMultiThread) {
if (!should_run_issue_2431_tests()) {
GTEST_SKIP()
<< "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run";
}
std::atomic<bool> stop{false};
std::vector<std::thread> threads;
for (int t = 0; t < 8; ++t) {
threads.emplace_back([t, &stop] {
int i = 0;
while (!stop.load(std::memory_order_relaxed)) {
auto host = unique_unresolvable_host(t * 100000 + i++);
Client cli(host, 80);
cli.set_connection_timeout(1, 0);
cli.set_read_timeout(1, 0);
cli.set_write_timeout(1, 0);
(void)cli.Get("/");
}
});
}
std::this_thread::sleep_for(std::chrono::seconds(8));
stop.store(true, std::memory_order_relaxed);
for (auto &th : threads) {
th.join();
}
std::this_thread::sleep_for(std::chrono::seconds(3));
}
#endif // __linux__ && __GLIBC__ && CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO
TEST(ConnectionErrorTest, InvalidHost) {
auto host = "-abcde.com";
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 443;
SSLClient cli(host, port);
#else
auto port = 80;
Client cli(host, port);
#endif
cli.set_connection_timeout(std::chrono::seconds(2));
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Connection, res.error());
}
TEST(ConnectionErrorTest, InvalidHost2) {
auto host = "httpcan.org/";
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_connection_timeout(std::chrono::seconds(2));
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Connection, res.error());
}
TEST(ConnectionErrorTest, InvalidHostCheckResultErrorToString) {
auto host = "httpcan.org/";
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_connection_timeout(std::chrono::seconds(2));
auto res = cli.Get("/");
ASSERT_TRUE(!res);
stringstream s;
s << "error code: " << res.error();
EXPECT_EQ("error code: Could not establish connection (2)", s.str());
}
TEST(ConnectionErrorTest, InvalidPort) {
auto host = "localhost";
auto port = 44380;
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host, port);
#else
Client cli(host, port);
#endif
cli.set_connection_timeout(std::chrono::seconds(2));
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_TRUE(Error::Connection == res.error() ||
Error::ConnectionTimeout == res.error());
}
TEST(ConnectionErrorTest, Timeout_Online) {
auto host = "google.com";
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 44380;
SSLClient cli(host, port);
#else
auto port = 8080;
Client cli(host, port);
#endif
cli.set_connection_timeout(std::chrono::seconds(2));
// only probe one address type so that the error reason
// correlates to the timed-out IPv4, not the unsupported
// IPv6 connection attempt
cli.set_address_family(AF_INET);
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::ConnectionTimeout, res.error());
}
TEST(CancelTest, NoCancel_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/range/32"};
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 443;
SSLClient cli(host, port);
#else
auto port = 80;
Client cli(host, port);
#endif
cli.set_connection_timeout(std::chrono::seconds(5));
auto res = cli.Get(path, [](uint64_t, uint64_t) { return true; });
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("abcdefghijklmnopqrstuvwxyzabcdef", res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(CancelTest, WithCancelSmallPayload_Online) {
// Use /bytes with a large payload so that the DownloadProgress callback
// (which only fires for Content-Length responses) is invoked before the
// entire body is received, giving cancellation a chance to fire.
auto host = "httpbingo.org";
auto path = std::string{"/bytes/524288"};
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 443;
SSLClient cli(host, port);
#else
auto port = 80;
Client cli(host, port);
#endif
auto res = cli.Get(path, [](uint64_t, uint64_t) { return false; });
cli.set_connection_timeout(std::chrono::seconds(5));
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, WithCancelLargePayload_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/bytes/524288"};
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 443;
SSLClient cli(host, port);
#else
auto port = 80;
Client cli(host, port);
#endif
cli.set_connection_timeout(std::chrono::seconds(5));
uint32_t count = 0;
auto res =
cli.Get(path, [&count](uint64_t, uint64_t) { return (count++ == 0); });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, NoCancelPost) {
Server svr;
svr.Post("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Post("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return true; });
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Hello World!", res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(CancelTest, WithCancelSmallPayloadPost) {
Server svr;
svr.Post("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Post("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, WithCancelLargePayloadPost) {
Server svr;
svr.set_payload_max_length(200 * 1024 * 1024);
svr.Post("/", [&](const Request & /*req*/, Response &res) {
res.set_content(LARGE_DATA, "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();
Client cli(HOST, PORT);
cli.set_payload_max_length(200 * 1024 * 1024);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Post("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, NoCancelPut) {
Server svr;
svr.Put("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Put("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return true; });
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Hello World!", res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(CancelTest, WithCancelSmallPayloadPut) {
Server svr;
svr.Put("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Put("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, WithCancelLargePayloadPut) {
Server svr;
svr.set_payload_max_length(200 * 1024 * 1024);
svr.Put("/", [&](const Request & /*req*/, Response &res) {
res.set_content(LARGE_DATA, "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();
Client cli(HOST, PORT);
cli.set_payload_max_length(200 * 1024 * 1024);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Put("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, NoCancelPatch) {
Server svr;
svr.Patch("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Patch("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return true; });
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Hello World!", res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(CancelTest, WithCancelSmallPayloadPatch) {
Server svr;
svr.Patch("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Patch("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, WithCancelLargePayloadPatch) {
Server svr;
svr.set_payload_max_length(200 * 1024 * 1024);
svr.Patch("/", [&](const Request & /*req*/, Response &res) {
res.set_content(LARGE_DATA, "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();
Client cli(HOST, PORT);
cli.set_payload_max_length(200 * 1024 * 1024);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Patch("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, NoCancelDelete) {
Server svr;
svr.Delete("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Delete("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return true; });
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Hello World!", res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(CancelTest, WithCancelSmallPayloadDelete) {
Server svr;
svr.Delete("/", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "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();
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Delete("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST(CancelTest, WithCancelLargePayloadDelete) {
Server svr;
svr.set_payload_max_length(200 * 1024 * 1024);
svr.Delete("/", [&](const Request & /*req*/, Response &res) {
res.set_content(LARGE_DATA, "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();
Client cli(HOST, PORT);
cli.set_payload_max_length(200 * 1024 * 1024);
cli.set_connection_timeout(std::chrono::seconds(5));
auto res =
cli.Delete("/", Headers(), JSON_DATA.data(), JSON_DATA.size(),
"application/json", [](uint64_t, uint64_t) { return false; });
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
static std::string remove_whitespace(const std::string &input) {
std::string output;
output.reserve(input.size());
std::copy_if(input.begin(), input.end(), std::back_inserter(output),
[](unsigned char c) { return !std::isspace(c); });
return output;
}
TEST(BaseAuthTest, FromHTTPWatch_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/basic-auth/hello/world"};
#ifdef CPPHTTPLIB_SSL_ENABLED
auto port = 443;
SSLClient cli(host, port);
#else
auto port = 80;
Client cli(host, port);
#endif
{
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
}
{
auto res = cli.Get(
path, Headers{make_basic_authentication_header("hello", "world")});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
auto body = remove_whitespace(res->body);
EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
cli.set_basic_auth("hello", "world");
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
auto body = remove_whitespace(res->body);
EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
cli.set_basic_auth("hello", "bad");
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
}
{
cli.set_basic_auth("bad", "world");
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
}
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(DigestAuthTest, FromHTTPWatch_Online) {
auto host = "httpbingo.org";
auto unauth_path = std::string{"/digest-auth/auth/hello/world"};
auto paths = std::vector<std::string>{
"/digest-auth/auth/hello/world/MD5",
"/digest-auth/auth/hello/world/SHA-256",
};
auto port = 443;
SSLClient cli(host, port);
{
auto res = cli.Get(unauth_path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
}
{
cli.set_digest_auth("hello", "world");
for (const auto &path : paths) {
auto res = cli.Get(path.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
auto body = remove_whitespace(res->body);
EXPECT_TRUE(body.find("\"authenticated\":true") != std::string::npos);
EXPECT_TRUE(body.find("\"user\":\"hello\"") != std::string::npos);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
cli.set_digest_auth("hello", "bad");
for (const auto &path : paths) {
auto res = cli.Get(path.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
}
}
}
// RFC 9110 Section 11.6.1: a WWW-Authenticate field value is a
// comma-separated list of challenges, and each challenge may itself carry a
// comma-separated auth-param list, so a server can legally offer Basic
// before Digest, either as two field lines or packed into one. Runs one 401
// -> Digest-retry round trip with the given field lines (get_combined_header_
// value() joins them in receipt order) and checks that the retry carries a
// well-formed Digest Authorization header naming expected_realm.
static void
run_digest_challenge_list_test(const std::vector<std::string> &challenges,
const std::string &expected_realm) {
std::atomic<int> hits{0};
Server svr;
svr.Get("/x", [&](const Request &req, Response &res) {
if (++hits == 1) {
res.status = StatusCode::Unauthorized_401;
for (const auto &challenge : challenges) {
res.set_header("WWW-Authenticate", challenge);
}
} else {
auto authorization = req.get_header_value("Authorization");
EXPECT_EQ(0u, authorization.rfind("Digest ", 0));
EXPECT_NE(std::string::npos,
authorization.find("realm=\"" + expected_realm + "\""));
EXPECT_EQ(std::string::npos, authorization.find("Basic"));
res.set_content("ok", "text/plain");
}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_digest_auth("hello", "world");
auto res = cli.Get("/x");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(2, hits.load());
}
static const char *kBasicChallenge = "Basic realm=\"decoy\"";
static const char *kDigestChallenge =
"Digest realm=\"testrealm\", qop=\"auth\", nonce=\"abc123\", "
"algorithm=MD5";
TEST(DigestAuthTest, BasicChallengeListedBeforeDigest) {
run_digest_challenge_list_test({kBasicChallenge, kDigestChallenge},
"testrealm");
}
// Same challenge list with the schemes in the opposite order, to make sure
// the fix above didn't just special-case "Basic first".
TEST(DigestAuthTest, DigestChallengeListedBeforeBasic) {
run_digest_challenge_list_test({kDigestChallenge, kBasicChallenge},
"testrealm");
}
// A comma inside a quoted auth-param value must not be mistaken for the
// separator between two challenges (or two auth-params).
TEST(DigestAuthTest, RealmContainingCommaIsNotSplit) {
run_digest_challenge_list_test(
{"Digest realm=\"test,realm\", qop=\"auth\", nonce=\"abc123\", "
"algorithm=MD5"},
"test,realm");
}
// RFC 7616 Section 3.3 requires realm and nonce on every Digest challenge.
// make_digest_authentication_header() dereferences both unconditionally, so
// a server sending a challenge missing either one must not be treated as
// usable -- doing so used to crash the client with std::out_of_range.
static void run_digest_challenge_missing_field_test(const char *challenge) {
Server svr;
svr.Get("/x", [&](const Request & /*req*/, Response &res) {
res.status = StatusCode::Unauthorized_401;
res.set_header("WWW-Authenticate", challenge);
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_digest_auth("hello", "world");
auto res = cli.Get("/x");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
}
TEST(DigestAuthTest, ChallengeMissingRealmAndNonceDoesNotCrash) {
run_digest_challenge_missing_field_test("Digest qop=\"auth\"");
}
TEST(DigestAuthTest, ChallengeMissingNonceDoesNotCrash) {
run_digest_challenge_missing_field_test("Digest realm=\"r\", qop=\"auth\"");
}
TEST(DigestAuthTest, ChallengeMissingRealmDoesNotCrash) {
run_digest_challenge_missing_field_test("Digest nonce=\"n\", qop=\"auth\"");
}
// A hostile server can put a '"' in realm/nonce/opaque, or a non-token
// algorithm, in its challenge. parse_www_authenticate() de-escapes quoted-pairs
// when storing the values, so the header builder has to re-escape them (and
// keep algorithm a bare token); otherwise the value breaks out of its
// quoted-string and injects extra auth-params into the client's Authorization.
TEST(DigestAuthTest, EscapesInjectedAuthParams) {
std::atomic<int> hits{0};
std::string authorization;
Server svr;
svr.Get("/x", [&](const Request &req, Response &res) {
if (++hits == 1) {
res.status = StatusCode::Unauthorized_401;
// On the wire the quotes embedded in the values are backslash-escaped.
res.set_header("WWW-Authenticate",
"Digest realm=\"testrealm\", "
"nonce=\"n\\\"; injected=\\\"x\", "
"algorithm=\"MD5, injected2=\\\"y\\\"\", qop=\"auth\"");
} else {
authorization = req.get_header_value("Authorization");
res.set_content("ok", "text/plain");
}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_digest_auth("hello", "world");
auto res = cli.Get("/x");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(2, hits.load());
EXPECT_EQ(0u, authorization.rfind("Digest ", 0));
// The nonce (de-escaped to n"; injected="x ) must be re-escaped so it stays
// inside its quoted-string rather than starting an "injected" auth-param.
EXPECT_NE(std::string::npos,
authorization.find("nonce=\"n\\\"; injected=\\\"x\""));
// A non-token algorithm falls back to a bare MD5 token, dropping the payload.
EXPECT_EQ(std::string::npos, authorization.find("injected2"));
}
#endif
TEST(SpecifyServerIPAddressTest, AnotherHostname_Online) {
auto host = "google.com";
auto another_host = "example.com";
auto wrong_ip = "0.0.0.0";
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_hostname_addr_map({{another_host, wrong_ip}});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::MovedPermanently_301, res->status);
}
TEST(SpecifyServerIPAddressTest, RealHostname_Online) {
auto host = "google.com";
auto wrong_ip = "0.0.0.0";
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_hostname_addr_map({{host, wrong_ip}});
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Connection, res.error());
}
TEST(SpecifyServerIPAddressTest, HostnameAsAddrMapValue) {
// A mapped value that is not an IP literal must be resolved. "localhost"
// resolves from the hosts file, so this test needs no external DNS.
// "target.invalid" (RFC 6761) is only a map key and the Host header value.
auto host = "target.invalid";
Server svr;
std::string received_host;
svr.Get("/hi", [&](const Request &req, Response &res) {
received_host = req.get_header_value("Host");
res.set_content("Hello World!", "text/plain");
});
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);
cli.set_hostname_addr_map({{host, HOST}});
auto res = cli.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// The mapping only redirects the connection; the identity stays host_.
EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host);
}
TEST(SpecifyServerIPAddressTest, IPAddressAsAddrMapValue) {
// A mapped value that is an IP literal keeps the AI_NUMERICHOST path.
auto host = "target.invalid";
Server svr;
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
});
auto port = svr.bind_to_any_port("127.0.0.1");
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);
cli.set_hostname_addr_map({{host, "127.0.0.1"}});
auto res = cli.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(SpecifyServerIPAddressTest, EmptyAddrMapValueIsIgnored) {
// An empty mapped value must leave host_ as the connection target. Without
// that guard the empty value would become the host argument, getaddrinfo
// would be called with a null node, and (no AI_PASSIVE) it would resolve to
// loopback - silently connecting somewhere the caller never asked for.
// The server listens on loopback, so such a fallback would succeed and is
// therefore observable as a failure of this test.
auto blackhole = "192.0.2.1"; // TEST-NET-1, never routable
Server svr;
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
});
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(blackhole, port);
cli.set_hostname_addr_map({{blackhole, ""}});
cli.set_connection_timeout(1);
auto res = cli.Get("/hi");
EXPECT_FALSE(res) << "empty mapping must not redirect to loopback";
}
TEST(AbsoluteRedirectTest, Redirect_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/absolute-redirect/3"};
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_follow_location(true);
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(RedirectTest, Redirect_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/redirect/3"};
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_follow_location(true);
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(RelativeRedirectTest, Redirect_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/relative-redirect/3"};
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_follow_location(true);
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(TooManyRedirectTest, Redirect_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/redirect/21"};
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_follow_location(true);
auto res = cli.Get(path);
ASSERT_TRUE(!res);
EXPECT_EQ(Error::ExceedRedirectCount, res.error());
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(YahooRedirectTest, Redirect_Online) {
Client cli("yahoo.com");
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MovedPermanently_301, res->status);
cli.set_follow_location(true);
res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("https://www.yahoo.com/", res->location);
}
// Previously "nghttp2.org" "/httpbin/redirect-to"
#define REDIR_HOST "httpbingo.org"
#define REDIR_PATH "/redirect-to"
TEST(HttpsToHttpRedirectTest, Redirect_Online) {
SSLClient cli(REDIR_HOST);
cli.set_follow_location(true);
auto res =
cli.Get(REDIR_PATH "?url=http%3A%2F%2Fexample.com&status_code=302");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(HttpsToHttpRedirectTest2, Redirect_Online) {
SSLClient cli(REDIR_HOST);
cli.set_follow_location(true);
Params params;
params.emplace("url", "http://example.com");
params.emplace("status_code", "302");
auto res = cli.Get(REDIR_PATH, params, Headers{});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(HttpsToHttpRedirectTest3, Redirect_Online) {
SSLClient cli(REDIR_HOST);
cli.set_follow_location(true);
Params params;
params.emplace("url", "http://example.com");
auto res = cli.Get(REDIR_PATH "?status_code=302", params, Headers{});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(UrlWithSpace, Redirect_Online) {
SSLClient cli("edge.forgecdn.net");
cli.set_follow_location(true);
auto res = cli.Get("/files/2595/310/Neat 1.4-17.jar");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(18527U, res->get_header_value_u64("Content-Length"));
}
#endif
#if !defined(_WIN32) && !defined(_WIN64)
TEST(ReceiveSignals, Signal) {
auto setupSignalHandlers = []() {
struct sigaction act;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_SIGINFO;
act.sa_sigaction = [](int sig, siginfo_t *, void *) {
switch (sig) {
case SIGINT:
default: break;
}
};
::sigaction(SIGINT, &act, nullptr);
};
Server svr;
int port = 0;
auto thread = std::thread([&]() {
setupSignalHandlers();
port = svr.bind_to_any_port(HOST);
svr.listen_after_bind();
});
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
ASSERT_TRUE(svr.is_running());
pthread_kill(thread.native_handle(), SIGINT);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
ASSERT_TRUE(svr.is_running());
}
#endif
TEST(RedirectToDifferentPort, Redirect) {
Server svr1;
svr1.Get("/1", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
});
int svr1_port = 0;
auto thread1 = std::thread([&]() {
svr1_port = svr1.bind_to_any_port(HOST);
svr1.listen_after_bind();
});
Server svr2;
svr2.Get("/2", [&](const Request & /*req*/, Response &res) {
res.set_redirect("http://localhost:" + std::to_string(svr1_port) + "/1");
});
int svr2_port = 0;
auto thread2 = std::thread([&]() {
svr2_port = svr2.bind_to_any_port(HOST);
svr2.listen_after_bind();
});
auto se = detail::scope_exit([&] {
svr2.stop();
thread2.join();
svr1.stop();
thread1.join();
ASSERT_FALSE(svr2.is_running());
ASSERT_FALSE(svr1.is_running());
});
svr1.wait_until_ready();
svr2.wait_until_ready();
Client cli("localhost", svr2_port);
cli.set_follow_location(true);
auto res = cli.Get("/2");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!", res->body);
}
static void
TestDoNotForwardCredentialsOnRedirect(std::function<void(Client &)> set_auth) {
Server svr1;
std::string captured_authorization;
svr1.Get("/target", [&](const Request &req, Response &res) {
captured_authorization = req.get_header_value("Authorization");
res.set_content("OK", "text/plain");
});
int svr1_port = 0;
auto thread1 = std::thread([&]() {
svr1_port = svr1.bind_to_any_port(HOST);
svr1.listen_after_bind();
});
Server svr2;
svr2.Get("/redir", [&](const Request & /*req*/, Response &res) {
res.set_redirect(
"http://localhost:" + std::to_string(svr1_port) + "/target", 302);
});
int svr2_port = 0;
auto thread2 = std::thread([&]() {
svr2_port = svr2.bind_to_any_port(HOST);
svr2.listen_after_bind();
});
auto se = detail::scope_exit([&] {
svr2.stop();
thread2.join();
svr1.stop();
thread1.join();
ASSERT_FALSE(svr2.is_running());
ASSERT_FALSE(svr1.is_running());
});
svr1.wait_until_ready();
svr2.wait_until_ready();
Client cli("localhost", svr2_port);
cli.set_follow_location(true);
set_auth(cli);
auto res = cli.Get("/redir");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// RFC 9110: credentials MUST NOT be forwarded to a different host
EXPECT_TRUE(captured_authorization.empty());
}
TEST(RedirectToDifferentPort, DoNotForwardCredentialsBasicAuth) {
TestDoNotForwardCredentialsOnRedirect(
[](Client &cli) { cli.set_basic_auth("admin", "secret"); });
}
TEST(RedirectToDifferentPort, DoNotForwardCredentialsBearerToken) {
TestDoNotForwardCredentialsOnRedirect(
[](Client &cli) { cli.set_bearer_token_auth("my-secret-token"); });
}
TEST(RedirectToDifferentPort, DoNotForwardCookie) {
Server svr1;
std::string captured_cookie;
bool target_hit = false;
svr1.Get("/target", [&](const Request &req, Response &res) {
captured_cookie = req.get_header_value("Cookie");
target_hit = true;
res.set_content("OK", "text/plain");
});
int svr1_port = 0;
auto thread1 = std::thread([&]() {
svr1_port = svr1.bind_to_any_port(HOST);
svr1.listen_after_bind();
});
Server svr2;
svr2.Get("/redir", [&](const Request & /*req*/, Response &res) {
res.set_redirect(
"http://localhost:" + std::to_string(svr1_port) + "/target", 302);
});
int svr2_port = 0;
auto thread2 = std::thread([&]() {
svr2_port = svr2.bind_to_any_port(HOST);
svr2.listen_after_bind();
});
auto se = detail::scope_exit([&] {
svr2.stop();
thread2.join();
svr1.stop();
thread1.join();
ASSERT_FALSE(svr2.is_running());
ASSERT_FALSE(svr1.is_running());
});
svr1.wait_until_ready();
svr2.wait_until_ready();
Client cli("localhost", svr2_port);
cli.set_follow_location(true);
Headers headers = {{"Cookie", "session_id=SECRET; auth=PRIVATE"}};
auto res = cli.Get("/redir", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_TRUE(target_hit);
// Cookie MUST NOT be forwarded to a different host (GHSA-22mf-w2v3-r2jv)
EXPECT_TRUE(captured_cookie.empty());
}
TEST(RedirectToSamePort, ForwardCookie) {
// A same-origin redirect (same scheme/host/port) should preserve the Cookie
// header so that ordinary session flows keep working.
Server svr;
std::string captured_cookie;
svr.Get("/redir", [&](const Request & /*req*/, Response &res) {
res.set_redirect("/target", 302);
});
svr.Get("/target", [&](const Request &req, Response &res) {
captured_cookie = req.get_header_value("Cookie");
res.set_content("OK", "text/plain");
});
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);
cli.set_follow_location(true);
Headers headers = {{"Cookie", "session_id=SECRET"}};
auto res = cli.Get("/redir", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("session_id=SECRET", captured_cookie);
}
TEST(RedirectToDifferentPort, OverflowPortNumber) {
Server svr;
svr.Get("/redir", [&](const Request & /*req*/, Response &res) {
// Port number that overflows int — should not crash
res.set_redirect("http://localhost:99999999999999999999/target");
});
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);
cli.set_follow_location(true);
auto res = cli.Get("/redir");
// Should fail gracefully, not crash (no valid response due to bad port)
EXPECT_FALSE(res);
}
TEST(RedirectToDifferentPort, TrailingCharactersInPort) {
Server svr;
auto port = svr.bind_to_any_port(HOST);
svr.Get("/redir", [&](const Request & /*req*/, Response &res) {
// The server's own port followed by junk must not be followed
res.set_redirect("http://" + std::string(HOST) + ":" +
std::to_string(port) + "junk/target");
});
svr.Get("/target", [&](const Request & /*req*/, Response &res) {
res.set_content("target", "text/plain");
});
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);
cli.set_follow_location(true);
auto res = cli.Get("/redir");
EXPECT_FALSE(res);
}
TEST(RedirectFromPageWithContent, Redirect) {
Server svr;
svr.Get("/1", [&](const Request & /*req*/, Response &res) {
res.set_content("___", "text/plain");
res.set_redirect("/2");
});
svr.Get("/2", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
});
auto th = std::thread([&]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
th.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli("localhost", PORT);
cli.set_follow_location(true);
std::string body;
auto res = cli.Get("/1", [&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!", body);
}
{
Client cli("localhost", PORT);
std::string body;
auto res = cli.Get("/1", [&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Found_302, res->status);
EXPECT_EQ("___", body);
}
}
TEST(RedirectFromPageWithContentIP6, Redirect) {
Server svr;
auto port_str = std::to_string(PORT);
auto redirect_url = "http://[::1]:" + port_str + "/2";
auto expected_host = "[::1]:" + port_str;
svr.Get("/1", [&](const Request & /*req*/, Response &res) {
res.set_content("___", "text/plain");
// res.set_redirect("/2");
res.set_redirect(redirect_url);
});
svr.Get("/2", [&](const Request &req, Response &res) {
auto host_header = req.headers.find("Host");
ASSERT_TRUE(host_header != req.headers.end());
EXPECT_EQ(expected_host, host_header->second);
res.set_content("Hello World!", "text/plain");
});
auto th = std::thread([&]() { svr.listen("::1", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
th.join();
ASSERT_FALSE(svr.is_running());
});
// When IPV6 support isn't available svr.listen("::1", PORT) never
// actually starts anything, so the condition !svr.is_running() will
// always remain true, and the loop never stops.
// This basically counts how many milliseconds have passed since the
// call to svr.listen(), and if after 5 seconds nothing started yet
// aborts the test.
for (unsigned int milliseconds = 0; !svr.is_running(); milliseconds++) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
ASSERT_LT(milliseconds, 5000U);
}
{
Client cli("::1", PORT);
cli.set_follow_location(true);
std::string body;
auto res = cli.Get("/1", [&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!", body);
}
{
Client cli("::1", PORT);
std::string body;
auto res = cli.Get("/1", [&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Found_302, res->status);
EXPECT_EQ("___", body);
}
}
TEST(PathUrlEncodeTest, PathUrlEncode) {
Server svr;
svr.Get("/foo", [](const Request &req, Response &res) {
auto a = req.params.find("a");
if (a != req.params.end()) {
res.set_content((*a).second, "text/plain");
res.status = StatusCode::OK_200;
} else {
res.status = StatusCode::BadRequest_400;
}
});
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();
{
Client cli(HOST, PORT);
cli.set_path_encode(false);
auto res = cli.Get("/foo?a=explicitly+encoded");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// This expects it back with a space, as the `+` won't have been
// url-encoded, and server-side the params get decoded turning `+`
// into spaces.
EXPECT_EQ("explicitly encoded", res->body);
}
}
TEST(PathUrlEncodeTest, PreEncodedQueryNotReencoded) {
// When path encoding is disabled the client must transmit the supplied
// query verbatim. Decoding-then-re-encoding it (the previous behavior)
// corrupts pre-encoded binary payloads: e.g. `%20` would be turned into
// `+`, which a strict RFC 3986 server decodes back as `+` (0x2B) rather
// than a space (0x20). Assert on the raw wire target to catch this.
Server svr;
const std::string expected_target = "/foo?q=a%20b%2Cc%24d%3Bx&a=%00%FF";
svr.Get("/foo", [&](const Request &req, Response &res) {
EXPECT_EQ(expected_target, req.target);
res.status = StatusCode::OK_200;
});
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();
{
Client cli(HOST, PORT);
cli.set_path_encode(false);
auto res = cli.Get(expected_target.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
}
TEST(PathUrlEncodeTest, StreamingMatchesBufferedTarget) {
// `open_stream()` used to skip the path encoding that the buffered send
// path applies, so the same `path` produced different bytes in the request
// line depending on which API was used. Assert the two agree.
Server svr;
std::string target;
svr.Get(".*", [&](const Request &req, Response &res) {
target = req.target;
res.status = StatusCode::OK_200;
});
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();
struct {
const char *path;
const char *expected;
} cases[] = {
{"/a b?x=1", "/a%20b?x=1"},
{"/\xE6\x97\xA5\xE6\x9C\xAC", "/%E6%97%A5%E6%9C%AC"},
{"/a,b;c+d", "/a%2Cb%3Bc%2Bd"},
};
for (const auto &c : cases) {
Client cli(HOST, port);
target.clear();
auto res = cli.Get(c.path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(c.expected, target) << "buffered path: " << c.path;
auto buffered_target = target;
target.clear();
auto handle = cli.open_stream("GET", c.path);
EXPECT_TRUE(handle.is_valid())
<< "streaming path: " << c.path << ", " << to_string(handle.error);
EXPECT_EQ(buffered_target, target) << "streaming path: " << c.path;
}
}
TEST(PathUrlEncodeTest, StreamingPreEncodedQueryNotReencoded) {
// The `set_path_encode(false)` contract — transmit the supplied target
// verbatim — must hold for the streaming API too.
Server svr;
const std::string expected_target = "/foo?q=a%20b%2Cc%24d%3Bx&a=%00%FF";
std::string target;
svr.Get("/foo", [&](const Request &req, Response &res) {
target = req.target;
res.status = StatusCode::OK_200;
});
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);
cli.set_path_encode(false);
auto handle = cli.open_stream("GET", expected_target);
EXPECT_TRUE(handle.is_valid()) << to_string(handle.error);
EXPECT_EQ(expected_target, target);
}
}
TEST(PathUrlEncodeTest, StreamingCRLFInTargetIsEncoded) {
// With path encoding enabled a CR/LF in the target is percent-encoded
// rather than rejected, matching the buffered send path. The CR/LF guard in
// write_request_line still backstops `set_path_encode(false)`, which is
// covered by StreamingCRLFRejectedWhenPathEncodeDisabled.
Server svr;
// Captured before routing: the decoded path contains a newline, which a
// `.*` handler pattern would not match (`.` excludes `\n` in std::regex).
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 handle = cli.open_stream("GET", "/a\r\nX-Injected: 1");
EXPECT_TRUE(handle.is_valid()) << to_string(handle.error);
EXPECT_EQ("/a%0D%0AX-Injected:%201", target);
}
}
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.
Server svr;
// Pre-routing so that "not called" means nothing reached the server at all,
// rather than merely failing to match a handler pattern.
auto handler_called = false;
svr.set_pre_routing_handler([&](const Request & /*req*/, Response &res) {
handler_called = true;
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);
cli.set_path_encode(false);
auto handle = cli.open_stream("GET", "/a\r\nX-Injected: 1");
EXPECT_FALSE(handle.is_valid());
EXPECT_EQ(Error::Write, handle.error);
EXPECT_FALSE(handler_called);
}
}
TEST(PathUrlEncodeTest, RequestParamsBranchSelection) {
// Which of the two branches runs is decided by whether the query component
// is empty, not by whether `req.path` contains a `?`: a trailing `?` yields
// an empty query and must still fall back to building one from
// `req.params`, while a non-empty query must win over `req.params`.
Server svr;
std::string target;
svr.Get("/foo", [&](const Request &req, Response &res) {
target = req.target;
res.status = StatusCode::OK_200;
});
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();
{
// Trailing `?` -> empty query component -> `req.params` is used.
Client cli(HOST, port);
Request req;
req.method = "GET";
req.path = "/foo?";
req.params.emplace("a", "1");
target.clear();
auto res = cli.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("/foo?a=1", target);
}
{
// Non-empty query in `req.path` -> `req.params` is not appended.
Client cli(HOST, port);
Request req;
req.method = "GET";
req.path = "/foo?b=2";
req.params.emplace("a", "1");
target.clear();
auto res = cli.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("/foo?b=2", target);
}
}
TEST(PathUrlEncodeTest, IncludePercentEncodingLF) {
Server svr;
svr.Get("/", [](const Request &req, Response &) {
EXPECT_EQ("\x0A", req.get_param_value("something"));
});
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();
{
Client cli(HOST, PORT);
cli.set_path_encode(false);
auto res = cli.Get("/?something=%0A");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
}
TEST(BindServerTest, BindDualStack) {
Server svr;
svr.Get("/1", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
});
auto thread = std::thread([&]() { svr.listen("::", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli("127.0.0.1", PORT);
auto res = cli.Get("/1");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!", res->body);
}
{
Client cli("::1", PORT);
auto res = cli.Get("/1");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!", res->body);
}
}
TEST(BindServerTest, BindAndListenSeparately) {
Server svr;
int port = svr.bind_to_any_port("0.0.0.0");
ASSERT_TRUE(svr.is_valid());
ASSERT_TRUE(port > 0);
svr.stop();
}
// Reports whether anything is still listening on a loopback port. A plain
// connect() is used instead of a Client request because a socket left bound by
// mistake accepts the connection into its backlog and never answers, which
// would hang the test instead of failing it.
static bool is_loopback_port_accepting(int port) {
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
socket_t sock = ::socket(AF_INET, SOCK_STREAM, 0);
EXPECT_NE(sock, INVALID_SOCKET);
if (sock == INVALID_SOCKET) { return false; }
auto ret = ::connect(sock, reinterpret_cast<sockaddr *>(&addr),
static_cast<socklen_t>(sizeof(addr)));
detail::close_socket(sock);
return ret == 0;
}
TEST(BindServerTest, StopClosesBoundSocketWithoutListen) {
Server svr;
auto port = svr.bind_to_any_port("127.0.0.1");
ASSERT_TRUE(port > 0);
svr.stop();
// bind_to_any_port() already called listen(2), so until stop() closed the
// descriptor the port kept accepting connections into the backlog. ASSERT
// rather than EXPECT: with the socket still open, the listen_after_bind()
// below blocks forever in the accept loop.
ASSERT_FALSE(is_loopback_port_accepting(port));
// Nothing is left to accept on, so listen_after_bind() must report failure
// instead of returning success without ever serving.
EXPECT_FALSE(svr.listen_after_bind());
// The failed listen marks the server decommissioned, so a waiter returns
// instead of spinning forever.
svr.wait_until_ready();
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(BindServerTest, BindAndListenSeparatelySSL) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE,
CLIENT_CA_CERT_DIR);
int port = svr.bind_to_any_port("0.0.0.0");
ASSERT_TRUE(svr.is_valid());
ASSERT_TRUE(port > 0);
svr.stop();
}
// SSLServer::is_valid() overrides the base version, so it has to chain to it
// or a rejected CustomRoute() registration would not stop the server binding.
TEST(BindServerTest, SSLServerIsInvalidAfterRejectedCustomRoute) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE,
CLIENT_CA_CERT_DIR);
ASSERT_TRUE(svr.is_valid());
svr.CustomRoute("GET", "/x", [](const Request &, Response &) {});
EXPECT_FALSE(svr.is_valid());
EXPECT_TRUE(svr.bind_to_any_port("0.0.0.0") < 0);
}
TEST(BindServerTest, BindAndListenSeparatelySSLEncryptedKey) {
SSLServer svr(SERVER_ENCRYPTED_CERT_FILE, SERVER_ENCRYPTED_PRIVATE_KEY_FILE,
nullptr, nullptr, SERVER_ENCRYPTED_PRIVATE_KEY_PASS);
int port = svr.bind_to_any_port("0.0.0.0");
ASSERT_TRUE(svr.is_valid());
ASSERT_TRUE(port > 0);
svr.stop();
}
TEST(BindServerTest, UpdateCertsPem) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
int port = svr.bind_to_any_port("0.0.0.0");
ASSERT_TRUE(svr.is_valid());
ASSERT_TRUE(port > 0);
// Read PEM files
std::string cert_pem, key_pem, ca_pem;
read_file(SERVER_CERT_FILE, cert_pem);
read_file(SERVER_PRIVATE_KEY_FILE, key_pem);
read_file(CLIENT_CA_CERT_FILE, ca_pem);
// Update server certificates using PEM API
ASSERT_TRUE(
svr.update_certs_pem(cert_pem.c_str(), key_pem.c_str(), ca_pem.c_str()));
ASSERT_TRUE(svr.is_valid());
svr.stop();
}
TEST(SSLClientServerTest, UpdateCertsPemWithClientAuth) {
// Start server with client CA (enables client auth)
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
bool handler_called = false;
svr.Get("/test", [&](const Request &req, Response &res) {
handler_called = true;
// Verify client certificate is present
auto cert = req.peer_cert();
EXPECT_TRUE(static_cast<bool>(cert));
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Read PEM files
std::string cert_pem, key_pem, ca_pem;
read_file(SERVER_CERT_FILE, cert_pem);
read_file(SERVER_PRIVATE_KEY_FILE, key_pem);
read_file(CLIENT_CA_CERT_FILE, ca_pem);
// Update server certificates and client CA using PEM API while server running
ASSERT_TRUE(
svr.update_certs_pem(cert_pem.c_str(), key_pem.c_str(), ca_pem.c_str()));
// Connect with client certificate
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_TRUE(handler_called);
EXPECT_EQ("ok", res->body);
}
#endif
TEST(ErrorHandlerTest, ContentLength) {
Server svr;
svr.set_error_handler([](const Request & /*req*/, Response &res) {
res.status = StatusCode::OK_200;
res.set_content("abcdefghijklmnopqrstuvwxyz",
"text/html"); // <= Content-Length still 13
});
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("Hello World!\n", "text/plain");
res.status = 524;
});
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();
{
Client cli(HOST, PORT);
auto res = cli.Get("/hi", Headers{{"Accept-Encoding", ""}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("26", res->get_header_value("Content-Length"));
EXPECT_EQ("abcdefghijklmnopqrstuvwxyz", res->body);
}
}
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
TEST(ExceptionTest, WithoutExceptionHandler) {
Server svr;
svr.Get("/exception", [&](const Request & /*req*/, Response & /*res*/) {
throw std::runtime_error("exception...");
});
svr.Get("/unknown", [&](const Request & /*req*/, Response & /*res*/) {
throw std::runtime_error("exception\r\n...");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
{
auto res = cli.Get("/exception");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::InternalServerError_500, res->status);
EXPECT_FALSE(res->has_header("EXCEPTION_WHAT"));
}
{
auto res = cli.Get("/unknown");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::InternalServerError_500, res->status);
EXPECT_FALSE(res->has_header("EXCEPTION_WHAT"));
}
}
TEST(ExceptionTest, WithExceptionHandler) {
Server svr;
svr.set_exception_handler([](const Request & /*req*/, Response &res,
std::exception_ptr ep) {
EXPECT_FALSE(ep == nullptr);
try {
std::rethrow_exception(ep);
} catch (std::exception &e) {
EXPECT_EQ("abc", std::string(e.what()));
} catch (...) {}
res.status = StatusCode::InternalServerError_500;
res.set_content("abcdefghijklmnopqrstuvwxyz",
"text/html"); // <= Content-Length still 13 at this point
});
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("Hello World!\n", "text/plain");
throw std::runtime_error("abc");
});
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();
for (size_t i = 0; i < 10; i++) {
Client cli(HOST, PORT);
for (size_t j = 0; j < 100; j++) {
auto res = cli.Get("/hi", Headers{{"Accept-Encoding", ""}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::InternalServerError_500, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("26", res->get_header_value("Content-Length"));
EXPECT_EQ("abcdefghijklmnopqrstuvwxyz", res->body);
}
cli.set_keep_alive(true);
for (size_t j = 0; j < 100; j++) {
auto res = cli.Get("/hi", Headers{{"Accept-Encoding", ""}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::InternalServerError_500, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("26", res->get_header_value("Content-Length"));
EXPECT_EQ("abcdefghijklmnopqrstuvwxyz", res->body);
}
}
}
TEST(ExceptionTest, AndErrorHandler) {
Server svr;
svr.set_error_handler([](const Request & /*req*/, Response &res) {
if (res.body.empty()) { res.set_content("NOT_FOUND", "text/html"); }
});
svr.set_exception_handler(
[](const Request & /*req*/, Response &res, std::exception_ptr ep) {
EXPECT_FALSE(ep == nullptr);
try {
std::rethrow_exception(ep);
} catch (std::exception &e) {
res.set_content(e.what(), "text/html");
} catch (...) {}
res.status = StatusCode::InternalServerError_500;
});
svr.Get("/exception", [](const Request & /*req*/, Response & /*res*/) {
throw std::runtime_error("EXCEPTION");
});
svr.Get("/error", [](const Request & /*req*/, Response &res) {
res.set_content("ERROR", "text/html");
res.status = StatusCode::InternalServerError_500;
});
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();
Client cli(HOST, PORT);
{
auto res = cli.Get("/exception");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("EXCEPTION", res->body);
}
{
auto res = cli.Get("/error");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::InternalServerError_500, res->status);
EXPECT_EQ("ERROR", res->body);
}
{
auto res = cli.Get("/invalid");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
EXPECT_EQ("NOT_FOUND", res->body);
}
}
#endif
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
// process_request() wraps only routing() in a try/catch, so an exception from
// any other user callback used to unwind into the task queue - which does not
// catch - and terminate the process. Each test below runs the server on a
// single worker thread: a guard that catches the exception but still loses the
// thread would otherwise be hidden by a spare worker serving the follow-up
// request. Note that a regression here aborts the whole test binary rather
// than failing one test.
TEST(ServerExceptionTest, ThrowingContentProviderDoesNotKillTheServer) {
Server svr;
svr.new_task_queue = [] { return new ThreadPool(1); };
std::atomic<int> reported{0};
svr.set_error_logger([&](const Error &err, const Request * /*req*/) {
if (err == Error::UserCallbackException) { reported++; }
});
svr.Get("/throw", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
1024, "text/plain",
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) -> bool {
sink.write("hello", 5);
throw std::runtime_error("from the content provider");
});
});
svr.Get("/ok", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli(HOST, port);
cli.set_read_timeout(5, 0);
EXPECT_FALSE(cli.Get("/throw"));
}
EXPECT_TRUE(svr.is_running());
EXPECT_EQ(1, reported.load());
// A request on a fresh connection is still served.
{
Client cli(HOST, port);
auto res = cli.Get("/ok");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
}
}
TEST(ServerExceptionTest, ThrowingPostRoutingHandlerDoesNotKillTheServer) {
Server svr;
svr.new_task_queue = [] { return new ThreadPool(1); };
std::atomic<bool> should_throw{true};
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("hi", "text/plain");
});
svr.set_post_routing_handler(
[&](const Request & /*req*/, Response & /*res*/) {
if (should_throw) {
throw std::runtime_error("from the post-routing handler");
}
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli(HOST, port);
cli.set_read_timeout(5, 0);
EXPECT_FALSE(cli.Get("/hi"));
}
EXPECT_TRUE(svr.is_running());
should_throw = false;
{
Client cli(HOST, port);
auto res = cli.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("hi", res->body);
}
}
TEST(ServerExceptionTest, ThrowingErrorLoggerDoesNotReopenTheGuard) {
// The guard reports the caught exception through the error logger, which is
// a user callback too. A logger that throws has to be contained as well, or
// the process would terminate from inside the catch.
Server svr;
svr.new_task_queue = [] { return new ThreadPool(1); };
std::atomic<int> reported{0};
svr.set_error_logger([&](const Error &err, const Request * /*req*/) {
if (err == Error::UserCallbackException) {
reported++;
throw std::runtime_error("from the error logger");
}
});
svr.Get("/throw", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
1024, "text/plain",
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) -> bool {
sink.write("hello", 5);
throw std::runtime_error("from the content provider");
});
});
svr.Get("/ok", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli(HOST, port);
cli.set_read_timeout(5, 0);
EXPECT_FALSE(cli.Get("/throw"));
}
EXPECT_TRUE(svr.is_running());
EXPECT_EQ(1, reported.load());
{
Client cli(HOST, port);
auto res = cli.Get("/ok");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
}
}
TEST(ServerExceptionTest, ThrowingWebSocketHandlerDoesNotKillTheServer) {
// A WebSocket handler runs on the upgrade path after the 101 has been sent,
// so the exception unwinds through the ws::WebSocket object on its way to
// the guard. None of the HTTP cases above cross that.
Server svr;
svr.new_task_queue = [] { return new ThreadPool(1); };
std::atomic<int> reported{0};
svr.set_error_logger([&](const Error &err, const Request * /*req*/) {
if (err == Error::UserCallbackException) { reported++; }
});
svr.WebSocket("/ws", [](const Request & /*req*/, ws::WebSocket & /*ws*/) {
throw std::runtime_error("from the WebSocket handler");
});
svr.Get("/ok", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) +
"/ws");
auto res = client.connect();
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
// The server dropped the connection instead of dying.
std::string msg;
EXPECT_FALSE(client.read(msg));
client.close();
}
EXPECT_TRUE(svr.is_running());
EXPECT_EQ(1, reported.load());
{
Client cli(HOST, port);
auto res = cli.Get("/ok");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
}
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(ServerExceptionTest, ThrowingContentProviderDoesNotKillTheSSLServer) {
// SSLServer::process_and_close_socket() is a separate overload with its own
// guard, so it is exercised on its own.
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.new_task_queue = [] { return new ThreadPool(1); };
std::atomic<int> reported{0};
svr.set_error_logger([&](const Error &err, const Request * /*req*/) {
if (err == Error::UserCallbackException) { reported++; }
});
svr.Get("/throw", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
1024, "text/plain",
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) -> bool {
sink.write("hello", 5);
throw std::runtime_error("from the content provider");
});
});
svr.Get("/ok", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(false);
cli.set_read_timeout(5, 0);
EXPECT_FALSE(cli.Get("/throw"));
}
EXPECT_TRUE(svr.is_running());
EXPECT_EQ(1, reported.load());
{
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(false);
auto res = cli.Get("/ok");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
}
}
#endif
#endif
TEST(NoContentTest, ContentLength) {
Server svr;
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.status = StatusCode::NoContent_204;
});
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();
{
Client cli(HOST, PORT);
auto res = cli.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NoContent_204, res->status);
EXPECT_EQ("0", res->get_header_value("Content-Length"));
}
}
TEST(RoutingHandlerTest, PreAndPostRoutingHandlers) {
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
#else
Server svr;
#endif
svr.set_pre_routing_handler([](const Request &req, Response &res) {
if (req.path == "/routing_handler") {
res.set_header("PRE_ROUTING", "on");
res.set_content("Routing Handler", "text/plain");
return httplib::Server::HandlerResponse::Handled;
}
return httplib::Server::HandlerResponse::Unhandled;
});
svr.set_error_handler([](const Request & /*req*/, Response &res) {
res.set_content("Error", "text/html");
});
svr.set_post_routing_handler([](const Request &req, Response &res) {
if (req.path == "/routing_handler") {
res.set_header("POST_ROUTING", "on");
}
});
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("Hello World!\n", "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();
{
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
#else
Client cli(HOST, PORT);
#endif
auto res = cli.Get("/routing_handler");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Routing Handler", res->body);
EXPECT_EQ(1U, res->get_header_value_count("PRE_ROUTING"));
EXPECT_EQ("on", res->get_header_value("PRE_ROUTING"));
EXPECT_EQ(1U, res->get_header_value_count("POST_ROUTING"));
EXPECT_EQ("on", res->get_header_value("POST_ROUTING"));
}
{
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
#else
Client cli(HOST, PORT);
#endif
auto res = cli.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!\n", res->body);
EXPECT_EQ(0U, res->get_header_value_count("PRE_ROUTING"));
EXPECT_EQ(0U, res->get_header_value_count("POST_ROUTING"));
}
{
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
#else
Client cli(HOST, PORT);
#endif
auto res = cli.Get("/aaa");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
EXPECT_EQ("Error", res->body);
EXPECT_EQ(0U, res->get_header_value_count("PRE_ROUTING"));
EXPECT_EQ(0U, res->get_header_value_count("POST_ROUTING"));
}
}
TEST(RequestHandlerTest, PreRequestHandler) {
auto route_path = "/user/:user";
Server svr;
svr.Get("/hi", [](const Request &, Response &res) {
res.set_content("hi", "text/plain");
});
svr.Get(route_path, [](const Request &req, Response &res) {
res.set_content(req.path_params.at("user"), "text/plain");
});
svr.set_pre_request_handler([&](const Request &req, Response &res) {
if (req.matched_route == route_path) {
auto user = req.path_params.at("user");
if (user != "john") {
res.status = StatusCode::Forbidden_403;
res.set_content("error", "text/html");
return Server::HandlerResponse::Handled;
}
}
return Server::HandlerResponse::Unhandled;
});
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();
Client cli(HOST, PORT);
{
auto res = cli.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("hi", res->body);
}
{
auto res = cli.Get("/user/john");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("john", res->body);
}
{
auto res = cli.Get("/user/invalid-user");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Forbidden_403, res->status);
EXPECT_EQ("error", res->body);
}
}
// The pre-request handler must run before the request body is read, so a
// rejected request never forces the server to buffer a (potentially large)
// body. Here the posted body is larger than the payload limit: if the body
// were read first the server would answer 413, but because the pre-request
// handler runs first it answers 403 and the body is never read.
TEST(RequestHandlerTest, PreRequestHandlerRunsBeforeBodyIsRead) {
Server svr;
svr.set_payload_max_length(8);
auto handler_ran = false;
svr.Post("/reject", [&](const Request &req, Response &res) {
handler_ran = true;
res.set_content(req.body, "text/plain");
});
svr.Post("/accept", [](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
svr.set_pre_request_handler([](const Request &req, Response &res) {
if (req.matched_route == "/reject") {
res.status = StatusCode::Forbidden_403;
res.set_content("denied", "text/plain");
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
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();
Client cli(HOST, PORT);
// Body (10 bytes) exceeds the 8-byte limit, yet the pre-request handler
// rejects with 403 before the body is read, so 413 is never reached.
{
auto res = cli.Post("/reject", "0123456789", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Forbidden_403, res->status);
EXPECT_EQ("denied", res->body);
EXPECT_FALSE(handler_ran);
}
// An approved route still reads the body and enforces the payload limit.
{
auto res = cli.Post("/accept", "0123456789", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
}
}
TEST(UserDataTest, BasicOperations) {
httplib::UserData ud;
// Initially empty
EXPECT_FALSE(ud.has("key"));
EXPECT_EQ(nullptr, ud.get<int>("key"));
// set and get
ud.set("key", 42);
EXPECT_TRUE(ud.has("key"));
auto *p = ud.get<int>("key");
ASSERT_NE(nullptr, p);
EXPECT_EQ(42, *p);
// Type mismatch → nullptr
EXPECT_EQ(nullptr, ud.get<std::string>("key"));
// Overwrite with different type
ud.set("key", std::string("hello"));
EXPECT_EQ(nullptr, ud.get<int>("key"));
auto *s = ud.get<std::string>("key");
ASSERT_NE(nullptr, s);
EXPECT_EQ("hello", *s);
// erase
ud.erase("key");
EXPECT_FALSE(ud.has("key"));
// clear
ud.set("a", 1);
ud.set("b", 2);
ud.clear();
EXPECT_FALSE(ud.has("a"));
EXPECT_FALSE(ud.has("b"));
}
TEST(RequestHandlerTest, ResponseUserDataInPreRouting) {
struct AuthCtx {
std::string user_id;
};
Server svr;
svr.set_pre_routing_handler([](const Request & /*req*/, Response &res) {
res.user_data.set("auth", AuthCtx{"alice"});
return Server::HandlerResponse::Unhandled;
});
svr.Get("/me", [](const Request & /*req*/, Response &res) {
auto *ctx = res.user_data.get<AuthCtx>("auth");
ASSERT_NE(nullptr, ctx);
res.set_content("Hello " + ctx->user_id, "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();
Client cli(HOST, PORT);
auto res = cli.Get("/me");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello alice", res->body);
}
TEST(RequestHandlerTest, ResponseUserDataInPreRequest) {
struct RoleCtx {
std::string role;
};
Server svr;
svr.set_pre_request_handler([](const Request & /*req*/, Response &res) {
res.user_data.set("role", RoleCtx{"admin"});
return Server::HandlerResponse::Unhandled;
});
svr.Get("/role", [](const Request & /*req*/, Response &res) {
auto *ctx = res.user_data.get<RoleCtx>("role");
ASSERT_NE(nullptr, ctx);
res.set_content(ctx->role, "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();
Client cli(HOST, PORT);
auto res = cli.Get("/role");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("admin", res->body);
}
TEST(InvalidFormatTest, StatusCode) {
Server svr;
svr.Get("/hi", [](const Request & /*req*/, Response &res) {
res.set_content("Hello World!\n", "text/plain");
res.status = 9999; // Status should be a three-digit code...
});
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();
{
Client cli(HOST, PORT);
auto res = cli.Get("/hi");
ASSERT_FALSE(res);
}
}
TEST(URLFragmentTest, WithFragment) {
Server svr;
svr.Get("/hi", [](const Request &req, Response & /*res*/) {
EXPECT_TRUE(req.target == "/hi");
});
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();
{
Client cli(HOST, PORT);
auto res = cli.Get("/hi#key1=val1=key2=val2");
EXPECT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
res = cli.Get("/hi%23key1=val1=key2=val2");
EXPECT_TRUE(res);
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
}
TEST(HeaderWriter, SetHeaderWriter) {
Server svr;
svr.set_header_writer([](Stream &strm, Headers &hdrs) {
hdrs.emplace("CustomServerHeader", "CustomServerValue");
return detail::write_headers(strm, hdrs);
});
svr.Get("/hi", [](const Request &req, Response &res) {
auto it = req.headers.find("CustomClientHeader");
EXPECT_TRUE(it != req.headers.end());
EXPECT_EQ(it->second, "CustomClientValue");
res.set_content("Hello World!\n", "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();
{
Client cli(HOST, PORT);
cli.set_header_writer([](Stream &strm, Headers &hdrs) {
hdrs.emplace("CustomClientHeader", "CustomClientValue");
return detail::write_headers(strm, hdrs);
});
auto res = cli.Get("/hi");
EXPECT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
auto it = res->headers.find("CustomServerHeader");
EXPECT_TRUE(it != res->headers.end());
EXPECT_EQ(it->second, "CustomServerValue");
}
}
class ServerTest : public ::testing::Test {
protected:
ServerTest()
: cli_(HOST, PORT)
#ifdef CPPHTTPLIB_SSL_ENABLED
,
svr_(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE)
#endif
{
#ifdef CPPHTTPLIB_SSL_ENABLED
cli_.enable_server_certificate_verification(false);
#endif
// Allow LARGE_DATA (100MB) responses
cli_.set_payload_max_length(200 * 1024 * 1024);
}
virtual void SetUp() {
// Allow LARGE_DATA (100MB) tests to pass with new 100MB default limit
svr_.set_payload_max_length(200 * 1024 * 1024);
svr_.set_mount_point("/", "./www");
svr_.set_mount_point("/mount", "./www2");
svr_.set_file_extension_and_mimetype_mapping("abcde", "text/abcde");
svr_.Get("/hi",
[&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
})
.Get("/file_content",
[&](const Request & /*req*/, Response &res) {
res.set_file_content("./www/dir/test.html");
})
.Get("/file_content_with_content_type",
[&](const Request & /*req*/, Response &res) {
res.set_file_content("./www/file", "text/plain");
})
.Get("/invalid_file_content",
[&](const Request & /*req*/, Response &res) {
res.set_file_content("./www/dir/invalid_file_path");
})
.Get("/http_response_splitting",
[&](const Request & /*req*/, Response &res) {
res.set_header("a", "1\r\nSet-Cookie: a=1");
EXPECT_EQ(0U, res.headers.size());
EXPECT_FALSE(res.has_header("a"));
res.set_header("a", "1\nSet-Cookie: a=1");
EXPECT_EQ(0U, res.headers.size());
EXPECT_FALSE(res.has_header("a"));
res.set_header("a", "1\rSet-Cookie: a=1");
EXPECT_EQ(0U, res.headers.size());
EXPECT_FALSE(res.has_header("a"));
res.set_header("a\r\nb", "0");
EXPECT_EQ(0U, res.headers.size());
EXPECT_FALSE(res.has_header("a"));
res.set_header("a\rb", "0");
EXPECT_EQ(0U, res.headers.size());
EXPECT_FALSE(res.has_header("a"));
res.set_header("a\nb", "0");
EXPECT_EQ(0U, res.headers.size());
EXPECT_FALSE(res.has_header("a"));
res.set_redirect("1\r\nSet-Cookie: a=1");
EXPECT_EQ(0U, res.headers.size());
EXPECT_FALSE(res.has_header("Location"));
})
.Get("/slow",
[&](const Request & /*req*/, Response &res) {
std::this_thread::sleep_for(std::chrono::seconds(4));
res.set_content("slow", "text/plain");
})
#if 0
.Post("/slowpost",
[&](const Request & /*req*/, Response &res) {
std::this_thread::sleep_for(std::chrono::seconds(2));
res.set_content("slow", "text/plain");
})
#endif
.Get("/remote_addr",
[&](const Request &req, Response &res) {
ASSERT_FALSE(req.has_header("REMOTE_ADDR"));
ASSERT_FALSE(req.has_header("REMOTE_PORT"));
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
ASSERT_ANY_THROW(req.get_header_value("REMOTE_ADDR"));
ASSERT_ANY_THROW(req.get_header_value("REMOTE_PORT"));
#endif
res.set_content(req.remote_addr, "text/plain");
})
.Get("/local_addr",
[&](const Request &req, Response &res) {
ASSERT_FALSE(req.has_header("LOCAL_ADDR"));
ASSERT_FALSE(req.has_header("LOCAL_PORT"));
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
ASSERT_ANY_THROW(req.get_header_value("LOCAL_ADDR"));
ASSERT_ANY_THROW(req.get_header_value("LOCAL_PORT"));
#endif
auto local_addr = req.local_addr;
auto local_port = std::to_string(req.local_port);
res.set_content(local_addr.append(":").append(local_port),
"text/plain");
})
.Get("/endwith%",
[&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
})
.Get("/a\\+\\+b",
[&](const Request &req, Response &res) {
ASSERT_TRUE(req.has_param("a +b"));
auto val = req.get_param_value("a +b");
res.set_content(val, "text/plain");
})
.Get("/", [&](const Request & /*req*/,
Response &res) { res.set_redirect("/hi"); })
.Post("/1",
[](const Request & /*req*/, Response &res) {
res.set_redirect("/2", StatusCode::SeeOther_303);
})
.Get("/2",
[](const Request & /*req*/, Response &res) {
res.set_content("redirected.", "text/plain");
res.status = StatusCode::OK_200;
})
.Post("/person",
[&](const Request &req, Response &res) {
if (req.has_param("name") && req.has_param("note")) {
persons_[req.get_param_value("name")] =
req.get_param_value("note");
} else {
res.status = StatusCode::BadRequest_400;
}
})
.Put("/person",
[&](const Request &req, Response &res) {
if (req.has_param("name") && req.has_param("note")) {
persons_[req.get_param_value("name")] =
req.get_param_value("note");
} else {
res.status = StatusCode::BadRequest_400;
}
})
.Get("/person/(.*)",
[&](const Request &req, Response &res) {
string name = req.matches[1];
if (persons_.find(name) != persons_.end()) {
auto note = persons_[name];
res.set_content(note, "text/plain");
} else {
res.status = StatusCode::NotFound_404;
}
})
.Delete("/person",
[&](const Request &req, Response &res) {
if (req.has_param("name")) {
string name = req.get_param_value("name");
if (persons_.find(name) != persons_.end()) {
persons_.erase(name);
res.set_content("DELETED", "text/plain");
} else {
res.status = StatusCode::NotFound_404;
}
} else {
res.status = StatusCode::BadRequest_400;
}
})
.Post("/x-www-form-urlencoded-json",
[&](const Request &req, Response &res) {
auto json = req.get_param_value("json");
ASSERT_EQ(JSON_DATA, json);
res.set_content(json, "appliation/json");
res.status = StatusCode::OK_200;
})
.Get("/streamed-chunked",
[&](const Request & /*req*/, Response &res) {
res.set_chunked_content_provider(
"text/plain", [](size_t /*offset*/, DataSink &sink) {
sink.os << "123";
sink.os << "456";
sink.os << "789";
sink.done();
return true;
});
})
.Get("/streamed-chunked-with-prohibited-trailer",
[&](const Request & /*req*/, Response &res) {
auto i = new int(0);
// Declare both a prohibited trailer (Content-Length) and an
// allowed one
res.set_header("Trailer", "Content-Length, X-Allowed");
res.set_chunked_content_provider(
"text/plain",
[i](size_t /*offset*/, DataSink &sink) {
switch (*i) {
case 0: sink.os << "123"; break;
case 1: sink.os << "456"; break;
case 2: sink.os << "789"; break;
case 3: {
sink.done_with_trailer(
{{"Content-Length", "5"}, {"X-Allowed", "yes"}});
} break;
}
(*i)++;
return true;
},
[i](bool success) {
EXPECT_TRUE(success);
delete i;
});
})
.Get("/streamed-chunked2",
[&](const Request & /*req*/, Response &res) {
auto i = new int(0);
res.set_chunked_content_provider(
"text/plain",
[i](size_t /*offset*/, DataSink &sink) {
switch (*i) {
case 0: sink.os << "123"; break;
case 1: sink.os << "456"; break;
case 2: sink.os << "789"; break;
case 3: sink.done(); break;
}
(*i)++;
return true;
},
[i](bool success) {
EXPECT_TRUE(success);
delete i;
});
})
.Get("/streamed-chunked-with-trailer",
[&](const Request & /*req*/, Response &res) {
auto i = new int(0);
res.set_header("Trailer", "Dummy1, Dummy2");
res.set_chunked_content_provider(
"text/plain",
[i](size_t /*offset*/, DataSink &sink) {
switch (*i) {
case 0: sink.os << "123"; break;
case 1: sink.os << "456"; break;
case 2: sink.os << "789"; break;
case 3: {
sink.done_with_trailer(
{{"Dummy1", "DummyVal1"}, {"Dummy2", "DummyVal2"}});
} break;
}
(*i)++;
return true;
},
[i](bool success) {
EXPECT_TRUE(success);
delete i;
});
})
.Get("/streamed",
[&](const Request & /*req*/, Response &res) {
res.set_content_provider(
6, "text/plain",
[](size_t offset, size_t /*length*/, DataSink &sink) {
sink.os << (offset < 3 ? "a" : "b");
return true;
});
})
.Get("/streamed-without-length",
[&](const Request & /*req*/, Response &res) {
auto data = new std::string("abcdefg");
res.set_content_provider(
"text/plain",
[data](size_t offset, DataSink &sink) {
if (offset < data->size()) {
sink.os << data->substr(offset);
}
sink.done();
return true;
},
[data](bool success) {
EXPECT_TRUE(success);
delete data;
});
})
.Get("/streamed-with-range",
[&](const Request &req, Response &res) {
auto data = new std::string("abcdefg");
// An explicit status keeps the server from picking the 206 it
// would otherwise choose for a ranged request, so the response
// is not a partial representation.
auto status = req.get_param_value("status");
if (status == "200") {
res.status = StatusCode::OK_200;
} else if (status == "403") {
res.status = StatusCode::Forbidden_403;
}
res.set_content_provider(
data->size(), "text/plain",
[data](size_t offset, size_t length, DataSink &sink) {
size_t DATA_CHUNK_SIZE = 4;
const auto &d = *data;
auto out_len =
std::min(static_cast<size_t>(length), DATA_CHUNK_SIZE);
auto ret =
sink.write(&d[static_cast<size_t>(offset)], out_len);
EXPECT_TRUE(ret);
return true;
},
[data, &req](bool success) {
EXPECT_EQ(success, !req.has_param("error"));
delete data;
});
})
.Get("/streamed-cancel",
[&](const Request & /*req*/, Response &res) {
res.set_content_provider(
size_t(-1), "text/plain",
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.os << "data_chunk";
return true;
});
})
.Get("/regex-with-delimiter",
[&](const Request &req, Response & /*res*/) {
ASSERT_TRUE(req.has_param("key"));
EXPECT_EQ("^(?.*(value))", req.get_param_value("key"));
})
.Get("/with-range",
[&](const Request & /*req*/, Response &res) {
res.set_content("abcdefg", "text/plain");
})
.Get("/test-start-time",
[&](const Request &req, Response & /*res*/) {
EXPECT_NE(req.start_time_,
std::chrono::steady_clock::time_point::min());
})
.Get("/with-range-customized-response",
[&](const Request & /*req*/, Response &res) {
res.status = StatusCode::BadRequest_400;
res.set_content(JSON_DATA, "application/json");
})
.Post("/chunked",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(req.body, "dechunked post body");
})
.Post("/large-chunked",
[&](const Request &req, Response & /*res*/) {
std::string expected(6 * 30 * 1024u, 'a');
EXPECT_EQ(req.body, expected);
})
.Post("/multipart",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(4u, req.form.get_field_count("text1") +
req.form.get_field_count("text2") +
req.form.get_field_count("file3") +
req.form.get_field_count("file4"));
EXPECT_EQ(2u, req.form.get_file_count("file1") +
req.form.get_file_count("file2"));
ASSERT_TRUE(!req.form.has_file("???"));
ASSERT_TRUE(!req.form.has_field("???"));
ASSERT_TRUE(req.body.empty());
{
const auto &text = req.form.get_field("text1");
EXPECT_EQ("text default", text);
}
{
const auto &text = req.form.get_field("text2");
EXPECT_EQ("aωb", text);
}
{
const auto &file = req.form.get_file("file1");
EXPECT_EQ("hello.txt", file.filename);
EXPECT_EQ("text/plain", file.content_type);
EXPECT_EQ("h\ne\n\nl\nl\no\n", file.content);
}
{
const auto &file = req.form.get_file("file2");
EXPECT_EQ("world.json", file.filename);
EXPECT_EQ("application/json", file.content_type);
EXPECT_EQ("{\n \"world\", true\n}\n", file.content);
}
{
const auto &text = req.form.get_field("file3");
EXPECT_EQ(0u, text.size());
}
{
const auto &text = req.form.get_field("file4");
EXPECT_EQ(0u, text.size());
}
})
.Post("/multipart/multi_file_values",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(3u, req.form.get_field_count("text") +
req.form.get_field_count("multi_text1"));
EXPECT_EQ(2u, req.form.get_file_count("multi_file1"));
ASSERT_TRUE(!req.form.has_file("???"));
ASSERT_TRUE(!req.form.has_field("???"));
ASSERT_TRUE(req.body.empty());
{
const auto &text = req.form.get_field("text");
EXPECT_EQ("default text", text);
}
{
const auto &text1_values = req.form.get_fields("multi_text1");
EXPECT_EQ(2u, text1_values.size());
EXPECT_EQ("aaaaa", text1_values[0]);
EXPECT_EQ("bbbbb", text1_values[1]);
}
{
const auto &file1_values = req.form.get_files("multi_file1");
EXPECT_EQ(2u, file1_values.size());
auto file1 = file1_values[0];
EXPECT_EQ(file1.filename, "hello.txt");
EXPECT_EQ(file1.content_type, "text/plain");
EXPECT_EQ("h\ne\n\nl\nl\no\n", file1.content);
auto file2 = file1_values[1];
EXPECT_EQ(file2.filename, "world.json");
EXPECT_EQ(file2.content_type, "application/json");
EXPECT_EQ("{\n \"world\", true\n}\n", file2.content);
}
})
.Post("/empty",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "");
EXPECT_EQ("text/plain", req.get_header_value("Content-Type"));
EXPECT_EQ("0", req.get_header_value("Content-Length"));
res.set_content("empty", "text/plain");
})
.Post("/empty-no-content-type",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "");
EXPECT_FALSE(req.has_header("Content-Type"));
EXPECT_EQ("0", req.get_header_value("Content-Length"));
res.set_content("empty-no-content-type", "text/plain");
})
.Post("/path-only",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "");
EXPECT_EQ("", req.get_header_value("Content-Type"));
EXPECT_EQ("0", req.get_header_value("Content-Length"));
res.set_content("path-only", "text/plain");
})
.Post("/path-headers-only",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "");
EXPECT_EQ("", req.get_header_value("Content-Type"));
EXPECT_EQ("0", req.get_header_value("Content-Length"));
EXPECT_EQ("world", req.get_header_value("hello"));
EXPECT_EQ("world2", req.get_header_value("hello2"));
res.set_content("path-headers-only", "text/plain");
})
.Post("/post-large",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, LARGE_DATA);
res.set_content(req.body, "text/plain");
})
.Post("/post-loopback",
[&](const Request &, Response &res,
ContentReader const &content_reader) {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
res.set_content(body, "text/plain");
})
.Put("/put-loopback",
[&](const Request &, Response &res,
ContentReader const &content_reader) {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
res.set_content(body, "text/plain");
})
.Patch("/patch-loopback",
[&](const Request &, Response &res,
ContentReader const &content_reader) {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
res.set_content(body, "text/plain");
})
.Put("/empty-no-content-type",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "");
EXPECT_FALSE(req.has_header("Content-Type"));
EXPECT_EQ("0", req.get_header_value("Content-Length"));
res.set_content("empty-no-content-type", "text/plain");
})
.Put("/put",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "PUT");
res.set_content(req.body, "text/plain");
})
.Put("/put-large",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, LARGE_DATA);
res.set_content(req.body, "text/plain");
})
.Patch("/patch",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "PATCH");
res.set_content(req.body, "text/plain");
})
.Delete("/delete",
[&](const Request & /*req*/, Response &res) {
res.set_content("DELETE", "text/plain");
})
.Delete("/delete-body",
[&](const Request &req, Response &res) {
EXPECT_EQ(req.body, "content");
res.set_content(req.body, "text/plain");
})
.Options(R"(\*)",
[&](const Request & /*req*/, Response &res) {
res.set_header("Allow", "GET, POST, HEAD, OPTIONS");
})
.CustomRoute("PROPFIND", "/dav/:id",
[&](const Request &req, Response &res) {
res.set_header("x-body-size",
std::to_string(req.body.size()));
res.set_header("x-matched-route", req.matched_route);
res.set_header("x-dav-id", req.path_params.at("id"));
res.status = StatusCode::MultiStatus_207;
res.set_content(req.body, "application/xml");
})
.CustomRoute("PROPFIND", R"(/dav-re/(\d+))",
[&](const Request &req, Response &res) {
res.set_header("x-dav-match", req.matches[1]);
res.status = StatusCode::MultiStatus_207;
})
.CustomRoute("MKCOL", "/dav-mkcol",
[&](const Request &req, Response &res) {
EXPECT_TRUE(req.body.empty());
res.status = StatusCode::Created_201;
})
.CustomRoute(
"REPORT", "/dav-report",
[&](const Request & /*req*/, Response &res,
const ContentReader &content_reader) {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
res.set_header("x-body-size", std::to_string(body.size()));
res.status = StatusCode::MultiStatus_207;
res.set_content(body, "application/xml");
})
.Get("/request-target",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ("/request-target?aaa=bbb&ccc=ddd", req.target);
EXPECT_EQ("bbb", req.get_param_value("aaa"));
EXPECT_EQ("ddd", req.get_param_value("ccc"));
})
.Get("/long-query-value",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(LONG_QUERY_URL, req.target);
EXPECT_EQ(LONG_QUERY_VALUE, req.get_param_value("key"));
})
.Get("/too-long-query-value",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(TOO_LONG_QUERY_URL, req.target);
EXPECT_EQ(TOO_LONG_QUERY_VALUE, req.get_param_value("key"));
})
.Get("/array-param",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(3u, req.get_param_value_count("array"));
EXPECT_EQ("value1", req.get_param_value("array", 0));
EXPECT_EQ("value2", req.get_param_value("array", 1));
EXPECT_EQ("value3", req.get_param_value("array", 2));
})
.Get("/array-param-values",
[&](const Request &req, Response & /*res*/) {
auto values = req.get_param_values("array");
EXPECT_EQ(3u, values.size());
EXPECT_EQ("value1", values[0]);
EXPECT_EQ("value2", values[1]);
EXPECT_EQ("value3", values[2]);
auto empty = req.get_param_values("nonexistent");
EXPECT_TRUE(empty.empty());
})
.Post("/validate-no-multiple-headers",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(1u, req.get_header_value_count("Content-Length"));
EXPECT_EQ("5", req.get_header_value("Content-Length"));
})
.Post("/content_receiver",
[&](const Request &req, Response &res,
const ContentReader &content_reader) {
if (req.is_multipart_form_data()) {
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
EXPECT_EQ(5u, items.size());
{
const auto &file = get_file_value(items, "text1");
EXPECT_TRUE(file.filename.empty());
EXPECT_EQ("text default", file.content);
}
{
const auto &file = get_file_value(items, "text2");
EXPECT_TRUE(file.filename.empty());
EXPECT_EQ("aωb", file.content);
}
{
const auto &file = get_file_value(items, "file1");
EXPECT_EQ("hello.txt", file.filename);
EXPECT_EQ("text/plain", file.content_type);
EXPECT_EQ("h\ne\n\nl\nl\no\n", file.content);
}
{
const auto &file = get_file_value(items, "file2");
EXPECT_EQ("world.json", file.filename);
EXPECT_EQ("application/json", file.content_type);
EXPECT_EQ(R"({\n "world": true\n}\n)", file.content);
}
{
const auto &file = get_file_value(items, "file3");
EXPECT_TRUE(file.filename.empty());
EXPECT_EQ("application/octet-stream", file.content_type);
EXPECT_EQ(0u, file.content.size());
}
} else {
std::string body;
content_reader([&](const char *data, size_t data_length) {
EXPECT_EQ(7U, data_length);
body.append(data, data_length);
return true;
});
EXPECT_EQ(body, "content");
res.set_content(body, "text/plain");
}
})
.Put("/content_receiver",
[&](const Request & /*req*/, Response &res,
const ContentReader &content_reader) {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
EXPECT_EQ(body, "content");
res.set_content(body, "text/plain");
})
.Patch("/content_receiver",
[&](const Request & /*req*/, Response &res,
const ContentReader &content_reader) {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
EXPECT_EQ(body, "content");
res.set_content(body, "text/plain");
})
.Post("/query-string-and-body",
[&](const Request &req, Response & /*res*/) {
ASSERT_TRUE(req.has_param("key"));
EXPECT_EQ(req.get_param_value("key"), "value");
EXPECT_EQ(req.body, "content");
})
.Get("/last-request",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ("close", req.get_header_value("Connection"));
})
.Get(R"(/redirect/(\d+))",
[&](const Request &req, Response &res) {
auto num = std::stoi(req.matches[1]) + 1;
std::string url = "/redirect/" + std::to_string(num);
res.set_redirect(url);
})
.Post("/binary",
[&](const Request &req, Response &res) {
EXPECT_EQ(4U, req.body.size());
EXPECT_EQ("application/octet-stream",
req.get_header_value("Content-Type"));
EXPECT_EQ("4", req.get_header_value("Content-Length"));
res.set_content(req.body, "application/octet-stream");
})
.Put("/binary",
[&](const Request &req, Response &res) {
EXPECT_EQ(4U, req.body.size());
EXPECT_EQ("application/octet-stream",
req.get_header_value("Content-Type"));
EXPECT_EQ("4", req.get_header_value("Content-Length"));
res.set_content(req.body, "application/octet-stream");
})
.Patch("/binary",
[&](const Request &req, Response &res) {
EXPECT_EQ(4U, req.body.size());
EXPECT_EQ("application/octet-stream",
req.get_header_value("Content-Type"));
EXPECT_EQ("4", req.get_header_value("Content-Length"));
res.set_content(req.body, "application/octet-stream");
})
.Delete("/binary",
[&](const Request &req, Response &res) {
EXPECT_EQ(4U, req.body.size());
EXPECT_EQ("application/octet-stream",
req.get_header_value("Content-Type"));
EXPECT_EQ("4", req.get_header_value("Content-Length"));
res.set_content(req.body, "application/octet-stream");
})
.Get("/issue1772",
[&](const Request & /*req*/, Response &res) {
res.status = 401;
res.set_header("WWW-Authenticate", "Basic realm=123456");
})
.Delete("/issue609",
[](const httplib::Request &, httplib::Response &res,
const httplib::ContentReader &) {
res.set_content("ok", "text/plain");
})
#if defined(CPPHTTPLIB_ZLIB_SUPPORT) || defined(CPPHTTPLIB_BROTLI_SUPPORT) || \
defined(CPPHTTPLIB_ZSTD_SUPPORT)
.Get("/compress",
[&](const Request & /*req*/, Response &res) {
res.set_content(
"12345678901234567890123456789012345678901234567890123456789"
"01234567890123456789012345678901234567890",
"text/plain");
})
.Get("/compress-with-charset",
[&](const Request & /*req*/, Response &res) {
res.set_content(
"12345678901234567890123456789012345678901234567890123456789"
"01234567890123456789012345678901234567890",
"application/json; charset=utf-8");
})
.Get("/nocompress",
[&](const Request & /*req*/, Response &res) {
res.set_content(
"12345678901234567890123456789012345678901234567890123456789"
"01234567890123456789012345678901234567890",
"application/octet-stream");
})
.Post("/compress-multipart",
[&](const Request &req, Response & /*res*/) {
EXPECT_EQ(2u, req.form.fields.size());
ASSERT_TRUE(!req.form.has_field("???"));
{
const auto &text = req.form.get_field("key1");
EXPECT_EQ("test", text);
}
{
const auto &text = req.form.get_field("key2");
EXPECT_EQ("--abcdefg123", text);
}
})
#endif
;
persons_["john"] = "programmer";
t_ = thread([&]() { ASSERT_TRUE(svr_.listen(HOST, PORT)); });
svr_.wait_until_ready();
}
virtual void TearDown() {
svr_.stop();
if (!request_threads_.empty()) {
std::this_thread::sleep_for(std::chrono::seconds(1));
for (auto &t : request_threads_) {
t.join();
}
}
t_.join();
}
map<string, string> persons_;
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli_;
SSLServer svr_;
#else
Client cli_;
Server svr_;
#endif
thread t_;
std::vector<thread> request_threads_;
};
TEST_F(ServerTest, GetMethod200) {
auto res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("HTTP/1.1", res->version);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("OK", res->reason);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ(1U, res->get_header_value_count("Content-Type"));
EXPECT_EQ("Hello World!", res->body);
}
TEST_F(ServerTest, GetEmptyFile) {
auto res = cli_.Get("/empty_file");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("application/octet-stream", res->get_header_value("Content-Type"));
EXPECT_EQ(0, std::stoi(res->get_header_value("Content-Length")));
EXPECT_EQ("", res->body);
}
TEST_F(ServerTest, GetFileContent) {
auto res = cli_.Get("/file_content");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ(9, std::stoi(res->get_header_value("Content-Length")));
EXPECT_EQ("test.html", res->body);
}
TEST_F(ServerTest, GetFileContentWithRange) {
auto res = cli_.Get("/file_content", Headers{{make_range_header({{1, 3}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("bytes 1-3/9", res->get_header_value("Content-Range"));
EXPECT_EQ(3, std::stoi(res->get_header_value("Content-Length")));
EXPECT_EQ("est", res->body);
}
TEST_F(ServerTest, GetFileContentWithContentType) {
auto res = cli_.Get("/file_content_with_content_type");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ(5, std::stoi(res->get_header_value("Content-Length")));
EXPECT_EQ("file\n", res->body);
}
TEST_F(ServerTest, GetInvalidFileContent) {
auto res = cli_.Get("/invalid_file_content");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethod200withPercentEncoding) {
auto res = cli_.Get("/%68%69"); // auto res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("HTTP/1.1", res->version);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ(1U, res->get_header_value_count("Content-Type"));
EXPECT_EQ("Hello World!", res->body);
}
TEST_F(ServerTest, GetMethod302) {
auto res = cli_.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Found_302, res->status);
EXPECT_EQ("/hi", res->get_header_value("Location"));
}
TEST_F(ServerTest, GetMethod302Redirect) {
cli_.set_follow_location(true);
auto res = cli_.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!", res->body);
EXPECT_EQ("/hi", res->location);
}
TEST_F(ServerTest, GetMethod404) {
auto res = cli_.Get("/invalid");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, HeadMethod200) {
auto res = cli_.Head("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_TRUE(res->body.empty());
}
TEST_F(ServerTest, HeadMethod200Static) {
auto res = cli_.Head("/mount/dir/index.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ(104, std::stoi(res->get_header_value("Content-Length")));
EXPECT_TRUE(res->body.empty());
}
TEST_F(ServerTest, HeadMethod404) {
auto res = cli_.Head("/invalid");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
EXPECT_TRUE(res->body.empty());
}
TEST_F(ServerTest, GetMethodPersonJohn) {
auto res = cli_.Get("/person/john");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("programmer", res->body);
}
TEST_F(ServerTest, PostMethod1) {
auto res = cli_.Get("/person/john1");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
res = cli_.Post("/person", "name=john1&note=coder",
"application/x-www-form-urlencoded");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
res = cli_.Get("/person/john1");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("text/plain", res->get_header_value("Content-Type"));
ASSERT_EQ("coder", res->body);
}
TEST_F(ServerTest, PostMethod2) {
auto res = cli_.Get("/person/john2");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
Params params;
params.emplace("name", "john2");
params.emplace("note", "coder");
res = cli_.Post("/person", params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
res = cli_.Get("/person/john2");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("text/plain", res->get_header_value("Content-Type"));
ASSERT_EQ("coder", res->body);
}
TEST_F(ServerTest, PutMethod3) {
auto res = cli_.Get("/person/john3");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
Params params;
params.emplace("name", "john3");
params.emplace("note", "coder");
res = cli_.Put("/person", params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
res = cli_.Get("/person/john3");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("text/plain", res->get_header_value("Content-Type"));
ASSERT_EQ("coder", res->body);
}
TEST_F(ServerTest, DeleteMethod1) {
auto res = cli_.Get("/person/john4");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
Params params;
params.emplace("name", "john4");
params.emplace("note", "coder");
res = cli_.Post("/person", params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
res = cli_.Get("/person/john4");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("text/plain", res->get_header_value("Content-Type"));
ASSERT_EQ("coder", res->body);
Params delete_params;
delete_params.emplace("name", "john4");
res = cli_.Delete("/person", delete_params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("DELETED", res->body);
res = cli_.Get("/person/john4");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, DeleteMethod2) {
auto res = cli_.Get("/person/john5");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
Params params;
params.emplace("name", "john5");
params.emplace("note", "developer");
res = cli_.Post("/person", params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
res = cli_.Get("/person/john5");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("text/plain", res->get_header_value("Content-Type"));
ASSERT_EQ("developer", res->body);
Params delete_params;
delete_params.emplace("name", "john5");
Headers headers;
headers.emplace("Custom-Header", "test-value");
res = cli_.Delete("/person", headers, delete_params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("DELETED", res->body);
res = cli_.Get("/person/john5");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, DeleteMethod3) {
auto res = cli_.Get("/person/john6");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
Params params;
params.emplace("name", "john6");
params.emplace("note", "tester");
res = cli_.Post("/person", params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
res = cli_.Get("/person/john6");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("text/plain", res->get_header_value("Content-Type"));
ASSERT_EQ("tester", res->body);
Params delete_params;
delete_params.emplace("name", "john6");
Headers headers;
headers.emplace("Custom-Header", "test-value");
res = cli_.Delete("/person", headers, delete_params, nullptr);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("DELETED", res->body);
res = cli_.Get("/person/john6");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, PostWwwFormUrlEncodedJson) {
Params params;
params.emplace("json", JSON_DATA);
auto res = cli_.Post("/x-www-form-urlencoded-json", params);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(JSON_DATA, res->body);
}
TEST_F(ServerTest, PostEmptyContent) {
auto res = cli_.Post("/empty", "", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("empty", res->body);
}
TEST_F(ServerTest, PostEmptyContentWithNoContentType) {
auto res = cli_.Post("/empty-no-content-type");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("empty-no-content-type", res->body);
}
TEST_F(ServerTest, PostPathOnly) {
auto res = cli_.Post("/path-only");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("path-only", res->body);
}
TEST_F(ServerTest, PostPathAndHeadersOnly) {
auto res = cli_.Post("/path-headers-only",
Headers({{"hello", "world"}, {"hello2", "world2"}}));
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("path-headers-only", res->body);
}
TEST_F(ServerTest, PostLarge) {
auto res = cli_.Post("/post-large", LARGE_DATA, "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(LARGE_DATA, res->body);
}
TEST_F(ServerTest, PutEmptyContentWithNoContentType) {
auto res = cli_.Put("/empty-no-content-type");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("empty-no-content-type", res->body);
}
TEST_F(ServerTest, GetMethodDir) {
auto res = cli_.Get("/dir/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
auto body = R"(<html>
<head>
</head>
<body>
<a href="/dir/test.html">Test</a>
<a href="/hi">hi</a>
</body>
</html>
)";
EXPECT_EQ(body, res->body);
}
TEST_F(ServerTest, GetMethodDirTest) {
auto res = cli_.Get("/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
}
TEST_F(ServerTest, GetMethodDirTestWithDoubleDots) {
auto res = cli_.Get("/dir/../dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
}
TEST_F(ServerTest, GetMethodInvalidPath) {
auto res = cli_.Get("/dir/../test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethodOutOfBaseDir) {
auto res = cli_.Get("/../www/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethodOutOfBaseDir2) {
auto res = cli_.Get("/dir/../../www/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethodDirMountTest) {
auto res = cli_.Get("/mount/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
}
TEST_F(ServerTest, GetMethodDirMountTestWithDoubleDots) {
auto res = cli_.Get("/mount/dir/../dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
}
TEST_F(ServerTest, GetMethodInvalidMountPath) {
auto res = cli_.Get("/mount/dir/../test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethodEmbeddedNUL) {
auto res = cli_.Get("/mount/dir/test.html%00.js");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethodOutOfBaseDirMount) {
auto res = cli_.Get("/mount/../www2/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethodOutOfBaseDirMount2) {
auto res = cli_.Get("/mount/dir/../../www2/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, GetMethodOutOfBaseDirMountWithBackslash) {
auto res = cli_.Get("/mount/%2e%2e%5c/www2/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, PostMethod303) {
auto res = cli_.Post("/1", "body", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::SeeOther_303, res->status);
EXPECT_EQ("/2", res->get_header_value("Location"));
}
TEST_F(ServerTest, PostMethod303Redirect) {
cli_.set_follow_location(true);
auto res = cli_.Post("/1", "body", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("redirected.", res->body);
EXPECT_EQ("/2", res->location);
}
TEST_F(ServerTest, UserDefinedMIMETypeMapping) {
auto res = cli_.Get("/dir/test.abcde");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/abcde", res->get_header_value("Content-Type"));
EXPECT_EQ("abcde", res->body);
}
TEST_F(ServerTest, StaticFileRange) {
auto res =
cli_.Get("/dir/test.abcde", Headers{{make_range_header({{2, 3}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("text/abcde", res->get_header_value("Content-Type"));
EXPECT_EQ("2", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 2-3/5", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("cd"), res->body);
}
TEST_F(ServerTest, StaticFileRanges) {
auto res = cli_.Get("/dir/test.abcde",
Headers{{make_range_header({{1, 2}, {4, -1}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_TRUE(
res->get_header_value("Content-Type")
.find(
"multipart/byteranges; boundary=--cpp-httplib-multipart-data-") ==
0);
EXPECT_EQ("266", res->get_header_value("Content-Length"));
}
TEST_F(ServerTest, StaticFileRangeHead) {
auto res = cli_.Head("/dir/test.abcde", {{make_range_header({{2, 3}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("text/abcde", res->get_header_value("Content-Type"));
EXPECT_EQ("2", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 2-3/5", res->get_header_value("Content-Range"));
}
TEST_F(ServerTest, StaticFileRangeBigFile) {
auto res = cli_.Get("/dir/1MB.txt", Headers{{make_range_header({{-1, 5}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("5", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 1048571-1048575/1048576",
res->get_header_value("Content-Range"));
EXPECT_EQ("LAST\n", res->body);
}
TEST_F(ServerTest, StaticFileRangeBigFile2) {
auto res =
cli_.Get("/dir/1MB.txt", Headers{{make_range_header({{1, 4097}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("4097", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 1-4097/1048576", res->get_header_value("Content-Range"));
}
TEST_F(ServerTest, StaticFileBigFile) {
auto res = cli_.Get("/dir/1MB.txt");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("1048576", res->get_header_value("Content-Length"));
}
TEST_F(ServerTest, InvalidBaseDirMount) {
EXPECT_EQ(false, svr_.set_mount_point("invalid_mount_point", "./www3"));
}
TEST_F(ServerTest, Binary) {
std::vector<char> binary{0x00, 0x01, 0x02, 0x03};
auto res = cli_.Post("/binary", binary.data(), binary.size(),
"application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
res = cli_.Put("/binary", binary.data(), binary.size(),
"application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
res = cli_.Patch("/binary", binary.data(), binary.size(),
"application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
res = cli_.Delete("/binary", binary.data(), binary.size(),
"application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
}
TEST_F(ServerTest, BinaryString) {
auto binary = std::string("\x00\x01\x02\x03", 4);
auto res = cli_.Post("/binary", binary, "application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
res = cli_.Put("/binary", binary, "application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
res = cli_.Patch("/binary", binary, "application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
res = cli_.Delete("/binary", binary, "application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(4U, res->body.size());
}
TEST_F(ServerTest, EmptyRequest) {
auto res = cli_.Get("");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Connection, res.error());
}
TEST_F(ServerTest, LongRequest) {
std::string request;
for (size_t i = 0; i < 545; i++) {
request += "/TooLongRequest";
}
request += "OK";
auto res = cli_.Get(request.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, TooLongRequest) {
std::string request;
for (size_t i = 0; i < 546; i++) {
request += "/TooLongRequest";
}
request += "_NG";
auto start = std::chrono::high_resolution_clock::now();
cli_.set_keep_alive(true);
auto res = cli_.Get(request.c_str());
auto end = std::chrono::high_resolution_clock::now();
auto elapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start)
.count();
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::UriTooLong_414, res->status);
EXPECT_LE(elapsed, 1000);
EXPECT_EQ("close", res->get_header_value("Connection"));
EXPECT_FALSE(cli_.is_socket_open());
}
TEST_F(ServerTest, AlmostTooLongRequest) {
// test for #2046 - URI length check shouldn't include other content on req
// line URI is max URI length, minus 14 other chars in req line (GET, space,
// leading /, space, HTTP/1.1)
std::string request =
"/" + string(CPPHTTPLIB_REQUEST_URI_MAX_LENGTH - 14, 'A');
auto res = cli_.Get(request.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, LongHeader) {
Request req;
req.method = "GET";
req.path = "/hi";
std::string host_and_port;
host_and_port += HOST;
host_and_port += ":";
host_and_port += std::to_string(PORT);
req.headers.emplace("Host", host_and_port.c_str());
req.headers.emplace("Accept", "*/*");
req.headers.emplace("User-Agent", "cpp-httplib/0.1");
req.headers.emplace(
"Header-Name",
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@");
auto res = std::make_shared<Response>();
auto error = Error::Success;
auto ret = cli_.send(req, *res, error);
ASSERT_TRUE(ret);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, LongQueryValue) {
auto start = std::chrono::high_resolution_clock::now();
cli_.set_keep_alive(true);
auto res = cli_.Get(LONG_QUERY_URL.c_str());
auto end = std::chrono::high_resolution_clock::now();
auto elapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start)
.count();
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::UriTooLong_414, res->status);
EXPECT_LE(elapsed, 1000);
EXPECT_EQ("close", res->get_header_value("Connection"));
EXPECT_FALSE(cli_.is_socket_open());
}
TEST_F(ServerTest, TooLongQueryValue) {
auto res = cli_.Get(TOO_LONG_QUERY_URL.c_str());
ASSERT_FALSE(res);
EXPECT_EQ(Error::Read, res.error());
}
TEST_F(ServerTest, TooLongHeader) {
Request req;
req.method = "GET";
req.path = "/hi";
std::string host_and_port;
host_and_port += HOST;
host_and_port += ":";
host_and_port += std::to_string(PORT);
req.headers.emplace("Host", host_and_port.c_str());
req.headers.emplace("Accept", "*/*");
req.headers.emplace("User-Agent", "cpp-httplib/0.1");
req.headers.emplace(
"Header-Name",
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
"@@@@@@@@@@@@@@@@@");
auto res = std::make_shared<Response>();
auto error = Error::Success;
auto ret = cli_.send(req, *res, error);
ASSERT_TRUE(ret);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, HeaderCountAtLimit) {
// Test with headers just under the 100 limit
httplib::Headers headers;
// Add 95 custom headers (the client will add Host, User-Agent, Accept, etc.)
// This should keep us just under the 100 header limit
for (int i = 0; i < 95; i++) {
std::string name = "X-Test-Header-" + std::to_string(i);
std::string value = "value" + std::to_string(i);
headers.emplace(name, value);
}
// This should work fine as we're under the limit
auto res = cli_.Get("/hi", headers);
EXPECT_TRUE(res);
if (res) { EXPECT_EQ(StatusCode::OK_200, res->status); }
}
TEST_F(ServerTest, HeaderCountExceedsLimit) {
// Test with many headers to exceed the 100 limit
httplib::Headers headers;
// Add 150 headers to definitely exceed the 100 limit
for (int i = 0; i < 150; i++) {
std::string name = "X-Test-Header-" + std::to_string(i);
std::string value = "value" + std::to_string(i);
headers.emplace(name, value);
}
// This should fail due to exceeding header count limit
cli_.set_keep_alive(true);
auto res = cli_.Get("/hi", headers);
// The server should respond with 400 Bad Request
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
EXPECT_EQ("close", res->get_header_value("Connection"));
EXPECT_FALSE(cli_.is_socket_open());
}
TEST_F(ServerTest, PercentEncoding) {
auto res = cli_.Get("/e%6edwith%");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, PercentEncodingUnicode) {
auto res = cli_.Get("/e%u006edwith%");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, InvalidPercentEncoding) {
auto res = cli_.Get("/%endwith%");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, InvalidPercentEncodingUnicode) {
auto res = cli_.Get("/%uendwith%");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, EndWithPercentCharacterInQuery) {
auto res = cli_.Get("/hello?aaa=bbb%");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, PlusSignEncoding) {
auto res = cli_.Get("/a+%2Bb?a %2bb=a %2Bb");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("a +b", res->body);
}
TEST_F(ServerTest, HeaderCountSecurityTest) {
// This test simulates a potential DoS attack using many headers
// to verify our security fix prevents memory exhaustion
httplib::Headers attack_headers;
// Attempt to add many headers like an attacker would (200 headers to far
// exceed limit)
for (int i = 0; i < 200; i++) {
std::string name = "X-Attack-Header-" + std::to_string(i);
std::string value = "attack_payload_" + std::to_string(i);
attack_headers.emplace(name, value);
}
// Try to POST with excessive headers
cli_.set_keep_alive(true);
auto res = cli_.Post("/", attack_headers, "test_data", "text/plain");
// Should either fail or return 400 Bad Request due to security limit
if (res) {
// If we get a response, it should be 400 Bad Request
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
EXPECT_EQ("close", res->get_header_value("Connection"));
}
EXPECT_FALSE(cli_.is_socket_open());
}
TEST_F(ServerTest, MultipartFormData) {
UploadFormDataItems items = {
{"text1", "text default", "", ""},
{"text2", "aωb", "", ""},
{"file1", "h\ne\n\nl\nl\no\n", "hello.txt", "text/plain"},
{"file2", "{\n \"world\", true\n}\n", "world.json", "application/json"},
{"file3", "", "", "application/octet-stream"},
{"file4", "", "", " application/json tmp-string "}};
auto res = cli_.Post("/multipart", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, MultipartFormDataMultiFileValues) {
UploadFormDataItems items = {
{"text", "default text", "", ""},
{"multi_text1", "aaaaa", "", ""},
{"multi_text1", "bbbbb", "", ""},
{"multi_file1", "h\ne\n\nl\nl\no\n", "hello.txt", "text/plain"},
{"multi_file1", "{\n \"world\", true\n}\n", "world.json",
"application/json"},
};
auto res = cli_.Post("/multipart/multi_file_values", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, CaseInsensitiveHeaderName) {
auto res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("content-type"));
EXPECT_EQ("Hello World!", res->body);
}
TEST_F(ServerTest, CaseInsensitiveTransferEncoding) {
Request req;
req.method = "POST";
req.path = "/chunked";
std::string host_and_port;
host_and_port += HOST;
host_and_port += ":";
host_and_port += std::to_string(PORT);
req.headers.emplace("Host", host_and_port.c_str());
req.headers.emplace("Accept", "*/*");
req.headers.emplace("User-Agent", "cpp-httplib/0.1");
req.headers.emplace("Content-Type", "text/plain");
req.headers.emplace("Content-Length", "0");
req.headers.emplace(
"Transfer-Encoding",
"Chunked"); // Note, "Chunked" rather than typical "chunked".
// Client does not chunk, so make a chunked body manually.
req.body = "4\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n";
auto res = std::make_shared<Response>();
auto error = Error::Success;
auto ret = cli_.send(req, *res, error);
ASSERT_TRUE(ret);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
template <typename ClientT>
static void expect_chunked_body_status(ClientT &cli, const char *body,
int expected_status) {
Request req;
req.method = "POST";
req.path = "/chunked";
std::string host_and_port;
host_and_port += HOST;
host_and_port += ":";
host_and_port += std::to_string(PORT);
req.headers.emplace("Host", host_and_port.c_str());
req.headers.emplace("Content-Length", "0");
req.headers.emplace("Transfer-Encoding", "chunked");
req.body = body;
auto res = std::make_shared<Response>();
auto error = Error::Success;
ASSERT_TRUE(cli.send(req, *res, error));
EXPECT_EQ(expected_status, res->status);
}
// GHSA-h6wq-j5mv-f3q8: the server must reject malformed chunk-size lines
// rather than treat them as valid lengths.
template <typename ClientT>
static void expect_chunked_body_rejected(ClientT &cli, const char *body) {
expect_chunked_body_status(cli, body, StatusCode::BadRequest_400);
}
TEST_F(ServerTest, RejectsNegativeChunkSize) {
expect_chunked_body_rejected(cli_, "-2\r\nAAAA\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsChunkSizeWithLeadingPlus) {
expect_chunked_body_rejected(
cli_, "+4\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
// RFC 9112 §7.1.1: a chunk-ext is made of tokens and quoted-strings, so the
// chunk-size line carries no CR, LF or other control character ahead of its
// terminator. Such a line must be refused rather than read as extension text.
TEST_F(ServerTest, RejectsBareLFInChunkExtension) {
expect_chunked_body_rejected(
cli_, "4;\nxx\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsBareLFAfterChunkSize) {
expect_chunked_body_rejected(
cli_, "4\nxx\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsBareCRInChunkExtension) {
expect_chunked_body_rejected(
cli_, "4;a\rb\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsControlCharacterInChunkExtension) {
// The literal stays split: a hex escape consumes every hex digit that
// follows, so "\x01b" would be the single byte \x1b, not \x01 then 'b'.
expect_chunked_body_rejected(
cli_, "4;a\x01"
"b\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, AcceptsChunkExtension) {
expect_chunked_body_status(cli_,
"4;name=value\r\ndech\r\n"
"f ; note=\"a;b c\"\r\nunked post body\r\n"
"0;last\r\n\r\n",
StatusCode::OK_200);
}
TEST_F(ServerTest, GetStreamed2) {
auto res = cli_.Get("/streamed", Headers{{make_range_header({{2, 3}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("2", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 2-3/6", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("ab"), res->body);
}
TEST_F(ServerTest, GetStreamed) {
auto res = cli_.Get("/streamed");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("6", res->get_header_value("Content-Length"));
EXPECT_EQ(std::string("aaabbb"), res->body);
}
TEST_F(ServerTest, GetStreamedWithoutLengthWithRange) {
auto res = cli_.Get("/streamed-without-length",
Headers{make_range_header({{0, -1}})});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(false, res->has_header("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(std::string("abcdefg"), res->body);
}
TEST_F(ServerTest, GetStreamedWithRange1) {
auto res =
cli_.Get("/streamed-with-range", Headers{{make_range_header({{3, 5}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("3", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 3-5/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("def"), res->body);
}
TEST_F(ServerTest, GetStreamedWithRange2) {
auto res =
cli_.Get("/streamed-with-range", Headers{{make_range_header({{1, -1}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("6", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 1-6/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("bcdefg"), res->body);
}
TEST_F(ServerTest, GetStreamedWithRangeSuffix1) {
auto res = cli_.Get("/streamed-with-range", Headers{{"Range", "bytes=-3"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("3", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 4-6/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("efg"), res->body);
}
TEST_F(ServerTest, GetStreamedWithRangeSuffix2) {
// RFC 9110 14.1.2: a suffix-length longer than the representation selects
// the entire representation.
for (auto range : {"bytes=-8", "bytes=-9999"}) {
auto res = cli_.Get("/streamed-with-range", Headers{{"Range", range}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status) << range;
EXPECT_EQ("7", res->get_header_value("Content-Length")) << range;
EXPECT_EQ("bytes 0-6/7", res->get_header_value("Content-Range")) << range;
EXPECT_EQ(std::string("abcdefg"), res->body) << range;
}
}
TEST_F(ServerTest, GetStreamedWithRangeSuffixZero) {
auto res =
cli_.Get("/streamed-with-range?error", Headers{{"Range", "bytes=-0"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
EXPECT_EQ("0", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(0U, res->body.size());
}
TEST_F(ServerTest, GetStreamedWithRangeAndNonPartialStatus) {
// Only a 206 is served as a partial representation. Under any other status
// `apply_ranges()` reported the full content length, so the body must match
// that header, and the content provider must never be asked for an offset
// outside the representation.
auto check = [&](int status, const char *range) {
auto path =
std::string("/streamed-with-range?status=") + std::to_string(status);
auto ctx = path + " Range: " + range;
auto res = cli_.Get(path, Headers{{"Range", range}});
ASSERT_TRUE(res) << ctx << " Error: " << to_string(res.error());
EXPECT_EQ(status, res->status) << ctx;
EXPECT_EQ("7", res->get_header_value("Content-Length")) << ctx;
EXPECT_EQ("text/plain", res->get_header_value("Content-Type")) << ctx;
EXPECT_FALSE(res->has_header("Content-Range")) << ctx;
EXPECT_EQ(std::string("abcdefg"), res->body) << ctx;
};
// Non-2xx: `detail::range_error()` never validated these ranges at all.
check(403, "bytes=3-5");
// The offset is far past the representation.
check(403, "bytes=100000-100200");
// `first_pos` is still -1 here, and the bounds asserts in
// `get_range_offset_and_length()` are compiled out under NDEBUG.
check(403, "bytes=-3");
// `apply_ranges()` makes no boundary for a non-206 response, so the
// multipart branch would have written one that is empty.
check(403, "bytes=1-2, 4-5");
// 2xx but not 206: the ranges were validated, yet the Content-Length still
// covers the whole representation.
check(200, "bytes=3-5");
check(200, "bytes=1-2, 4-5");
}
TEST_F(ServerTest, GetStreamedWithRangeError) {
auto res =
cli_.Get("/streamed-with-range",
Headers{{"Range", "bytes=92233720368547758079223372036854775806-"
"92233720368547758079223372036854775807"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
EXPECT_EQ("0", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(0U, res->body.size());
}
TEST_F(ServerTest, GetRangeWithMaxLongLength) {
auto res = cli_.Get(
"/with-range",
Headers{{"Range",
"bytes=0-" + std::to_string(std::numeric_limits<long>::max())},
{"Accept-Encoding", ""}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("7", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 0-6/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("abcdefg"), res->body);
}
TEST_F(ServerTest, GetRangeWithZeroToInfinite) {
auto res = cli_.Get("/with-range", Headers{
{"Range", "bytes=0-"},
{"Accept-Encoding", ""},
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("7", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 0-6/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("abcdefg"), res->body);
}
TEST_F(ServerTest, GetStreamedWithRangeMultipart) {
auto res = cli_.Get("/streamed-with-range",
Headers{{make_range_header({{1, 2}, {4, 5}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("267", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(267U, res->body.size());
// Check that both range contents are present
EXPECT_TRUE(res->body.find("bc\r\n") != std::string::npos);
EXPECT_TRUE(res->body.find("ef\r\n") != std::string::npos);
// Check that Content-Range headers are present for both ranges
EXPECT_TRUE(res->body.find("Content-Range: bytes 1-2/7") !=
std::string::npos);
EXPECT_TRUE(res->body.find("Content-Range: bytes 4-5/7") !=
std::string::npos);
}
TEST_F(ServerTest, GetStreamedWithTooManyRanges) {
Ranges ranges;
for (size_t i = 0; i < CPPHTTPLIB_RANGE_MAX_COUNT + 1; i++) {
ranges.emplace_back(0, -1);
}
auto res = cli_.Get("/streamed-with-range?error",
Headers{{make_range_header(ranges)}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
EXPECT_EQ("0", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(0U, res->body.size());
}
TEST_F(ServerTest, GetStreamedWithOverwrapping) {
auto res = cli_.Get("/streamed-with-range",
Headers{{make_range_header({{1, 4}, {2, 5}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ(5U, res->body.size());
// Check that overlapping ranges are coalesced into a single range
EXPECT_EQ("bcdef", res->body);
EXPECT_EQ("bytes 1-5/7", res->get_header_value("Content-Range"));
// Should be single range, not multipart
EXPECT_TRUE(res->has_header("Content-Range"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
}
TEST_F(ServerTest, GetStreamedWithNonAscendingRanges) {
auto res = cli_.Get("/streamed-with-range",
Headers{{make_range_header({{4, 5}, {0, 2}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ(268U, res->body.size());
// Check that both range contents are present
EXPECT_TRUE(res->body.find("ef\r\n") != std::string::npos);
EXPECT_TRUE(res->body.find("abc\r\n") != std::string::npos);
// Check that Content-Range headers are present for both ranges
EXPECT_TRUE(res->body.find("Content-Range: bytes 4-5/7") !=
std::string::npos);
EXPECT_TRUE(res->body.find("Content-Range: bytes 0-2/7") !=
std::string::npos);
}
TEST_F(ServerTest, GetStreamedWithDuplicateRanges) {
auto res = cli_.Get("/streamed-with-range",
Headers{{make_range_header({{0, 2}, {0, 2}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ(269U, res->body.size());
// Check that both duplicate range contents are present
size_t first_abc = res->body.find("abc\r\n");
EXPECT_TRUE(first_abc != std::string::npos);
size_t second_abc = res->body.find("abc\r\n", first_abc + 1);
EXPECT_TRUE(second_abc != std::string::npos);
// Check that Content-Range headers are present for both ranges
size_t first_range = res->body.find("Content-Range: bytes 0-2/7");
EXPECT_TRUE(first_range != std::string::npos);
size_t second_range =
res->body.find("Content-Range: bytes 0-2/7", first_range + 1);
EXPECT_TRUE(second_range != std::string::npos);
}
TEST_F(ServerTest, GetStreamedWithRangesMoreThanTwoOverwrapping) {
auto res =
cli_.Get("/streamed-with-range?error",
Headers{{make_range_header({{0, 1}, {1, 2}, {2, 3}, {3, 4}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
EXPECT_EQ("0", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(0U, res->body.size());
}
TEST_F(ServerTest, GetStreamedEndless) {
uint64_t offset = 0;
auto res = cli_.Get("/streamed-cancel",
[&](const char * /*data*/, uint64_t data_length) {
if (offset < 100) {
offset += data_length;
return true;
}
return false;
});
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST_F(ServerTest, ClientStop) {
std::atomic_size_t count{4};
std::vector<std::thread> threads;
for (auto i = count.load(); i != 0; --i) {
threads.emplace_back([&]() {
auto res = cli_.Get("/streamed-cancel",
[&](const char *, uint64_t) { return true; });
--count;
ASSERT_TRUE(!res);
EXPECT_TRUE(res.error() == Error::Canceled ||
res.error() == Error::Read || res.error() == Error::Write);
});
}
std::this_thread::sleep_for(std::chrono::seconds(2));
while (count != 0) {
cli_.stop();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
for (auto &t : threads) {
t.join();
}
}
TEST_F(ServerTest, GetWithRange1) {
auto res = cli_.Get("/with-range", Headers{
make_range_header({{3, 5}}),
{"Accept-Encoding", ""},
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("3", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 3-5/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("def"), res->body);
}
TEST_F(ServerTest, GetWithRange2) {
auto res = cli_.Get("/with-range", Headers{
make_range_header({{1, -1}}),
{"Accept-Encoding", ""},
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("6", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 1-6/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("bcdefg"), res->body);
}
TEST_F(ServerTest, GetWithRange3) {
auto res = cli_.Get("/with-range", Headers{
make_range_header({{0, 0}}),
{"Accept-Encoding", ""},
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("1", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 0-0/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("a"), res->body);
}
TEST_F(ServerTest, GetWithRange4) {
auto res = cli_.Get("/with-range", Headers{
make_range_header({{-1, 2}}),
{"Accept-Encoding", ""},
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("2", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 5-6/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("fg"), res->body);
}
TEST_F(ServerTest, GetWithRange5) {
auto res = cli_.Get("/with-range", Headers{
make_range_header({{0, 5}}),
{"Accept-Encoding", ""},
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("6", res->get_header_value("Content-Length"));
EXPECT_EQ(true, res->has_header("Content-Range"));
EXPECT_EQ("bytes 0-5/7", res->get_header_value("Content-Range"));
EXPECT_EQ(std::string("abcdef"), res->body);
}
TEST_F(ServerTest, GetWithRangeOffsetGreaterThanContent) {
auto res =
cli_.Get("/with-range", Headers{{make_range_header({{10000, 20000}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
}
TEST_F(ServerTest, GetWithRangeMultipart) {
auto res =
cli_.Get("/with-range", Headers{{make_range_header({{1, 2}, {4, 5}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_EQ("267", res->get_header_value("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(267U, res->body.size());
}
TEST_F(ServerTest, GetWithRangeMultipartOffsetGreaterThanContent) {
auto res = cli_.Get("/with-range",
Headers{{make_range_header({{-1, 2}, {10000, 30000}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::RangeNotSatisfiable_416, res->status);
}
TEST_F(ServerTest, GetWithRangeCustomizedResponse) {
auto res = cli_.Get("/with-range-customized-response",
Headers{{make_range_header({{1, 2}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
EXPECT_EQ(true, res->has_header("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(JSON_DATA, res->body);
}
TEST_F(ServerTest, GetWithRangeMultipartCustomizedResponseMultipleRange) {
auto res = cli_.Get("/with-range-customized-response",
Headers{{make_range_header({{1, 2}, {4, 5}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
EXPECT_EQ(true, res->has_header("Content-Length"));
EXPECT_EQ(false, res->has_header("Content-Range"));
EXPECT_EQ(JSON_DATA, res->body);
}
TEST_F(ServerTest, Issue1772) {
auto res = cli_.Get("/issue1772", Headers{{make_range_header({{1000, -1}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
}
TEST_F(ServerTest, Issue609) {
auto res = cli_.Delete("/issue609");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("ok"), res->body);
}
TEST_F(ServerTest, GetStreamedChunked) {
auto res = cli_.Get("/streamed-chunked");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("123456789"), res->body);
}
TEST_F(ServerTest, GetStreamedChunked2) {
auto res = cli_.Get("/streamed-chunked2");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("123456789"), res->body);
}
TEST_F(ServerTest, GetStreamedChunkedWithTrailer) {
auto res = cli_.Get("/streamed-chunked-with-trailer");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("123456789"), res->body);
EXPECT_TRUE(res->has_header("Trailer"));
EXPECT_EQ(1U, res->get_header_value_count("Trailer"));
EXPECT_EQ(std::string("Dummy1, Dummy2"), res->get_header_value("Trailer"));
// Trailers are now stored separately from headers (security fix)
EXPECT_EQ(2U, res->trailers.size());
EXPECT_TRUE(res->has_trailer("Dummy1"));
EXPECT_TRUE(res->has_trailer("Dummy2"));
EXPECT_FALSE(res->has_trailer("Dummy3"));
EXPECT_EQ(std::string("DummyVal1"), res->get_trailer_value("Dummy1"));
EXPECT_EQ(std::string("DummyVal2"), res->get_trailer_value("Dummy2"));
// Verify trailers are NOT in headers (security verification)
EXPECT_EQ(std::string(""), res->get_header_value("Dummy1"));
EXPECT_EQ(std::string(""), res->get_header_value("Dummy2"));
}
TEST_F(ServerTest, LargeChunkedPost) {
Request req;
req.method = "POST";
req.path = "/large-chunked";
std::string host_and_port;
host_and_port += HOST;
host_and_port += ":";
host_and_port += std::to_string(PORT);
req.headers.emplace("Host", host_and_port.c_str());
req.headers.emplace("Accept", "*/*");
req.headers.emplace("User-Agent", "cpp-httplib/0.1");
req.headers.emplace("Content-Type", "text/plain");
req.headers.emplace("Content-Length", "0");
req.headers.emplace("Transfer-Encoding", "chunked");
std::string long_string(30 * 1024u, 'a');
std::string chunk = "7800\r\n" + long_string + "\r\n";
// Attempt to make a large enough post to exceed OS buffers, to test that
// the server handles short reads if the full chunk data isn't available.
req.body = chunk + chunk + chunk + chunk + chunk + chunk + "0\r\n\r\n";
auto res = std::make_shared<Response>();
auto error = Error::Success;
auto ret = cli_.send(req, *res, error);
ASSERT_TRUE(ret);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, GetMethodRemoteAddr) {
auto res = cli_.Get("/remote_addr");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_TRUE(res->body == "::1" || res->body == "127.0.0.1");
}
TEST_F(ServerTest, GetMethodLocalAddr) {
auto res = cli_.Get("/local_addr");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_TRUE(res->body == std::string("::1:").append(to_string(PORT)) ||
res->body == std::string("127.0.0.1:").append(to_string(PORT)));
}
TEST_F(ServerTest, HTTPResponseSplitting) {
auto res = cli_.Get("/http_response_splitting");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, SlowRequest) {
request_threads_.emplace_back([this]() { auto res = cli_.Get("/slow"); });
request_threads_.emplace_back([this]() { auto res = cli_.Get("/slow"); });
request_threads_.emplace_back([this]() { auto res = cli_.Get("/slow"); });
}
#if 0
TEST_F(ServerTest, SlowPost) {
char buffer[64 * 1024];
memset(buffer, 0x42, sizeof(buffer));
auto res = cli_.Post(
"/slowpost", 64 * 1024 * 1024,
[&](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
auto ret = sink.write(buffer, sizeof(buffer));
EXPECT_TRUE(ret);
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, SlowPostFail) {
char buffer[64 * 1024];
memset(buffer, 0x42, sizeof(buffer));
cli_.set_write_timeout(std::chrono::seconds(0));
auto res = cli_.Post(
"/slowpost", 64 * 1024 * 1024,
[&](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.write(buffer, sizeof(buffer));
return true;
},
"text/plain");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Write, res.error());
}
#endif
TEST_F(ServerTest, Put) {
auto res = cli_.Put("/put", "PUT", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, PutWithContentProvider) {
auto res = cli_.Put(
"/put", 3,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.os << "PUT";
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, PostWithContentProviderAbort) {
auto res = cli_.Post(
"/post", 42,
[](size_t /*offset*/, size_t /*length*/, DataSink & /*sink*/) {
return false;
},
"text/plain");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST_F(ServerTest, PutWithContentProviderWithoutLength) {
auto res = cli_.Put(
"/put",
[](size_t /*offset*/, DataSink &sink) {
sink.os << "PUT";
sink.done();
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, PostWithContentProviderWithoutLengthAbort) {
auto res = cli_.Post(
"/post", [](size_t /*offset*/, DataSink & /*sink*/) { return false; },
"text/plain");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST_F(ServerTest, PostLoopBack) {
std::string body;
auto res = cli_.Post(
"/post-loopback", 9,
[](size_t /*offset*/, size_t length, DataSink &sink) {
EXPECT_EQ(9u, length);
sink.write("123", 3);
sink.write("456", 3);
sink.write("789", 3);
return true;
},
"text/plain",
[&body](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("123456789", body);
}
TEST_F(ServerTest, PutLoopBack) {
std::string body;
auto res = cli_.Put(
"/put-loopback", 9,
[](size_t /*offset*/, size_t length, DataSink &sink) {
EXPECT_EQ(9u, length);
sink.write("123", 3);
sink.write("456", 3);
sink.write("789", 3);
return true;
},
"text/plain",
[&body](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("123456789", body);
}
TEST_F(ServerTest, PatchLoopBack) {
std::string body;
auto res = cli_.Patch(
"/patch-loopback", 9,
[](size_t /*offset*/, size_t length, DataSink &sink) {
EXPECT_EQ(9u, length);
sink.write("123", 3);
sink.write("456", 3);
sink.write("789", 3);
return true;
},
"text/plain",
[&body](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("123456789", body);
}
TEST_F(ServerTest, PostLoopBackWithoutRequestContentLength) {
std::string body;
auto res = cli_.Post(
"/post-loopback",
[](size_t /*offset*/, DataSink &sink) {
sink.write("123", 3);
sink.write("456", 3);
sink.write("789", 3);
sink.done();
return true;
},
"text/plain",
[&body](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("123456789", body);
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST_F(ServerTest, PutWithContentProviderWithGzip) {
cli_.set_compress(true);
auto res = cli_.Put(
"/put", 3,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.os << "PUT";
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, PostWithContentProviderWithGzipAbort) {
cli_.set_compress(true);
auto res = cli_.Post(
"/post", 42,
[](size_t /*offset*/, size_t /*length*/, DataSink & /*sink*/) {
return false;
},
"text/plain");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST_F(ServerTest, PutWithContentProviderWithoutLengthWithGzip) {
cli_.set_compress(true);
auto res = cli_.Put(
"/put",
[](size_t /*offset*/, DataSink &sink) {
sink.os << "PUT";
sink.done();
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, PostWithContentProviderWithoutLengthWithGzipAbort) {
cli_.set_compress(true);
auto res = cli_.Post(
"/post", [](size_t /*offset*/, DataSink & /*sink*/) { return false; },
"text/plain");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::Canceled, res.error());
}
TEST_F(ServerTest, PutLargeFileWithGzip) {
cli_.set_compress(true);
auto res = cli_.Put("/put-large", LARGE_DATA, "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(LARGE_DATA, res->body);
}
TEST_F(ServerTest, PutLargeFileWithGzip2) {
#ifdef CPPHTTPLIB_SSL_ENABLED
std::string s = std::string("https://") + HOST + ":" + std::to_string(PORT);
Client cli(s.c_str());
cli.enable_server_certificate_verification(false);
#else
std::string s = std::string("http://") + HOST + ":" + std::to_string(PORT);
Client cli(s.c_str());
#endif
cli.set_compress(true);
auto res = cli.Put("/put-large", LARGE_DATA, "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(LARGE_DATA, res->body);
// The compressed size should be less than a 10th of the original. May vary
// depending on the zlib library.
EXPECT_LT(res.get_request_header_value_u64("Content-Length"),
static_cast<uint64_t>(10 * 1024 * 1024));
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
EXPECT_EQ("br", res.get_request_header_value("Content-Encoding"));
#elif defined(CPPHTTPLIB_ZLIB_SUPPORT)
EXPECT_EQ("gzip", res.get_request_header_value("Content-Encoding"));
#elif defined(CPPHTTPLIB_ZSTD_SUPPORT)
EXPECT_EQ("zstd", res.get_request_header_value("Content-Encoding"));
#endif
}
TEST_F(ServerTest, PutContentWithDeflate) {
cli_.set_compress(false);
Headers headers;
headers.emplace("Content-Encoding", "deflate");
// PUT in deflate format:
auto res = cli_.Put("/put", headers,
"\170\234\013\010\015\001\0\001\361\0\372", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, GetStreamedChunkedWithGzip) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
auto res = cli_.Get("/streamed-chunked", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("123456789"), res->body);
}
TEST_F(ServerTest, GetStreamedChunkedWithGzip2) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
auto res = cli_.Get("/streamed-chunked2", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("123456789"), res->body);
}
TEST_F(ServerTest, SplitDelimiterInPathRegex) {
auto res = cli_.Get("/regex-with-delimiter?key=^(?.*(value))");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(GzipDecompressor, ChunkedDecompression) {
std::string data;
for (size_t i = 0; i < 32 * 1024; ++i) {
data.push_back(static_cast<char>('a' + i % 26));
}
std::string compressed_data;
{
httplib::detail::gzip_compressor compressor;
bool result = compressor.compress(
data.data(), data.size(),
/*last=*/true,
[&](const char *compressed_data_chunk, size_t compressed_data_size) {
compressed_data.insert(compressed_data.size(), compressed_data_chunk,
compressed_data_size);
return true;
});
ASSERT_TRUE(result);
}
std::string decompressed_data;
{
httplib::detail::gzip_decompressor decompressor;
// Chunk size is chosen specifically to have a decompressed chunk size equal
// to 16384 bytes 16384 bytes is the size of decompressor output buffer
size_t chunk_size = 130;
for (size_t chunk_begin = 0; chunk_begin < compressed_data.size();
chunk_begin += chunk_size) {
size_t current_chunk_size =
std::min(compressed_data.size() - chunk_begin, chunk_size);
bool result = decompressor.decompress(
compressed_data.data() + chunk_begin, current_chunk_size,
[&](const char *decompressed_data_chunk,
size_t decompressed_data_chunk_size) {
decompressed_data.insert(decompressed_data.size(),
decompressed_data_chunk,
decompressed_data_chunk_size);
return true;
});
ASSERT_TRUE(result);
}
}
ASSERT_EQ(data, decompressed_data);
}
TEST(GzipDecompressor, DeflateDecompression) {
std::string original_text = "Raw deflate without gzip";
unsigned char data[32] = {0x78, 0x9C, 0x0B, 0x4A, 0x2C, 0x57, 0x48, 0x49,
0x4D, 0xCB, 0x49, 0x2C, 0x49, 0x55, 0x28, 0xCF,
0x2C, 0xC9, 0xC8, 0x2F, 0x2D, 0x51, 0x48, 0xAF,
0xCA, 0x2C, 0x00, 0x00, 0x6F, 0x98, 0x09, 0x2E};
std::string compressed_data(data, data + sizeof(data) / sizeof(data[0]));
std::string decompressed_data;
{
httplib::detail::gzip_decompressor decompressor;
bool result = decompressor.decompress(
compressed_data.data(), compressed_data.size(),
[&](const char *decompressed_data_chunk,
size_t decompressed_data_chunk_size) {
decompressed_data.insert(decompressed_data.size(),
decompressed_data_chunk,
decompressed_data_chunk_size);
return true;
});
ASSERT_TRUE(result);
}
ASSERT_EQ(original_text, decompressed_data);
}
TEST(GzipDecompressor, DeflateDecompressionTrailingBytes) {
std::string original_text = "Raw deflate without gzip";
unsigned char data[40] = {0x78, 0x9C, 0x0B, 0x4A, 0x2C, 0x57, 0x48, 0x49,
0x4D, 0xCB, 0x49, 0x2C, 0x49, 0x55, 0x28, 0xCF,
0x2C, 0xC9, 0xC8, 0x2F, 0x2D, 0x51, 0x48, 0xAF,
0xCA, 0x2C, 0x00, 0x00, 0x6F, 0x98, 0x09, 0x2E,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
std::string compressed_data(data, data + sizeof(data) / sizeof(data[0]));
std::string decompressed_data;
{
httplib::detail::gzip_decompressor decompressor;
bool result = decompressor.decompress(
compressed_data.data(), compressed_data.size(),
[&](const char *decompressed_data_chunk,
size_t decompressed_data_chunk_size) {
decompressed_data.insert(decompressed_data.size(),
decompressed_data_chunk,
decompressed_data_chunk_size);
return true;
});
ASSERT_TRUE(result);
}
ASSERT_EQ(original_text, decompressed_data);
}
#endif
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
TEST_F(ServerTest, GetStreamedChunkedWithBrotli) {
Headers headers;
headers.emplace("Accept-Encoding", "br");
auto res = cli_.Get("/streamed-chunked", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("123456789"), res->body);
}
TEST_F(ServerTest, GetStreamedChunkedWithBrotli2) {
Headers headers;
headers.emplace("Accept-Encoding", "br");
auto res = cli_.Get("/streamed-chunked2", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string("123456789"), res->body);
}
TEST_F(ServerTest, PutWithContentProviderWithBrotli) {
cli_.set_compress(true);
auto res = cli_.Put(
"/put", 3,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.os << "PUT";
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, PutWithContentProviderWithoutLengthWithBrotli) {
cli_.set_compress(true);
auto res = cli_.Put(
"/put",
[](size_t /*offset*/, DataSink &sink) {
sink.os << "PUT";
sink.done();
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
TEST_F(ServerTest, PutLargeFileWithBrotli) {
cli_.set_compress(true);
auto res = cli_.Put("/put-large", LARGE_DATA, "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(LARGE_DATA, res->body);
EXPECT_EQ("br", res.get_request_header_value("Content-Encoding"));
}
#endif
TEST_F(ServerTest, Patch) {
auto res = cli_.Patch("/patch", "PATCH", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PATCH", res->body);
}
TEST_F(ServerTest, Delete) {
auto res = cli_.Delete("/delete");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("DELETE", res->body);
}
TEST_F(ServerTest, DeleteContentReceiver) {
auto res = cli_.Delete("/delete-body", "content", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("content", res->body);
}
TEST_F(ServerTest, Options) {
auto res = cli_.Options("*");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("GET, POST, HEAD, OPTIONS", res->get_header_value("Allow"));
EXPECT_TRUE(res->body.empty());
}
TEST_F(ServerTest, URL) {
auto res = cli_.Get("/request-target?aaa=bbb&ccc=ddd");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, ArrayParam) {
auto res = cli_.Get("/array-param?array=value1&array=value2&array=value3");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, ArrayParamValues) {
auto res =
cli_.Get("/array-param-values?array=value1&array=value2&array=value3");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, NoMultipleHeaders) {
Headers headers = {{"Content-Length", "5"}};
auto res = cli_.Post("/validate-no-multiple-headers", headers, "hello",
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, PostContentReceiver) {
auto res = cli_.Post("/content_receiver", "content", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("content", res->body);
}
TEST_F(ServerTest, PostMultipartFileContentReceiver) {
UploadFormDataItems items = {
{"text1", "text default", "", ""},
{"text2", "aωb", "", ""},
{"file1", "h\ne\n\nl\nl\no\n", "hello.txt", "text/plain"},
{"file2", R"({\n "world": true\n}\n)", "world.json", "application/json"},
{"file3", "", "", "application/octet-stream"},
};
auto res = cli_.Post("/content_receiver", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, PostMultipartPlusBoundary) {
UploadFormDataItems items = {
{"text1", "text default", "", ""},
{"text2", "aωb", "", ""},
{"file1", "h\ne\n\nl\nl\no\n", "hello.txt", "text/plain"},
{"file2", R"({\n "world": true\n}\n)", "world.json", "application/json"},
{"file3", "", "", "application/octet-stream"},
};
auto boundary = std::string("+++++");
std::string body;
for (const auto &item : items) {
body += "--" + boundary + "\r\n";
body += "Content-Disposition: form-data; name=\"" + item.name + "\"";
if (!item.filename.empty()) {
body += "; filename=\"" + item.filename + "\"";
}
body += "\r\n";
if (!item.content_type.empty()) {
body += "Content-Type: " + item.content_type + "\r\n";
}
body += "\r\n";
body += item.content + "\r\n";
}
body += "--" + boundary + "--\r\n";
std::string content_type = "multipart/form-data; boundary=" + boundary;
auto res = cli_.Post("/content_receiver", body, content_type.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MultipartFormDataTest, FieldEscaping) {
Server svr;
svr.Post("/post", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
ASSERT_TRUE(req.is_multipart_form_data());
std::vector<FormData> received;
content_reader(
[&](const FormData &file) {
received.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
received.back().content.append(data, data_length);
return true;
});
// '"', CR and LF in names and filenames are escaped following the
// WHATWG HTML standard, so each part still parses as a single part
// with no injected headers. Content types get CR/LF escaped too,
// while '"' (legal there, e.g. quoted charset) is preserved.
ASSERT_EQ(4U, received.size());
EXPECT_EQ("na%22me", received[0].name);
EXPECT_EQ("quoted name", received[0].content);
EXPECT_EQ("file", received[1].name);
EXPECT_EQ("evil%0D%0AContent-Type: text/evil%0D%0A%0D%0A.pdf",
received[1].filename);
EXPECT_EQ("application/octet-stream", received[1].content_type);
EXPECT_EQ("injected", received[1].content);
EXPECT_EQ("my%0Dna%0Ame", received[2].name);
EXPECT_EQ("my%22file.txt", received[2].filename);
EXPECT_EQ("cr lf name", received[2].content);
EXPECT_EQ("typed", received[3].name);
EXPECT_EQ("text/plain; charset=\"utf-8\"%0D%0AX-Evil: 1",
received[3].content_type);
EXPECT_EQ("typed content", received[3].content);
});
auto port = svr.bind_to_any_port("localhost");
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", port);
UploadFormDataItems items = {
{"na\"me", "quoted name", "", ""},
{"file", "injected", "evil\r\nContent-Type: text/evil\r\n\r\n.pdf",
"application/octet-stream"},
{"my\rna\nme", "cr lf name", "my\"file.txt", "text/plain"},
{"typed", "typed content", "",
"text/plain; charset=\"utf-8\"\r\nX-Evil: 1"},
};
auto res = cli.Post("/post", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MultipartFormDataTest, PublicWriterAPI) {
const UploadFormDataItems items = {
{"name1", "Content 1", "", ""},
{"name2", "Content 2", "file2.txt", "text/plain"},
};
Server svr;
svr.Post("/post", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
ASSERT_TRUE(req.is_multipart_form_data());
std::vector<FormData> received;
content_reader(
[&](const FormData &file) {
received.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
received.back().content.append(data, data_length);
return true;
});
ASSERT_EQ(2U, received.size());
EXPECT_EQ("name1", received[0].name);
EXPECT_EQ("Content 1", received[0].content);
EXPECT_EQ("name2", received[1].name);
EXPECT_EQ("Content 2", received[1].content);
EXPECT_EQ("file2.txt", received[1].filename);
EXPECT_EQ("text/plain", received[1].content_type);
});
auto port = svr.bind_to_any_port("localhost");
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", port);
// Whole-body serialization with a generated boundary
{
MultipartFormDataWriter writer;
EXPECT_TRUE(is_valid_multipart_boundary(writer.boundary()));
EXPECT_EQ("multipart/form-data; boundary=" + writer.boundary(),
writer.content_type());
auto body = writer.serialize(items);
EXPECT_EQ(body.size(), writer.content_length(items));
auto res = cli.Post("/post", body, writer.content_type());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
// Per-part framing with a custom boundary via a content provider
{
EXPECT_FALSE(is_valid_multipart_boundary("bad boundary"));
ASSERT_TRUE(is_valid_multipart_boundary("custom-boundary_123"));
MultipartFormDataWriter writer("custom-boundary_123");
EXPECT_EQ("custom-boundary_123", writer.boundary());
std::string body;
for (const auto &item : items) {
body += writer.item_begin(item);
body += item.content;
body += MultipartFormDataWriter::item_end();
}
body += writer.finish();
EXPECT_EQ(body.size(), writer.content_length(items));
auto res = cli.Post(
"/post", body.size(),
[&](size_t offset, size_t length, DataSink &sink) {
sink.write(body.data() + offset, length);
return true;
},
writer.content_type());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
}
TEST_F(ServerTest, PostContentReceiverGzip) {
cli_.set_compress(true);
auto res = cli_.Post("/content_receiver", "content", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("content", res->body);
}
TEST_F(ServerTest, PutContentReceiver) {
auto res = cli_.Put("/content_receiver", "content", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("content", res->body);
}
TEST_F(ServerTest, PatchContentReceiver) {
auto res = cli_.Patch("/content_receiver", "content", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ("content", res->body);
}
template <typename ClientType>
void TestWithHeadersAndContentReceiver(
ClientType &cli,
std::function<Result(ClientType &, const std::string &, const Headers &,
const std::string &, const std::string &,
ContentReceiver, DownloadProgress)>
request_func) {
Headers headers;
headers.emplace("X-Custom-Header", "test-value");
std::string received_body;
auto res = request_func(
cli, "/content_receiver", headers, "content", "application/json",
[&](const char *data, size_t data_length) {
received_body.append(data, data_length);
return true;
},
nullptr);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("content", received_body);
}
TEST_F(ServerTest, PostWithHeadersAndContentReceiver) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiver<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver, DownloadProgress progress) {
return cli.Post(path, headers, body, content_type, receiver, progress);
});
}
TEST_F(ServerTest, PutWithHeadersAndContentReceiver) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiver<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver, DownloadProgress progress) {
return cli.Put(path, headers, body, content_type, receiver, progress);
});
}
TEST_F(ServerTest, PatchWithHeadersAndContentReceiver) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiver<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver, DownloadProgress progress) {
return cli.Patch(path, headers, body, content_type, receiver, progress);
});
}
template <typename ClientType>
void TestWithHeadersAndContentReceiverWithProgress(
ClientType &cli,
std::function<Result(ClientType &, const std::string &, const Headers &,
const std::string &, const std::string &,
ContentReceiver, DownloadProgress)>
request_func) {
Headers headers;
headers.emplace("X-Test-Header", "progress-test");
std::string received_body;
auto progress_called = false;
auto res = request_func(
cli, "/content_receiver", headers, "content", "text/plain",
[&](const char *data, size_t data_length) {
received_body.append(data, data_length);
return true;
},
[&](uint64_t /*current*/, uint64_t /*total*/) {
progress_called = true;
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("content", received_body);
EXPECT_TRUE(progress_called);
}
TEST_F(ServerTest, PostWithHeadersAndContentReceiverWithProgress) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiverWithProgress<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver, DownloadProgress progress) {
return cli.Post(path, headers, body, content_type, receiver, progress);
});
}
TEST_F(ServerTest, PutWithHeadersAndContentReceiverWithProgress) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiverWithProgress<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver, DownloadProgress progress) {
return cli.Put(path, headers, body, content_type, receiver, progress);
});
}
TEST_F(ServerTest, PatchWithHeadersAndContentReceiverWithProgress) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiverWithProgress<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver, DownloadProgress progress) {
return cli.Patch(path, headers, body, content_type, receiver, progress);
});
}
template <typename ClientType>
void TestWithHeadersAndContentReceiverError(
ClientType &cli, std::function<Result(ClientType &, const std::string &,
const Headers &, const std::string &,
const std::string &, ContentReceiver)>
request_func) {
Headers headers;
headers.emplace("X-Error-Test", "true");
std::string received_body;
auto receiver_failed = false;
auto res =
request_func(cli, "/content_receiver", headers, "content", "text/plain",
[&](const char *data, size_t data_length) {
received_body.append(data, data_length);
receiver_failed = true;
return false;
});
ASSERT_FALSE(res);
EXPECT_TRUE(receiver_failed);
}
TEST_F(ServerTest, PostWithHeadersAndContentReceiverError) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiverError<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver) {
return cli.Post(path, headers, body, content_type, receiver);
});
}
TEST_F(ServerTest, PuttWithHeadersAndContentReceiverError) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiverError<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver) {
return cli.Put(path, headers, body, content_type, receiver);
});
}
TEST_F(ServerTest, PatchWithHeadersAndContentReceiverError) {
#ifdef CPPHTTPLIB_SSL_ENABLED
using ClientT = SSLClient;
#else
using ClientT = Client;
#endif
TestWithHeadersAndContentReceiverError<ClientT>(
cli_, [](ClientT &cli, const std::string &path, const Headers &headers,
const std::string &body, const std::string &content_type,
ContentReceiver receiver) {
return cli.Patch(path, headers, body, content_type, receiver);
});
}
TEST_F(ServerTest, PostQueryStringAndBody) {
auto res =
cli_.Post("/query-string-and-body?key=value", "content", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, HTTP2Magic) {
Request req;
req.method = "PRI";
req.path = "*";
req.body = "SM";
auto res = std::make_shared<Response>();
auto error = Error::Success;
auto ret = cli_.send(req, *res, error);
ASSERT_TRUE(ret);
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
}
TEST_F(ServerTest, KeepAlive) {
auto res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("Hello World!", res->body);
res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("Hello World!", res->body);
res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("Hello World!", res->body);
res = cli_.Get("/not-exist");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
res = cli_.Post("/empty", "", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("empty", res->body);
EXPECT_EQ("close", res->get_header_value("Connection"));
res = cli_.Post(
"/empty", 0, [&](size_t, size_t, DataSink &) { return true; },
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("empty", res->body);
cli_.set_keep_alive(false);
res = cli_.Get("/last-request");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("close", res->get_header_value("Connection"));
}
TEST_F(ServerTest, TooManyRedirect) {
cli_.set_follow_location(true);
auto res = cli_.Get("/redirect/0");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::ExceedRedirectCount, res.error());
}
TEST_F(ServerTest, BadRequestLineCancelsKeepAlive) {
Request req;
req.method = "FOOBAR";
req.path = "/hi";
cli_.set_keep_alive(true);
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
EXPECT_EQ("close", res->get_header_value("Connection"));
EXPECT_FALSE(cli_.is_socket_open());
}
TEST_F(ServerTest, CustomRouteReadsBody) {
const std::string xml =
R"(<?xml version="1.0"?><propfind><allprop/></propfind>)";
Request req;
req.method = "PROPFIND";
req.path = "/dav/dir";
req.set_header("Depth", "1");
req.body = xml;
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MultiStatus_207, res->status);
EXPECT_EQ(xml, res->body);
EXPECT_EQ(std::to_string(xml.size()), res->get_header_value("x-body-size"));
EXPECT_EQ("application/xml", res->get_header_value("Content-Type"));
}
TEST_F(ServerTest, CustomRouteMatchedRouteAndPathParams) {
Request req;
req.method = "PROPFIND";
req.path = "/dav/42";
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MultiStatus_207, res->status);
EXPECT_EQ("/dav/:id", res->get_header_value("x-matched-route"));
EXPECT_EQ("42", res->get_header_value("x-dav-id"));
}
TEST_F(ServerTest, CustomRouteRegexPattern) {
Request req;
req.method = "PROPFIND";
req.path = "/dav-re/123";
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MultiStatus_207, res->status);
EXPECT_EQ("123", res->get_header_value("x-dav-match"));
}
TEST_F(ServerTest, CustomRouteWithoutBody) {
Request req;
req.method = "MKCOL";
req.path = "/dav-mkcol";
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Created_201, res->status);
}
TEST_F(ServerTest, CustomRouteWithContentReader) {
const std::string xml = R"(<?xml version="1.0"?><sync-collection/>)";
Request req;
req.method = "REPORT";
req.path = "/dav-report";
req.body = xml;
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MultiStatus_207, res->status);
EXPECT_EQ(xml, res->body);
}
// A content reader route must fire even when the request carries no body,
// the way the built-in Delete(pattern, HandlerWithContentReader) does.
// Without that, a body-less PROPFIND-style request would fall through to 404.
TEST_F(ServerTest, CustomRouteWithContentReaderWithoutBody) {
Request req;
req.method = "REPORT";
req.path = "/dav-report";
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MultiStatus_207, res->status);
EXPECT_EQ("0", res->get_header_value("x-body-size"));
}
TEST_F(ServerTest, CustomRouteUnmatchedPathReturns404) {
Request req;
req.method = "PROPFIND";
req.path = "/not-dav";
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, CustomRouteUnregisteredMethodIsRejected) {
Request req;
req.method = "UNLOCK";
req.path = "/dav/dir";
cli_.set_keep_alive(true);
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
EXPECT_EQ("close", res->get_header_value("Connection"));
EXPECT_FALSE(cli_.is_socket_open());
}
TEST_F(ServerTest, CustomRouteDoesNotServeStaticFiles) {
// The mount point serves this path, but only for GET and HEAD.
auto get_res = cli_.Get("/dir/index.html");
ASSERT_TRUE(get_res) << "Error: " << to_string(get_res.error());
ASSERT_EQ(StatusCode::OK_200, get_res->status);
Request req;
req.method = "PROPFIND";
req.path = "/dir/index.html";
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST_F(ServerTest, CustomRouteKeepAlive) {
const std::string xml = R"(<?xml version="1.0"?><propfind/>)";
cli_.set_keep_alive(true);
for (auto i = 0; i < 2; i++) {
Request req;
req.method = "PROPFIND";
req.path = "/dav/dir";
req.body = xml;
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MultiStatus_207, res->status);
EXPECT_EQ(xml, res->body);
EXPECT_TRUE(cli_.is_socket_open());
}
// A built-in method must still be served on the same connection.
cli_.set_keep_alive(false);
auto res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("close", res->get_header_value("Connection"));
}
TEST_F(ServerTest, CustomRouteExpect100Continue) {
const std::string xml = R"(<?xml version="1.0"?><propfind/>)";
Request req;
req.method = "PROPFIND";
req.path = "/dav/dir";
req.set_header("Expect", "100-continue");
req.body = xml;
auto res = cli_.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MultiStatus_207, res->status);
EXPECT_EQ(xml, res->body);
}
TEST_F(ServerTest, StartTime) { auto res = cli_.Get("/test-start-time"); }
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST_F(ServerTest, Gzip) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("33", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, GzipWithContentTypeParameters) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
auto res = cli_.Get("/compress-with-charset", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ("application/json; charset=utf-8",
res->get_header_value("Content-Type"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, GzipWithoutAcceptEncoding) {
Headers headers;
headers.emplace("Accept-Encoding", "");
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_TRUE(res->get_header_value("Content-Encoding").empty());
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, GzipWithContentReceiver) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
std::string body;
auto res = cli_.Get("/compress", headers,
[&](const char *data, uint64_t data_length) {
EXPECT_EQ(100U, data_length);
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("33", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, GzipWithoutDecompressing) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
cli_.set_decompress(false);
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("33", res->get_header_value("Content-Length"));
EXPECT_EQ(33U, res->body.size());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, GzipWithContentReceiverWithoutAcceptEncoding) {
Headers headers;
headers.emplace("Accept-Encoding", "");
std::string body;
auto res = cli_.Get("/compress", headers,
[&](const char *data, uint64_t data_length) {
EXPECT_EQ(100U, data_length);
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_TRUE(res->get_header_value("Content-Encoding").empty());
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, NoGzip) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
auto res = cli_.Get("/nocompress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(false, res->has_header("Content-Encoding"));
EXPECT_EQ("application/octet-stream", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, NoGzipWithContentReceiver) {
Headers headers;
headers.emplace("Accept-Encoding", "gzip, deflate");
std::string body;
auto res = cli_.Get("/nocompress", headers,
[&](const char *data, uint64_t data_length) {
EXPECT_EQ(100U, data_length);
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(false, res->has_header("Content-Encoding"));
EXPECT_EQ("application/octet-stream", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, MultipartFormDataGzip) {
UploadFormDataItems items = {
{"key1", "test", "", ""},
{"key2", "--abcdefg123", "", ""},
};
cli_.set_compress(true);
auto res = cli_.Post("/compress-multipart", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
#endif
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
// Static file compression is opt-in, so these run their own server rather than
// flipping it on for the shared ServerTest fixture, which would silently
// rewrite what every other static file test is asserting.
class StaticFileCompressionTest : public ::testing::Test {
protected:
void TearDown() override {
if (t_.joinable()) {
svr_.stop();
t_.join();
}
}
int start(const std::function<void(Server &)> &configure = nullptr) {
// Serves the same tree as a directory of build-time compressed assets
// would be served: the mount point names the coding the files are already
// stored in. Registered before "/" so it is the one that matches.
svr_.set_mount_point("/pre-encoded", "./www",
{{"Content-Encoding", "gzip"}});
svr_.set_mount_point("/", "./www");
svr_.Get("/file_content", [](const Request & /*req*/, Response &res) {
res.set_file_content("./www/dir/index.html", "text/html");
});
svr_.Get("/pre-encoded-file-content",
[](const Request & /*req*/, Response &res) {
res.set_header("Content-Encoding", "gzip");
res.set_file_content("./www/dir/index.html", "text/html");
});
svr_.Get("/streamed", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
6, "text/plain",
[](size_t offset, size_t /*length*/, DataSink &sink) {
sink.write(offset < 3 ? "aaa" : "bbb", 3);
return true;
});
});
svr_.Get("/slow-stream", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
1000, "text/plain",
[](size_t offset, size_t /*length*/, DataSink &sink) {
if (offset < 100) {
std::string data(100, 'A');
sink.write(data.data(), data.size());
return true;
}
std::this_thread::sleep_for(std::chrono::seconds(2));
std::string data(900, 'B');
sink.write(data.data(), data.size());
return true;
});
});
if (configure) { configure(svr_); }
auto port = svr_.bind_to_any_port(HOST);
t_ = std::thread([&]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
return port;
}
static void enable(Server &svr) { svr.set_static_file_compression(true); }
// Every file under ./www except 1MB.txt is smaller than the default
// 1400-byte floor, so a test that needs a small file compressed has to lower
// it out of the way.
static void enable_without_floor(Server &svr) {
svr.set_static_file_compression(true);
svr.set_static_file_compression_min_length(0);
}
Server svr_;
std::thread t_;
};
TEST_F(StaticFileCompressionTest, DisabledByDefault) {
auto port = start();
Client cli(HOST, port);
auto res = cli.Get("/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_FALSE(res->has_header("Content-Encoding"));
EXPECT_EQ("1048576", res->get_header_value("Content-Length"));
EXPECT_EQ(1048576U, res->body.size());
}
TEST_F(StaticFileCompressionTest, MountPoint) {
auto port = start(enable);
Client cli(HOST, port);
cli.set_decompress(false);
auto res = cli.Get("/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ("Accept-Encoding", res->get_header_value("Vary"));
// The response stays self-delimiting: a length, not a chunked body.
EXPECT_FALSE(res->has_header("Transfer-Encoding"));
EXPECT_EQ(std::to_string(res->body.size()),
res->get_header_value("Content-Length"));
EXPECT_LT(res->body.size(), 1048576U);
EXPECT_FALSE(res->body.empty());
}
TEST_F(StaticFileCompressionTest, MountPointDecompressed) {
auto port = start(enable);
Client cli(HOST, port);
auto res = cli.Get("/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ(1048576U, res->body.size());
}
TEST_F(StaticFileCompressionTest, FileContent) {
auto port = start(enable_without_floor);
Client cli(HOST, port);
auto res = cli.Get("/file_content", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ(104U, res->body.size());
}
// A handler that serves an already-encoded file names the coding itself. The
// file-backed path decided its coding from Accept-Encoding and the content
// type alone, so it compressed the stored bytes a second time and appended a
// second Content-Encoding field line.
TEST_F(StaticFileCompressionTest, PreEncodedFileContentIsNotCompressedAgain) {
auto port = start(enable_without_floor);
Client cli(HOST, port);
cli.set_decompress(false);
auto res = cli.Get("/pre-encoded-file-content",
Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
// Vary belongs to a coding the server chose, and it chose none here.
EXPECT_FALSE(res->has_header("Vary"));
// The file travels exactly as it is stored on disk.
EXPECT_EQ(104U, res->body.size());
}
// The 1MB file clears the default floor, so this one runs the configuration a
// deployment would actually have.
TEST_F(StaticFileCompressionTest, PreEncodedMountPointIsNotCompressedAgain) {
auto port = start(enable);
Client cli(HOST, port);
cli.set_decompress(false);
auto res =
cli.Get("/pre-encoded/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_FALSE(res->has_header("Vary"));
EXPECT_EQ(1048576U, res->body.size());
EXPECT_EQ("1048576", res->get_header_value("Content-Length"));
}
TEST_F(StaticFileCompressionTest, Head) {
auto port = start(enable);
Client cli(HOST, port);
cli.set_decompress(false);
auto get = cli.Get("/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(get) << "Error: " << to_string(get.error());
auto res = cli.Head("/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_FALSE(res->has_header("Transfer-Encoding"));
// HEAD still reports the size a GET would return.
EXPECT_EQ(get->get_header_value("Content-Length"),
res->get_header_value("Content-Length"));
EXPECT_TRUE(res->body.empty());
}
TEST_F(StaticFileCompressionTest, RangeStaysIdentity) {
auto port = start(enable);
Client cli(HOST, port);
auto res = cli.Get("/dir/test.abcde", Headers{make_range_header({{2, 3}}),
{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
EXPECT_FALSE(res->has_header("Content-Encoding"));
EXPECT_EQ("2", res->get_header_value("Content-Length"));
EXPECT_EQ("bytes 2-3/5", res->get_header_value("Content-Range"));
EXPECT_EQ("cd", res->body);
}
TEST_F(StaticFileCompressionTest, NotCompressibleType) {
auto port = start(enable);
Client cli(HOST, port);
auto res = cli.Get("/file", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("application/octet-stream", res->get_header_value("Content-Type"));
EXPECT_FALSE(res->has_header("Content-Encoding"));
EXPECT_EQ("5", res->get_header_value("Content-Length"));
}
TEST_F(StaticFileCompressionTest, EmptyFile) {
auto port = start(enable);
Client cli(HOST, port);
auto res = cli.Get("/empty_file", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_FALSE(res->has_header("Content-Encoding"));
EXPECT_EQ("0", res->get_header_value("Content-Length"));
EXPECT_TRUE(res->body.empty());
}
TEST_F(StaticFileCompressionTest, MinLength) {
auto port = start(enable);
Client cli(HOST, port);
// 104 bytes, well under the default floor. Compressing it would add the
// gzip header and trailer to a body that already fits in one packet.
auto res = cli.Get("/dir/index.html", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_FALSE(res->has_header("Content-Encoding"));
EXPECT_EQ("104", res->get_header_value("Content-Length"));
}
TEST_F(StaticFileCompressionTest, MinLengthLowered) {
auto port = start([](Server &svr) {
svr.set_static_file_compression(true);
svr.set_static_file_compression_min_length(1);
});
Client cli(HOST, port);
cli.set_decompress(false);
auto res = cli.Get("/dir/test.html", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
// A 9-byte file comes back larger than it went in, because gzip's header and
// trailer outweigh anything deflate can save. That is the end of the range
// the floor exists to keep out.
EXPECT_GT(res->body.size(), 9U);
}
TEST_F(StaticFileCompressionTest, MaxLength) {
auto port = start([](Server &svr) {
svr.set_static_file_compression(true);
svr.set_static_file_compression_min_length(0);
svr.set_static_file_compression_max_length(1024);
});
Client cli(HOST, port);
// Over the limit: served as is. Not named `big`/`small`: <rpcndr.h>
// defines `small` as a macro on Windows.
auto over = cli.Get("/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(over) << "Error: " << to_string(over.error());
EXPECT_FALSE(over->has_header("Content-Encoding"));
EXPECT_EQ("1048576", over->get_header_value("Content-Length"));
// Under it: compressed.
auto under = cli.Get("/dir/index.html", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(under) << "Error: " << to_string(under.error());
EXPECT_EQ("gzip", under->get_header_value("Content-Encoding"));
}
TEST_F(StaticFileCompressionTest, EtagPerEncoding) {
auto port = start(enable_without_floor);
Client cli(HOST, port);
auto identity = cli.Get("/dir/index.html", Headers{{"Accept-Encoding", ""}});
ASSERT_TRUE(identity) << "Error: " << to_string(identity.error());
EXPECT_FALSE(identity->has_header("Content-Encoding"));
auto identity_etag = identity->get_header_value("ETag");
ASSERT_FALSE(identity_etag.empty());
auto gzipped =
cli.Get("/dir/index.html", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(gzipped) << "Error: " << to_string(gzipped.error());
EXPECT_EQ("gzip", gzipped->get_header_value("Content-Encoding"));
auto gzip_etag = gzipped->get_header_value("ETag");
ASSERT_FALSE(gzip_etag.empty());
// Two representations, two validators.
EXPECT_NE(identity_etag, gzip_etag);
// Each one revalidates against the request that would produce it...
auto fresh =
cli.Get("/dir/index.html", Headers{{"Accept-Encoding", "gzip"},
{"If-None-Match", gzip_etag}});
ASSERT_TRUE(fresh) << "Error: " << to_string(fresh.error());
EXPECT_EQ(StatusCode::NotModified_304, fresh->status);
auto fresh_identity =
cli.Get("/dir/index.html", Headers{{"Accept-Encoding", ""},
{"If-None-Match", identity_etag}});
ASSERT_TRUE(fresh_identity) << "Error: " << to_string(fresh_identity.error());
EXPECT_EQ(StatusCode::NotModified_304, fresh_identity->status);
// ...and not against the other representation.
auto crossed =
cli.Get("/dir/index.html",
Headers{{"Accept-Encoding", ""}, {"If-None-Match", gzip_etag}});
ASSERT_TRUE(crossed) << "Error: " << to_string(crossed.error());
EXPECT_EQ(StatusCode::OK_200, crossed->status);
}
// The opt-in covers responses the server reads off disk itself. A caller's own
// known-length provider keeps its Content-Length and, more importantly, keeps
// delivering incrementally: routing it through a compressor would hold every
// write back until zlib's window filled.
TEST_F(StaticFileCompressionTest, KnownLengthProviderUnaffected) {
auto port = start(enable);
Client cli(HOST, port);
auto res = cli.Get("/streamed", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_FALSE(res->has_header("Content-Encoding"));
EXPECT_EQ("6", res->get_header_value("Content-Length"));
EXPECT_EQ("aaabbb", res->body);
}
TEST_F(StaticFileCompressionTest, IncrementalProviderUnaffected) {
auto port = start(enable);
Client cli(HOST, port);
cli.set_read_timeout(1, 0);
auto handle = cli.open_stream("GET", "/slow-stream", Params{},
Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(handle.is_valid());
// The provider writes 100 bytes and then stalls for longer than the read
// timeout. Those first bytes have to arrive anyway: run the response through
// a compressor and zlib holds them until its window fills, so the read would
// come back empty instead.
char buf[256];
auto n = handle.read(buf, sizeof(buf));
ASSERT_GT(n, 0) << "first read should return the bytes already written";
EXPECT_EQ(std::string(100, 'A'), std::string(buf, static_cast<size_t>(n)));
}
#endif
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
TEST_F(ServerTest, Brotli) {
Headers headers;
headers.emplace("Accept-Encoding", "br");
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("br", res->get_header_value("Content-Encoding"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("19", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
#endif
#ifdef CPPHTTPLIB_ZSTD_SUPPORT
TEST_F(ServerTest, Zstd) {
Headers headers;
headers.emplace("Accept-Encoding", "zstd");
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("zstd", res->get_header_value("Content-Encoding"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("26", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, ZstdWithoutAcceptEncoding) {
Headers headers;
headers.emplace("Accept-Encoding", "");
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_TRUE(res->get_header_value("Content-Encoding").empty());
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, ZstdWithContentReceiver) {
Headers headers;
headers.emplace("Accept-Encoding", "zstd");
std::string body;
auto res = cli_.Get("/compress", headers,
[&](const char *data, uint64_t data_length) {
EXPECT_EQ(100U, data_length);
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("zstd", res->get_header_value("Content-Encoding"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("26", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, ZstdWithoutDecompressing) {
Headers headers;
headers.emplace("Accept-Encoding", "zstd");
cli_.set_decompress(false);
auto res = cli_.Get("/compress", headers);
unsigned char compressed[26] = {0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x64, 0x8d,
0x00, 0x00, 0x50, 0x31, 0x32, 0x33, 0x34,
0x35, 0x36, 0x37, 0x38, 0x39, 0x30, 0x01,
0x00, 0xd7, 0xa9, 0x20, 0x01};
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("zstd", res->get_header_value("Content-Encoding"));
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("26", res->get_header_value("Content-Length"));
EXPECT_EQ(StatusCode::OK_200, res->status);
ASSERT_EQ(26U, res->body.size());
EXPECT_TRUE(std::memcmp(compressed, res->body.data(), sizeof(compressed)) ==
0);
}
TEST_F(ServerTest, ZstdWithContentReceiverWithoutAcceptEncoding) {
Headers headers;
headers.emplace("Accept-Encoding", "");
std::string body;
auto res = cli_.Get("/compress", headers,
[&](const char *data, uint64_t data_length) {
EXPECT_EQ(100U, data_length);
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_TRUE(res->get_header_value("Content-Encoding").empty());
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, NoZstd) {
Headers headers;
headers.emplace("Accept-Encoding", "zstd");
auto res = cli_.Get("/nocompress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(false, res->has_header("Content-Encoding"));
EXPECT_EQ("application/octet-stream", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
res->body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, NoZstdWithContentReceiver) {
Headers headers;
headers.emplace("Accept-Encoding", "zstd");
std::string body;
auto res = cli_.Get("/nocompress", headers,
[&](const char *data, uint64_t data_length) {
EXPECT_EQ(100U, data_length);
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(false, res->has_header("Content-Encoding"));
EXPECT_EQ("application/octet-stream", res->get_header_value("Content-Type"));
EXPECT_EQ("100", res->get_header_value("Content-Length"));
EXPECT_EQ("123456789012345678901234567890123456789012345678901234567890123456"
"7890123456789012345678901234567890",
body);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
// TODO: How to enable zstd ??
TEST_F(ServerTest, MultipartFormDataZstd) {
UploadFormDataItems items = {
{"key1", "test", "", ""},
{"key2", "--abcdefg123", "", ""},
};
Headers headers;
headers.emplace("Accept-Encoding", "zstd");
cli_.set_compress(true);
auto res = cli_.Post("/compress-multipart", headers, items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(ServerTest, PutWithContentProviderWithZstd) {
Headers headers;
headers.emplace("Accept-Encoding", "zstd");
cli_.set_compress(true);
auto res = cli_.Put(
"/put", headers, 3,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.os << "PUT";
return true;
},
"text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("PUT", res->body);
}
// Pre-compression logging tests
TEST_F(ServerTest, PreCompressionLogging) {
// Test data for compression (matches the actual /compress endpoint content)
const std::string test_content =
"123456789012345678901234567890123456789012345678901234567890123456789012"
"3456789012345678901234567890";
// Variables to capture logging data
std::string pre_compression_body;
std::string pre_compression_content_type;
std::string pre_compression_content_encoding;
std::string post_compression_body;
std::string post_compression_content_type;
std::string post_compression_content_encoding;
// Set up pre-compression logger
svr_.set_pre_compression_logger([&](const Request & /*req*/,
const Response &res) {
pre_compression_body = res.body;
pre_compression_content_type = res.get_header_value("Content-Type");
pre_compression_content_encoding = res.get_header_value("Content-Encoding");
});
// Set up post-compression logger
svr_.set_logger([&](const Request & /*req*/, const Response &res) {
post_compression_body = res.body;
post_compression_content_type = res.get_header_value("Content-Type");
post_compression_content_encoding =
res.get_header_value("Content-Encoding");
});
// Test with gzip compression
Headers headers;
headers.emplace("Accept-Encoding", "gzip");
auto res = cli_.Get("/compress", headers);
// Verify response was compressed
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
// Verify pre-compression logger captured uncompressed content
EXPECT_EQ(test_content, pre_compression_body);
EXPECT_EQ("text/plain", pre_compression_content_type);
EXPECT_TRUE(pre_compression_content_encoding
.empty()); // No encoding header before compression
// Verify post-compression logger captured compressed content
EXPECT_NE(test_content,
post_compression_body); // Should be different after compression
EXPECT_EQ("text/plain", post_compression_content_type);
EXPECT_EQ("gzip", post_compression_content_encoding);
// Verify compressed content is smaller
EXPECT_LT(post_compression_body.size(), pre_compression_body.size());
}
TEST_F(ServerTest, PreCompressionLoggingWithBrotli) {
const std::string test_content =
"123456789012345678901234567890123456789012345678901234567890123456789012"
"3456789012345678901234567890";
std::string pre_compression_body;
std::string post_compression_body;
svr_.set_pre_compression_logger(
[&](const Request & /*req*/, const Response &res) {
pre_compression_body = res.body;
});
svr_.set_logger([&](const Request & /*req*/, const Response &res) {
post_compression_body = res.body;
});
Headers headers;
headers.emplace("Accept-Encoding", "br");
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("br", res->get_header_value("Content-Encoding"));
// Verify pre-compression content is uncompressed
EXPECT_EQ(test_content, pre_compression_body);
// Verify post-compression content is compressed
EXPECT_NE(test_content, post_compression_body);
EXPECT_LT(post_compression_body.size(), pre_compression_body.size());
}
TEST_F(ServerTest, PreCompressionLoggingWithoutCompression) {
const std::string test_content =
"123456789012345678901234567890123456789012345678901234567890123456789012"
"3456789012345678901234567890";
std::string pre_compression_body;
std::string post_compression_body;
svr_.set_pre_compression_logger(
[&](const Request & /*req*/, const Response &res) {
pre_compression_body = res.body;
});
svr_.set_logger([&](const Request & /*req*/, const Response &res) {
post_compression_body = res.body;
});
// Request without compression (use /nocompress endpoint)
Headers headers;
auto res = cli_.Get("/nocompress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_TRUE(res->get_header_value("Content-Encoding").empty());
// Pre-compression logger should not be called when no compression is applied
EXPECT_TRUE(
pre_compression_body.empty()); // Pre-compression logger not called
EXPECT_EQ(
test_content,
post_compression_body); // Post-compression logger captures final content
}
TEST_F(ServerTest, LoggerSeesContentLength) {
size_t logged_content_length = 0;
std::string logged_content_length_header;
svr_.set_logger([&](const Request & /*req*/, const Response &res) {
logged_content_length = res.content_length_;
logged_content_length_header = res.get_header_value("Content-Length");
});
auto res = cli_.Get("/nocompress");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(res->body.size(), logged_content_length);
EXPECT_EQ(std::to_string(logged_content_length),
logged_content_length_header);
}
TEST_F(ServerTest, PreCompressionLoggingOnlyPreLogger) {
const std::string test_content =
"123456789012345678901234567890123456789012345678901234567890123456789012"
"3456789012345678901234567890";
std::string pre_compression_body;
bool pre_logger_called = false;
// Set only pre-compression logger
svr_.set_pre_compression_logger(
[&](const Request & /*req*/, const Response &res) {
pre_compression_body = res.body;
pre_logger_called = true;
});
Headers headers;
headers.emplace("Accept-Encoding", "gzip");
auto res = cli_.Get("/compress", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
// Verify pre-compression logger was called
EXPECT_TRUE(pre_logger_called);
EXPECT_EQ(test_content, pre_compression_body);
}
TEST_F(ServerTest, SendLargeBodyAfterRequestLineError) {
{
// Test with Expect: 100-continue header - success case
// Server returns 100 Continue, client sends body, server returns 200 OK
Request req;
req.method = "POST";
req.path = "/post-large";
req.set_header("Expect", "100-continue");
req.body = LARGE_DATA;
Response res;
auto error = Error::Success;
cli_.set_keep_alive(true);
auto ret = cli_.send(req, res, error);
EXPECT_TRUE(ret);
EXPECT_EQ(StatusCode::OK_200, res.status);
EXPECT_EQ(LARGE_DATA, res.body);
}
{
// Test with Expect: 100-continue header - error case
// Client should not send the body when server returns an error
Request req;
req.method = "POST";
req.path = "/post-large?q=" + LONG_QUERY_VALUE;
req.set_header("Expect", "100-continue");
req.body = LARGE_DATA;
Response res;
auto error = Error::Success;
auto start = std::chrono::high_resolution_clock::now();
cli_.set_keep_alive(true);
auto ret = cli_.send(req, res, error);
auto end = std::chrono::high_resolution_clock::now();
auto elapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start)
.count();
// With Expect: 100-continue, request completes successfully but with error
EXPECT_TRUE(ret);
EXPECT_EQ(StatusCode::UriTooLong_414, res.status);
EXPECT_EQ("close", res.get_header_value("Connection"));
EXPECT_FALSE(cli_.is_socket_open());
EXPECT_LE(elapsed, 2000);
}
{
// Send an extra GET request to ensure error recovery without hanging
Request req;
req.method = "GET";
req.path = "/hi";
auto start = std::chrono::high_resolution_clock::now();
auto res = cli_.send(req);
auto end = std::chrono::high_resolution_clock::now();
auto elapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start)
.count();
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("Hello World!", res->body);
EXPECT_LE(elapsed, 500);
}
}
TEST(ZstdDecompressor, ChunkedDecompression) {
std::string data;
for (size_t i = 0; i < 32 * 1024; ++i) {
data.push_back(static_cast<char>('a' + i % 26));
}
std::string compressed_data;
{
httplib::detail::zstd_compressor compressor;
bool result = compressor.compress(
data.data(), data.size(),
/*last=*/true,
[&](const char *compressed_data_chunk, size_t compressed_data_size) {
compressed_data.insert(compressed_data.size(), compressed_data_chunk,
compressed_data_size);
return true;
});
ASSERT_TRUE(result);
}
std::string decompressed_data;
{
httplib::detail::zstd_decompressor decompressor;
// Chunk size is chosen specifically to have a decompressed chunk size equal
// to 16384 bytes 16384 bytes is the size of decompressor output buffer
size_t chunk_size = 130;
for (size_t chunk_begin = 0; chunk_begin < compressed_data.size();
chunk_begin += chunk_size) {
size_t current_chunk_size =
std::min(compressed_data.size() - chunk_begin, chunk_size);
bool result = decompressor.decompress(
compressed_data.data() + chunk_begin, current_chunk_size,
[&](const char *decompressed_data_chunk,
size_t decompressed_data_chunk_size) {
decompressed_data.insert(decompressed_data.size(),
decompressed_data_chunk,
decompressed_data_chunk_size);
return true;
});
ASSERT_TRUE(result);
}
}
ASSERT_EQ(data, decompressed_data);
}
TEST(ZstdDecompressor, Decompress) {
std::string original_text = "Compressed with ZSTD";
unsigned char data[29] = {0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x14, 0xa1, 0x00,
0x00, 0x43, 0x6f, 0x6d, 0x70, 0x72, 0x65, 0x73,
0x73, 0x65, 0x64, 0x20, 0x77, 0x69, 0x74, 0x68,
0x20, 0x5a, 0x53, 0x54, 0x44};
std::string compressed_data(data, data + sizeof(data) / sizeof(data[0]));
std::string decompressed_data;
{
httplib::detail::zstd_decompressor decompressor;
bool result = decompressor.decompress(
compressed_data.data(), compressed_data.size(),
[&](const char *decompressed_data_chunk,
size_t decompressed_data_chunk_size) {
decompressed_data.insert(decompressed_data.size(),
decompressed_data_chunk,
decompressed_data_chunk_size);
return true;
});
ASSERT_TRUE(result);
}
ASSERT_EQ(original_text, decompressed_data);
}
#endif
// Sends a raw request to a server listening at HOST:PORT, writing it in
// separate chunks so that the server sees each part in its own read(). Used to
// place a read boundary at a specific offset of a request body.
static bool send_request_in_parts(time_t read_timeout_sec,
const std::vector<std::string> &parts,
std::string *resp = nullptr,
int port = PORT) {
auto error = Error::Success;
auto client_sock = detail::create_client_socket(
HOST, "", port, AF_UNSPEC, false, false, nullptr,
/*connection_timeout_sec=*/5, 0,
/*read_timeout_sec=*/5, 0,
/*write_timeout_sec=*/5, 0, std::string(), error);
if (client_sock == INVALID_SOCKET) { return false; }
auto ret = detail::process_client_socket(
client_sock, read_timeout_sec, 0, 0, 0, 0,
std::chrono::steady_clock::time_point::min(), [&](Stream &strm) {
for (size_t i = 0; i < parts.size(); i++) {
const auto &part = parts[i];
if (part.size() !=
static_cast<size_t>(strm.write(part.data(), part.size()))) {
return false;
}
if (i + 1 < parts.size()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
char buf[512];
detail::stream_line_reader line_reader(strm, buf, sizeof(buf));
while (line_reader.getline()) {
if (resp) { *resp += line_reader.ptr(); }
}
return true;
});
detail::close_socket(client_sock);
return ret;
}
// Sends a raw request to a server listening at HOST:PORT.
static bool send_request(time_t read_timeout_sec, const std::string &req,
std::string *resp = nullptr, int port = PORT) {
return send_request_in_parts(read_timeout_sec, {req}, resp, port);
}
TEST(ServerRequestParsingTest, TrimWhitespaceFromHeaderValues) {
Server svr;
std::string header_value;
svr.Get("/validate-ws-in-headers", [&](const Request &req, Response &res) {
header_value = req.get_header_value("foo");
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Only space and horizontal tab are whitespace. Make sure other whitespace-
// like characters are not treated the same - use vertical tab and escape.
const std::string req = "GET /validate-ws-in-headers HTTP/1.1\r\n"
"foo: \t \v bar \x1B\t \r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
EXPECT_EQ(header_value, "");
EXPECT_EQ("HTTP/1.1 400 Bad Request", res.substr(0, 24));
}
TEST(HeadersOrderTest, ReceivedFieldsKeepTheirOrder) {
Server svr;
std::string received;
svr.Get("/order", [&](const Request &req, Response &res) {
received = entries_to_string(req.headers);
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET /order HTTP/1.1\r\n"
"X-First: 1\r\n"
"X-Dup: a\r\n"
"X-Second: 2\r\n"
"X-Dup: b\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res, port));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
EXPECT_EQ("X-First=1 X-Dup=a X-Second=2 X-Dup=b Connection=close ", received);
}
TEST(HeadersOrderTest, SentFieldsKeepTheirOrder) {
Server svr;
svr.Get("/cookies", [](const Request & /*req*/, Response &res) {
res.set_header("Set-Cookie", "first=1");
res.set_header("X-Between", "y");
res.set_header("Set-Cookie", "second=2");
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Get("/cookies");
ASSERT_TRUE(res);
EXPECT_EQ(2U, res->get_header_value_count("Set-Cookie"));
EXPECT_EQ("first=1", res->get_header_value("Set-Cookie", "", 0));
EXPECT_EQ("second=2", res->get_header_value("Set-Cookie", "", 1));
}
TEST(ServerResponseSplittingTest, ChunkedTrailerCRLFInjection) {
Server svr;
svr.Get("/injected-trailer", [&](const Request & /*req*/, Response &res) {
auto i = new int(0);
res.set_header("Trailer", "X-Safe, X-Reflected");
res.set_chunked_content_provider(
"text/plain",
[i](size_t /*offset*/, DataSink &sink) {
if (*i == 0) {
sink.os << "body";
} else {
Headers trailer;
// A valid trailer that must survive.
trailer.emplace("X-Safe", "safe");
// Attacker-controlled value carrying CR/LF (response splitting).
trailer.emplace("X-Reflected",
"legit\r\nInjected-Header: pwned\r\nX-Evil: also");
// Attacker-controlled name carrying CR/LF.
trailer.emplace("X-Bad\r\nSet-Cookie: a=1", "x");
sink.done_with_trailer(trailer);
}
(*i)++;
return true;
},
[i](bool /*success*/) { delete i; });
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = std::string("GET /injected-trailer HTTP/1.1\r\n") +
"Host: " + HOST +
"\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
// The injected fields must not appear anywhere in the raw response.
EXPECT_EQ(std::string::npos, res.find("Injected-Header"));
EXPECT_EQ(std::string::npos, res.find("X-Evil"));
EXPECT_EQ(std::string::npos, res.find("Set-Cookie"));
// Invalid trailers are dropped entirely, matching set_header().
EXPECT_EQ(std::string::npos, res.find("X-Reflected:"));
// The valid trailer still passes through.
EXPECT_NE(std::string::npos, res.find("X-Safe: safe"));
}
TEST(ServerResponseSplittingTest, ResponseHeaderCRLFInjection) {
Server svr;
svr.Get("/injected-header", [&](const Request & /*req*/, Response &res) {
// res.headers is a public field an application can populate directly,
// bypassing set_header()'s validation (e.g. reflecting a request value).
// A valid header that must survive.
res.headers.emplace("X-Safe", "safe");
// Attacker-controlled value carrying CR/LF (response splitting).
res.headers.emplace("X-Reflected",
"legit\r\nInjected-Header: pwned\r\nX-Evil: also");
// Attacker-controlled name carrying CR/LF.
res.headers.emplace("X-Bad\r\nSet-Cookie: a=1", "x");
res.set_content("body", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = std::string("GET /injected-header HTTP/1.1\r\n") +
"Host: " + HOST +
"\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
// The injected fields must not appear anywhere in the raw response.
EXPECT_EQ(std::string::npos, res.find("Injected-Header"));
EXPECT_EQ(std::string::npos, res.find("X-Evil"));
EXPECT_EQ(std::string::npos, res.find("Set-Cookie"));
// Invalid headers are dropped entirely, matching set_header().
EXPECT_EQ(std::string::npos, res.find("X-Reflected:"));
// The valid header still passes through.
EXPECT_NE(std::string::npos, res.find("X-Safe: safe"));
}
// Forward declaration: in split builds split.py strips `inline` and moves the
// definition into httplib.cc, so detail::write_request_line is not visible from
// the public httplib.h. Re-declaring it here lets the tests link against the
// symbol in both header-only and split builds.
namespace httplib {
namespace detail {
ssize_t write_request_line(Stream &strm, const std::string &method,
const std::string &path);
} // namespace detail
} // namespace httplib
TEST(RequestLineInjectionTest, RejectsInvalidCharsInTarget) {
// A well-formed target is written verbatim.
{
detail::BufferStream strm;
auto n = detail::write_request_line(strm, "GET", "/path?a=b");
EXPECT_GT(n, 0);
EXPECT_EQ("GET /path?a=b HTTP/1.1\r\n", strm.get_buffer());
}
// 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.
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;
auto n = detail::write_request_line(strm, "GET", evil);
EXPECT_LT(n, 0);
EXPECT_TRUE(strm.get_buffer().empty());
}
}
TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) {
// End-to-end counterpart to RejectsInvalidCharsInTarget. With path encoding
// disabled the client transmits the target verbatim, so a CR/LF-bearing
// target 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) {
res.set_content("ok", "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();
{
Client cli(HOST, PORT);
cli.set_path_encode(false);
// The request is rejected before anything is written, so the connection
// stays silent and the subsequent response read would otherwise block for
// the full default read timeout. Shorten it -- the assertion is on the
// error, not the timeout.
cli.set_read_timeout(1, 0);
auto res = cli.Get("/a\r\nInjected: pwned");
EXPECT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
}
}
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) {
Server svr;
svr.Get("/hi", [&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
svr.Put("/put_hi", [&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
svr.Get("/header_field_value_check",
[&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
svr.CustomRoute("PROPFIND", "/dav",
[&](const Request & /*req*/, Response &res) {
res.status = StatusCode::MultiStatus_207;
});
// Server read timeout must be longer than the client read timeout for the
// bug to reproduce, probably to force the server to process a request
// without a trailing blank line.
const time_t client_read_timeout_sec = 1;
svr.set_read_timeout(std::chrono::seconds(client_read_timeout_sec + 1));
bool listen_thread_ok = false;
thread t = thread([&] { listen_thread_ok = svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
EXPECT_TRUE(listen_thread_ok);
});
svr.wait_until_ready();
ASSERT_TRUE(send_request(client_read_timeout_sec, req, out));
}
TEST(ServerRequestParsingTest, ReadHeadersRegexComplexity) {
// A certain header line causes an exception if the header property is parsed
// naively with a single regex. This occurs with libc++ but not libstdc++.
test_raw_request(
"GET /hi HTTP/1.1\r\n"
" : "
" "
" ");
}
TEST(ServerRequestParsingTest, ReadHeadersRegexComplexity2) {
// A certain header line causes an exception if the header property *name* is
// parsed with a regular expression starting with "(.+?):" - this is a non-
// greedy matcher and requires backtracking when there are a lot of ":"
// characters.
// This occurs with libc++ but not libstdc++.
test_raw_request(
"GET /hi HTTP/1.1\r\n"
":-:::::::::::::::::::::::::::-::::::::::::::::::::::::@-&&&&&&&&&&&"
"--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@-&&&&&&&&"
"&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@-:::::"
"::-:::::::::::::::::@-&&&&&&&&&&&--:::::::-::::::::::::::::::::::::"
":::::-:::::::::::::::::@-&&&&&&&&&&&--:::::::-:::::::::::::::::::::"
"::::::::-:::::::::::::::::@-&&&&&&&--:::::::-::::::::::::::::::::::"
":::::::-:::::::::::::::::@-&&&&&&&&&&&--:::::::-:::::::::::::::::::"
"::::::::::-:::::::::::::::::@-&&&&&::::::::::::-:::::::::::::::::@-"
"&&&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-::::::::::::::::"
":@-&&&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::"
"::::@-&&&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-::::::@-&&"
"&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@"
"::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@-&&&&&&&&&&&"
"--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@-&&&&&&&&"
"&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@-&&&&&"
"&&&&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@-&&"
"&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::::@"
"-&&&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::::"
"::@-&&&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-::::::::::::"
":::::@-&&&&&&&&&&&::-:::::::::::::::::@-&&&&&&&&&&&--:::::::-::::::"
":::::::::::::::::::::::-:::::::::::::::::@-&&&&&&&&&&&--:::::::-:::"
"::::::::::::::::::::::::::-:::::::::::::::::@-&&&&&&&&&&&--:::::::-"
":::::::::::::::::::::::::::::-:::::::::::::::::@-&&&&&&&&&&&---&&:&"
"&&.0------------:-:::::::::::::::::::::::::::::-:::::::::::::::::@-"
"&&&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-::::::::::::::::"
":@-&&&&&&&&&&&--:::::::-:::::::::::::::::::::::::::::-:::::::::::::"
"::::@-&&&&&&&&&&&---&&:&&&.0------------O--------\rH PUTHTTP/1.1\r\n"
"&&&%%%");
}
TEST(ServerRequestParsingTest, ExcessiveWhitespaceInUnparsableHeaderLine) {
// Make sure this doesn't crash the server.
// In a previous version of the header line regex, the "\r" rendered the line
// unparsable and the regex engine repeatedly backtracked, trying to look for
// a new position where the leading white space ended and the field value
// began.
// The crash occurs with libc++ but not libstdc++.
test_raw_request("GET /hi HTTP/1.1\r\n"
"a:" +
std::string(2000, ' ') + '\r' + std::string(20, 'z') +
"\r\n"
"\r\n");
}
TEST(ServerRequestParsingTest, InvalidFirstChunkLengthInRequest) {
std::string out;
test_raw_request("PUT /put_hi HTTP/1.1\r\n"
"Content-Type: text/plain\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"nothex\r\n",
&out);
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
TEST(ServerRequestParsingTest, InvalidSecondChunkLengthInRequest) {
std::string out;
test_raw_request("PUT /put_hi HTTP/1.1\r\n"
"Content-Type: text/plain\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"3\r\n"
"xyz\r\n"
"NaN\r\n",
&out);
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
TEST(ServerRequestParsingTest, ChunkLengthTooHighInRequest) {
std::string out;
test_raw_request("PUT /put_hi HTTP/1.1\r\n"
"Content-Type: text/plain\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
// Length is too large for 64 bits.
"1ffffffffffffffff\r\n"
"xyz\r\n",
&out);
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
TEST(ServerRequestParsingTest, InvalidHeaderTextWithExtraCR) {
test_raw_request("GET /hi HTTP/1.1\r\n"
"Content-Type: text/plain\r\n\r");
}
TEST(ServerRequestParsingTest, InvalidSpaceInURL) {
std::string out;
test_raw_request("GET /h i HTTP/1.1\r\n\r\n", &out);
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;
svr.set_error_handler([&](const Request &req, Response & /*res*/) {
EXPECT_TRUE(!req.remote_addr.empty());
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Send an invalid request line to trigger Bad Request
const std::string bad_req = "BADMETHOD / HTTP/1.1\r\nHost: localhost\r\n\r\n";
std::string out;
ASSERT_TRUE(send_request(5, bad_req, &out));
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
// A custom method with neither Content-Length nor Transfer-Encoding must be
// answered right away rather than blocking on a read that waits for EOF.
TEST(ServerRequestParsingTest, CustomMethodWithoutFraming) {
std::string out;
test_raw_request("PROPFIND /dav HTTP/1.1\r\nHost: localhost\r\n\r\n", &out);
EXPECT_EQ("HTTP/1.1 207 Multi-Status", out.substr(0, 25));
}
TEST(CustomRouteRegistrationTest, RejectsBuiltInMethods) {
const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE",
"CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"};
for (const auto *method : methods) {
Server svr;
svr.CustomRoute(method, "/x", [](const Request &, Response &) {});
EXPECT_FALSE(svr.is_valid()) << method;
EXPECT_FALSE(svr.listen(HOST, PORT)) << method;
}
}
TEST(CustomRouteRegistrationTest, RejectsNonTokenMethods) {
const char *methods[] = {"", "PRO PFIND", "PROP\tFIND", "PROP/FIND",
"PROP,FIND", "PROP:FIND", "PROP(FIND)", "\x01FIND"};
for (const auto *method : methods) {
Server svr;
svr.CustomRoute(method, "/x", [](const Request &, Response &) {});
EXPECT_FALSE(svr.is_valid()) << method;
}
}
TEST(CustomRouteRegistrationTest, AcceptsWebDavAndUpnpMethods) {
const char *methods[] = {"PROPFIND", "PROPPATCH", "MKCOL",
"COPY", "MOVE", "LOCK",
"UNLOCK", "REPORT", "SUBSCRIBE"};
Server svr;
for (const auto *method : methods) {
svr.CustomRoute(method, "/x", [](const Request &, Response &) {});
}
EXPECT_TRUE(svr.is_valid());
}
TEST(CustomRouteRegistrationTest, ContentReaderOverloadRejectsBuiltInMethods) {
Server svr;
svr.CustomRoute("POST", "/x",
[](const Request &, Response &, const ContentReader &) {});
EXPECT_FALSE(svr.is_valid());
}
TEST(CustomRouteRegistrationTest, RejectionIsSticky) {
Server svr;
svr.CustomRoute("PROPFIND", "/a", [](const Request &, Response &) {});
svr.CustomRoute("GET", "/b", [](const Request &, Response &) {});
svr.CustomRoute("MKCOL", "/c", [](const Request &, Response &) {});
EXPECT_FALSE(svr.is_valid());
}
TEST(CustomRouteRegistrationTest, ReportsRejectionToErrorLogger) {
Server svr;
auto captured = Error::Success;
svr.set_error_logger(
[&](const Error &err, const Request * /*req*/) { captured = err; });
svr.CustomRoute("POST", "/x", [](const Request &, Response &) {});
EXPECT_EQ(Error::InvalidHTTPMethod, captured);
}
TEST(ServerRequestParsingTest, InvalidFieldValueContains_CR_LF_NUL) {
std::string out;
std::string request(
"GET /header_field_value_check HTTP/1.1\r\nTest: [\r\x00\n]\r\n\r\n", 55);
test_raw_request(request, &out);
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
TEST(ServerRequestParsingTest, InvalidFieldValueContains_LF) {
std::string out;
std::string request(
"GET /header_field_value_check HTTP/1.1\r\nTest: [\n\n\n]\r\n\r\n", 55);
test_raw_request(request, &out);
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
TEST(ServerRequestParsingTest, InvalidFieldNameContains_PreceedingSpaces) {
std::string out;
std::string request(
"GET /header_field_value_check HTTP/1.1\r\n Test: val\r\n\r\n", 55);
test_raw_request(request, &out);
EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24));
}
TEST(ServerRequestParsingTest, EmptyFieldValue) {
std::string out;
test_raw_request("GET /header_field_value_check HTTP/1.1\r\n"
"Test: \r\n\r\n",
&out);
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
}
TEST(ServerRequestParsingTest, HeaderValueNotPercentDecoded) {
Server svr;
std::string x_custom;
std::string cookie;
std::string xff;
std::string x_unicode;
std::string x_iis;
svr.Get("/check", [&](const Request &req, Response &res) {
x_custom = req.get_header_value("X-Custom");
cookie = req.get_header_value("Cookie");
xff = req.get_header_value("X-Forwarded-For");
x_unicode = req.get_header_value("X-Unicode");
x_iis = req.get_header_value("X-IIS");
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET /check HTTP/1.1\r\n"
"Host: localhost\r\n"
"X-Custom: a%0D%0AInjected: b\r\n"
"Cookie: session%3Dvictim%3B%20admin%3Dyes\r\n"
"X-Forwarded-For: 1.2.3.4%2C5.6.7.8\r\n"
"X-Unicode: %E3%81%82\r\n"
"X-IIS: %u00E9\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
// Every value must be returned verbatim (wire form), with no decoding.
EXPECT_EQ("a%0D%0AInjected: b", x_custom);
EXPECT_EQ("session%3Dvictim%3B%20admin%3Dyes", cookie);
EXPECT_EQ("1.2.3.4%2C5.6.7.8", xff);
EXPECT_EQ("%E3%81%82", x_unicode);
EXPECT_EQ("%u00E9", x_iis);
}
// Applications that previously relied on automatic percent-decoding can
// reproduce the old behavior by explicitly calling decode_path_component()
// or, for RFC 3986 conformance, decode_uri_component().
TEST(ServerRequestParsingTest, HeaderValueExplicitDecodingByApplication) {
Server svr;
std::string decoded;
svr.Get("/check", [&](const Request &req, Response &res) {
decoded = decode_uri_component(req.get_header_value("X-Custom"));
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET /check HTTP/1.1\r\n"
"Host: localhost\r\n"
"X-Custom: hello%20world\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
EXPECT_EQ("hello world", decoded);
}
// Regression test for #2033. Browsers send Referer values that include
// percent-encoded characters such as %0A inside the URL. Decoding the
// header value would either trip the post-decode CR/LF/NUL guard (the
// original bug, returning 400) or, after that guard was relaxed, silently
// store a literal LF — both unacceptable. The wire form must round-trip.
TEST(ServerRequestParsingTest, RefererWithPercentEncodedNewline) {
Server svr;
std::string referer;
svr.Get("/check", [&](const Request &req, Response &res) {
referer = req.get_header_value("Referer");
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET /check HTTP/1.1\r\n"
"Host: localhost\r\n"
"Referer: http://localhost:1111/?q=Hello%0A\r\n"
"Connection: close\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(5, req, &res));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
EXPECT_EQ("http://localhost:1111/?q=Hello%0A", referer);
}
TEST(ServerStopTest, StopServerWithChunkedTransmission) {
Server svr;
svr.Get("/events", [](const Request & /*req*/, Response &res) {
res.set_header("Cache-Control", "no-cache");
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
std::string s = "data:";
s += std::to_string(offset);
s += "\n\n";
auto ret = sink.write(s.data(), s.size());
EXPECT_TRUE(ret);
std::this_thread::sleep_for(std::chrono::seconds(1));
return true;
});
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
svr.wait_until_ready();
Client client(HOST, PORT);
const Headers headers = {{"Accept", "text/event-stream"}};
auto get_thread = std::thread([&client, &headers]() {
auto res = client.Get(
"/events", headers,
[](const char * /*data*/, size_t /*len*/) -> bool { return true; });
});
auto se = detail::scope_exit([&] {
svr.stop();
get_thread.join();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
// Give GET time to get a few messages.
std::this_thread::sleep_for(std::chrono::seconds(2));
}
TEST(ServerStopTest, ClientAccessAfterServerDown) {
httplib::Server svr;
svr.Post("/hi",
[&](const httplib::Request & /*req*/, httplib::Response &res) {
res.status = StatusCode::OK_200;
});
auto thread = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Post("/hi", "data", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
res = cli.Post("/hi", "data", "text/plain");
ASSERT_FALSE(res);
}
TEST(ServerStopTest, ListenFailure) {
Server svr;
auto t = thread([&]() {
auto ret = svr.listen("????", PORT);
EXPECT_FALSE(ret);
});
svr.wait_until_ready();
svr.stop();
t.join();
}
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
TEST(ServerStopTest, Decommision) {
Server svr;
svr.Get("/hi", [&](const Request &, Response &res) { res.body = "hi..."; });
for (int i = 0; i < 4; i++) {
auto is_even = !(i % 2);
std::thread t{[&] {
try {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (is_even) {
throw std::runtime_error("Some thing that happens to go wrong.");
}
svr.listen(HOST, PORT);
} catch (...) { svr.decommission(); }
}};
svr.wait_until_ready();
// Server is up
{
Client cli(HOST, PORT);
auto res = cli.Get("/hi");
if (is_even) {
EXPECT_FALSE(res);
} else {
EXPECT_TRUE(res);
EXPECT_EQ("hi...", res->body);
}
}
svr.stop();
t.join();
// Server is down...
{
Client cli(HOST, PORT);
auto res = cli.Get("/hi");
EXPECT_FALSE(res);
}
}
}
#endif
// Helper function for string body upload progress tests
template <typename SetupHandler, typename ClientCall>
void TestStringBodyUploadProgress(SetupHandler &&setup_handler,
ClientCall &&client_call,
const string &body) {
Server svr;
setup_handler(svr);
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, PORT);
vector<uint64_t> progress_values;
bool progress_called = false;
auto res =
client_call(cli, body, [&](uint64_t current, uint64_t /*total*/) -> bool {
progress_values.push_back(current);
progress_called = true;
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_TRUE(progress_called);
}
TEST(UploadProgressTest, PostStringBodyBasic) {
TestStringBodyUploadProgress(
[](Server &svr) {
svr.Post("/test", [](const Request & /*req*/, Response &res) {
res.set_content("received", "text/plain");
});
},
[](Client &cli, const string &body, UploadProgress progress_callback) {
return cli.Post("/test", body, "text/plain", progress_callback);
},
"test data for upload progress");
}
TEST(UploadProgressTest, PutStringBodyBasic) {
TestStringBodyUploadProgress(
[](Server &svr) {
svr.Put("/test", [](const Request & /*req*/, Response &res) {
res.set_content("put received", "text/plain");
});
},
[](Client &cli, const string &body, UploadProgress progress_callback) {
return cli.Put("/test", body, "text/plain", progress_callback);
},
"put test data for upload progress");
}
TEST(UploadProgressTest, PatchStringBodyBasic) {
TestStringBodyUploadProgress(
[](Server &svr) {
svr.Patch("/test", [](const Request & /*req*/, Response &res) {
res.set_content("patch received", "text/plain");
});
},
[](Client &cli, const string &body, UploadProgress progress_callback) {
return cli.Patch("/test", body, "text/plain", progress_callback);
},
"patch test data for upload progress");
}
// Helper function for content provider upload progress tests
template <typename SetupHandler, typename ClientCall>
void TestContentProviderUploadProgress(SetupHandler &&setup_handler,
ClientCall &&client_call) {
Server svr;
setup_handler(svr);
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, PORT);
vector<uint64_t> progress_values;
auto res =
client_call(cli, [&](uint64_t current, uint64_t /*total*/) -> bool {
progress_values.push_back(current);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_FALSE(progress_values.empty());
}
TEST(UploadProgressTest, PostContentProviderProgress) {
TestContentProviderUploadProgress(
[](Server &svr) {
svr.Post("/test", [](const Request & /*req*/, Response &res) {
res.set_content("provider received", "text/plain");
});
},
[](Client &cli, UploadProgress progress_callback) {
return cli.Post(
"/test", 10,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) -> bool {
sink.os << "test data";
return true;
},
"text/plain", progress_callback);
});
}
// Helper function for multipart upload progress tests
template <typename SetupHandler, typename ClientCall>
void TestMultipartUploadProgress(SetupHandler &&setup_handler,
ClientCall &&client_call,
const string &endpoint) {
Server svr;
setup_handler(svr);
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, PORT);
vector<uint64_t> progress_values;
UploadFormDataItems items = {
{"field1", "value1", "", ""},
{"field2", "longer value for progress tracking test", "", ""},
{"file1", "file content data for upload progress", "test.txt",
"text/plain"}};
auto res = client_call(cli, endpoint, items,
[&](uint64_t current, uint64_t /*total*/) -> bool {
progress_values.push_back(current);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_FALSE(progress_values.empty());
}
TEST(UploadProgressTest, PostMultipartProgress) {
TestMultipartUploadProgress(
[](Server &svr) {
svr.Post("/multipart", [](const Request &req, Response &res) {
EXPECT_TRUE(!req.form.files.empty() || !req.form.fields.empty());
res.set_content("multipart received", "text/plain");
});
},
[](Client &cli, const string &endpoint, const UploadFormDataItems &items,
UploadProgress progress_callback) {
return cli.Post(endpoint, items, progress_callback);
},
"/multipart");
}
// Helper function for basic download progress tests
template <typename SetupHandler, typename ClientCall>
void TestBasicDownloadProgress(SetupHandler &&setup_handler,
ClientCall &&client_call, const string &endpoint,
size_t expected_content_size) {
Server svr;
setup_handler(svr);
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, PORT);
vector<uint64_t> progress_values;
auto res = client_call(cli, endpoint,
[&](uint64_t current, uint64_t /*total*/) -> bool {
progress_values.push_back(current);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_FALSE(progress_values.empty());
EXPECT_EQ(expected_content_size, res->body.size());
}
TEST(DownloadProgressTest, GetBasic) {
TestBasicDownloadProgress(
[](Server &svr) {
svr.Get("/download", [](const Request & /*req*/, Response &res) {
string content(1000, 'D');
res.set_content(content, "text/plain");
});
},
[](Client &cli, const string &endpoint,
DownloadProgress progress_callback) {
return cli.Get(endpoint, progress_callback);
},
"/download", 1000u);
}
// Helper function for content receiver download progress tests
template <typename SetupHandler, typename ClientCall>
void TestContentReceiverDownloadProgress(SetupHandler &&setup_handler,
ClientCall &&client_call,
const string &endpoint,
size_t expected_content_size) {
Server svr;
setup_handler(svr);
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Client cli(HOST, PORT);
vector<uint64_t> progress_values;
string received_body;
auto res = client_call(
cli, endpoint,
[&](const char *data, size_t data_length) -> bool {
received_body.append(data, data_length);
return true;
},
[&](uint64_t current, uint64_t /*total*/) -> bool {
progress_values.push_back(current);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_FALSE(progress_values.empty());
EXPECT_EQ(expected_content_size, received_body.size());
EXPECT_TRUE(res->body.empty());
}
TEST(DownloadProgressTest, GetWithContentReceiver) {
TestContentReceiverDownloadProgress(
[](Server &svr) {
svr.Get("/download-receiver",
[](const Request & /*req*/, Response &res) {
string content(2000, 'R');
res.set_content(content, "text/plain");
});
},
[](Client &cli, const string &endpoint, ContentReceiver content_receiver,
DownloadProgress progress_callback) {
return cli.Get(endpoint, content_receiver, progress_callback);
},
"/download-receiver", 2000u);
}
TEST(StreamingTest, ChunkedZeroLengthWriteDoesNotEndTheBody) {
// A provider reaching a pass with nothing to hand over - an empty buffer
// popped off a queue, say - must not be taken to mean the body is finished.
// It used to end the loop with the terminating chunk unwritten while the
// server still considered the response complete, so the peer waited for an
// end that never arrived.
Server svr;
svr.Get("/stream", [](const Request & /*req*/, Response &res) {
auto step = std::make_shared<int>(0);
res.set_chunked_content_provider("text/plain",
[step](size_t /*offset*/, DataSink &sink) {
switch ((*step)++) {
case 0: sink.write("", 0); break;
case 1: sink.write("hello", 5); break;
default: sink.done(); break;
}
return true;
});
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto listen_se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
// Without the fix the body is never terminated, so bound the wait rather
// than letting the test hang on the default read timeout.
cli.set_read_timeout(5, 0);
auto res = cli.Get("/stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("hello", res->body);
}
TEST(StreamingTest, NoContentLengthStreaming) {
Server svr;
svr.Get("/stream", [](const Request & /*req*/, Response &res) {
res.set_content_provider("text/plain", [](size_t offset, DataSink &sink) {
if (offset < 6) {
sink.os << (offset < 3 ? "a" : "b");
} else {
sink.done();
}
return true;
});
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto listen_se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client client(HOST, PORT);
auto get_thread = std::thread([&client]() {
std::string s;
auto res =
client.Get("/stream", [&s](const char *data, size_t len) -> bool {
s += std::string(data, len);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("aaabbb", s);
});
auto get_se = detail::scope_exit([&] { get_thread.join(); });
// Give GET time to get a few messages.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
TEST(MountTest, Unmount) {
Server svr;
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
svr.set_mount_point("/mount2", "./www2");
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
res = cli.Get("/mount2/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
svr.set_mount_point("/", "./www");
res = cli.Get("/dir/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
svr.remove_mount_point("/");
res = cli.Get("/dir/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
svr.remove_mount_point("/mount2");
res = cli.Get("/mount2/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST(MountTest, PathSegmentBoundary) {
Server svr;
svr.set_mount_point("/mount2", "./www2");
svr.set_mount_point("/", "./www");
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Get("/mount2/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// "/mount2" must not match part-way through a path segment.
res = cli.Get("/mount2dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
// A mount point ending in '/' keeps matching every path below it.
res = cli.Get("/dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MountTest, Redicect) {
Server svr;
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.set_mount_point("/", "./www");
svr.wait_until_ready();
Client cli("localhost", PORT);
auto res = cli.Get("/dir/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
res = cli.Get("/dir");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MovedPermanently_301, res->status);
res = cli.Get("/file");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
res = cli.Get("/file/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
cli.set_follow_location(true);
res = cli.Get("/dir");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MountTest, MultibytesPathName) {
Server svr;
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.set_mount_point("/", "./www");
svr.wait_until_ready();
Client cli("localhost", PORT);
auto res = cli.Get(U8("/日本語Dir/日本語File.txt"));
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(U8("日本語コンテンツ"), res->body);
}
#ifdef _WIN32
// Issue #2435: mmap::open() must succeed even when another handle holds
// the file open for writing (e.g. an active log file).
TEST(MmapTest, OpenWhileFileHeldForWriting) {
const char *path = "mmap_concurrent_writer_test.txt";
const char *content = "hello";
{
std::ofstream f(path, std::ios::binary);
f.write(content, static_cast<std::streamsize>(strlen(content)));
}
auto file_cleanup = detail::scope_exit([&] { std::remove(path); });
HANDLE writer = ::CreateFileA(path, GENERIC_WRITE, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
ASSERT_NE(INVALID_HANDLE_VALUE, writer);
auto handle_cleanup = detail::scope_exit([&] { ::CloseHandle(writer); });
detail::mmap m(path);
ASSERT_TRUE(m.is_open());
EXPECT_EQ(strlen(content), m.size());
EXPECT_EQ(0, std::memcmp(content, m.data(), strlen(content)));
}
#endif
#ifndef _WIN32
// A failed ::mmap() must not be reported as an open mapping, otherwise data()
// hands the caller the MAP_FAILED sentinel. A directory is the easiest way to
// reach it, since ::open() and fstat() succeed for one but ::mmap() doesn't.
TEST(MmapTest, FailedMappingIsNotOpen) {
const char *path = "./mmap_failed_mapping_test_dir";
ASSERT_EQ(0, ::mkdir(path, 0755));
auto dir_cleanup = detail::scope_exit([&] { ::rmdir(path); });
detail::mmap m(path);
EXPECT_FALSE(m.is_open());
EXPECT_EQ(0U, m.size());
EXPECT_NE(static_cast<const void *>(m.data()),
static_cast<const void *>(MAP_FAILED));
}
#endif
TEST(KeepAliveTest, ReadTimeout) {
Server svr;
svr.Get("/a", [&](const Request & /*req*/, Response &res) {
std::this_thread::sleep_for(std::chrono::seconds(2));
res.set_content("a", "text/plain");
});
svr.Get("/b", [&](const Request & /*req*/, Response &res) {
res.set_content("b", "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
cli.set_keep_alive(true);
cli.set_read_timeout(std::chrono::seconds(1));
auto resa = cli.Get("/a");
ASSERT_FALSE(resa);
EXPECT_EQ(Error::Read, resa.error());
auto resb = cli.Get("/b");
ASSERT_TRUE(resb);
EXPECT_EQ(StatusCode::OK_200, resb->status);
EXPECT_EQ("b", resb->body);
}
TEST(KeepAliveTest, MaxCount) {
size_t keep_alive_max_count = 3;
Server svr;
svr.set_keep_alive_max_count(keep_alive_max_count);
svr.Get("/hi", [](const httplib::Request &, httplib::Response &res) {
res.set_content("Hello World!", "text/plain");
});
auto listen_thread = std::thread([&svr] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
cli.set_keep_alive(true);
for (size_t i = 0; i < 5; i++) {
auto result = cli.Get("/hi");
ASSERT_TRUE(result);
EXPECT_EQ(StatusCode::OK_200, result->status);
if (i == keep_alive_max_count - 1) {
EXPECT_EQ("close", result->get_header_value("Connection"));
} else {
EXPECT_FALSE(result->has_header("Connection"));
}
}
}
// A client may pipeline its requests (RFC 9112 9.3.2), so reading one request
// can pull the next one into the server's buffer. The server must serve it
// without waiting for the socket to become readable again. The keep-alive
// timeout outlasts the client's read timeout, so a request left waiting for it
// shows up as a missing response.
static void serve_pipelining_routes(Server &svr,
const std::function<void(int)> &client) {
svr.set_keep_alive_timeout(5);
svr.Get("/hi/(\\d+)", [](const Request &req, Response &res) {
res.set_content("hi " + req.matches[1].str(), "text/plain");
});
svr.Post("/echo", [](const Request &req, Response &res) {
res.set_content("echo " + req.body, "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
client(port);
}
static std::string
send_pipelined_requests(const std::vector<std::string> &parts) {
Server svr;
std::string res;
serve_pipelining_routes(svr, [&](int port) {
EXPECT_TRUE(send_request_in_parts(2, parts, &res, port));
});
return res;
}
static void expect_in_order(const std::string &res,
const std::vector<std::string> &bodies) {
size_t pos = 0;
for (const auto &body : bodies) {
pos = res.find(body, pos);
ASSERT_NE(std::string::npos, pos) << "missing or out of order: " << body;
pos += body.size();
}
}
TEST(KeepAliveTest, PipelinedRequests) {
auto res = send_pipelined_requests(
{"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"POST /echo HTTP/1.1\r\nHost: localhost\r\n"
"Content-Length: 4\r\n\r\nbody"
"GET /hi/3 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"});
expect_in_order(res, {"hi 1", "echo body", "hi 3"});
}
// The second request starts in the server's buffer and ends on the socket.
TEST(KeepAliveTest, PipelinedRequestSplitAcrossReads) {
auto res =
send_pipelined_requests({"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"GET /hi/2 HTTP/1.1\r\nHo",
"st: localhost\r\nConnection: close\r\n\r\n"});
expect_in_order(res, {"hi 1", "hi 2"});
}
// RFC 9112 2.2: an empty line before the request-line is ignored, so an extra
// CRLF after a body does not turn into a 400 for the next request.
TEST(KeepAliveTest, EmptyLineBeforeRequestLineIsIgnored) {
auto res = send_pipelined_requests(
{"POST /echo HTTP/1.1\r\nHost: localhost\r\n"
"Content-Length: 4\r\n\r\nbody\r\n"
"GET /hi/2 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"});
EXPECT_EQ(std::string::npos, res.find("400 Bad Request"));
expect_in_order(res, {"echo body", "hi 2"});
}
// Where an unparsable request ends is unknown, so what follows it in the buffer
// must not be served as the next request.
TEST(KeepAliveTest, PipelinedRequestAfterInvalidRequestIsNotServed) {
auto res = send_pipelined_requests(
{"INVALID REQUEST LINE\r\n\r\n"
"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"});
EXPECT_EQ("HTTP/1.1 400 Bad Request", res.substr(0, 24));
EXPECT_EQ(std::string::npos, res.find("hi 1"));
}
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
// Over TLS, the next request is held by the TLS library as already decrypted
// data rather than on the socket.
TEST(KeepAliveTest, SSLPipelinedRequests) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
std::string res;
serve_pipelining_routes(svr, [&](int port) {
auto error = Error::Success;
auto sock = detail::create_client_socket(
HOST, "", port, AF_UNSPEC, false, false, nullptr,
/*connection_timeout_sec=*/5, 0,
/*read_timeout_sec=*/2, 0,
/*write_timeout_sec=*/5, 0, std::string(), error);
ASSERT_NE(INVALID_SOCKET, sock);
auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
auto ctx = SSL_CTX_new(TLS_client_method());
auto ssl = SSL_new(ctx);
auto ssl_se = detail::scope_exit([&] {
SSL_free(ssl);
SSL_CTX_free(ctx);
});
SSL_set_fd(ssl, static_cast<int>(sock));
ASSERT_EQ(1, SSL_connect(ssl));
// One write, so all three requests arrive in one TLS record
const std::string req =
"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"GET /hi/2 HTTP/1.1\r\nHost: localhost\r\n\r\n"
"GET /hi/3 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
ASSERT_EQ(static_cast<int>(req.size()),
SSL_write(ssl, req.data(), static_cast<int>(req.size())));
char buf[512];
int n;
while ((n = SSL_read(ssl, buf, sizeof(buf))) > 0) {
res.append(buf, static_cast<size_t>(n));
}
});
expect_in_order(res, {"hi 1", "hi 2", "hi 3"});
}
#endif
TEST(KeepAliveTest, Issue1041) {
Server svr;
svr.set_keep_alive_timeout(3);
svr.Get("/hi", [](const httplib::Request &, httplib::Response &res) {
res.set_content("Hello World!", "text/plain");
});
auto listen_thread = std::thread([&svr] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
cli.set_keep_alive(true);
auto result = cli.Get("/hi");
ASSERT_TRUE(result);
EXPECT_EQ(StatusCode::OK_200, result->status);
std::this_thread::sleep_for(std::chrono::seconds(5));
result = cli.Get("/hi");
ASSERT_TRUE(result);
EXPECT_EQ(StatusCode::OK_200, result->status);
}
TEST(KeepAliveTest, Issue1959) {
Server svr;
svr.set_keep_alive_timeout(5);
svr.Get("/a", [&](const Request & /*req*/, Response &res) {
res.set_content("a", "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
if (!svr.is_running()) return;
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
cli.set_keep_alive(true);
using namespace std::chrono;
auto start = steady_clock::now();
cli.Get("/a");
svr.stop();
listen_thread.join();
auto end = steady_clock::now();
auto elapsed = duration_cast<milliseconds>(end - start).count();
EXPECT_LT(elapsed, 5000);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(KeepAliveTest, SSLClientReconnection) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.set_keep_alive_timeout(1);
svr.Get("/hi", [](const httplib::Request &, httplib::Response &res) {
res.set_content("Hello World!", "text/plain");
});
auto listen_thread = std::thread([&svr] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_keep_alive(true);
auto result = cli.Get("/hi");
ASSERT_TRUE(result);
EXPECT_EQ(StatusCode::OK_200, result->status);
result = cli.Get("/hi");
ASSERT_TRUE(result);
EXPECT_EQ(StatusCode::OK_200, result->status);
std::this_thread::sleep_for(std::chrono::seconds(2));
// Recoonect
result = cli.Get("/hi");
ASSERT_TRUE(result);
EXPECT_EQ(StatusCode::OK_200, result->status);
result = cli.Get("/hi");
ASSERT_TRUE(result);
EXPECT_EQ(StatusCode::OK_200, result->status);
}
TEST(KeepAliveTest, SSLClientReconnectionPost) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.set_keep_alive_timeout(1);
std::string content = "reconnect";
svr.Post("/hi", [](const httplib::Request &, httplib::Response &res) {
res.set_content("Hello World!", "text/plain");
});
auto listen_thread = std::thread([&svr] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_keep_alive(true);
auto result = cli.Post(
"/hi", content.size(),
[&content](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.write(content.c_str(), content.size());
return true;
},
"text/plain");
ASSERT_TRUE(result);
EXPECT_EQ(200, result->status);
std::this_thread::sleep_for(std::chrono::seconds(2));
// Recoonect
result = cli.Post(
"/hi", content.size(),
[&content](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.write(content.c_str(), content.size());
return true;
},
"text/plain");
ASSERT_TRUE(result);
EXPECT_EQ(200, result->status);
result = cli.Post(
"/hi", content.size(),
[&content](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.write(content.c_str(), content.size());
return true;
},
"text/plain");
ASSERT_TRUE(result);
EXPECT_EQ(200, result->status);
}
TEST(SNI_AutoDetectionTest, SNI_Logic) {
using namespace httplib::tls;
{
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/sni", [&](const Request &req, Response &res) {
std::string expected = req.sni();
EXPECT_EQ(expected, req.get_param_value("expected"));
res.set_content("ok", "text/plain");
});
auto listen_thread = std::thread([&svr] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
SSLClient cli("localhost", PORT);
cli.enable_server_certificate_verification(false);
auto res = cli.Get("/sni?expected=localhost");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
}
{
SSLClient cli("::1", PORT);
cli.enable_server_certificate_verification(false);
auto res = cli.Get("/sni?expected=");
// NOTE: This may fail if the server is listening on IPv4 only
// (e.g., when localhost resolves to 127.0.0.1 only)
if (res) {
EXPECT_EQ(StatusCode::OK_200, res->status);
} else {
EXPECT_EQ(Error::Connection, res.error());
}
}
}
}
#endif
TEST(ClientProblemDetectionTest, ContentProvider) {
Server svr;
size_t content_length = 1024 * 1024;
svr.Get("/hi", [&](const Request & /*req*/, Response &res) {
res.set_content_provider(
content_length, "text/plain",
[&](size_t offset, size_t length, DataSink &sink) {
auto out_len = std::min(length, static_cast<size_t>(1024));
std::string out(out_len, '@');
sink.write(out.data(), out_len);
return offset < 4096;
},
[](bool success) { ASSERT_FALSE(success); });
});
svr.Get("/empty", [&](const Request & /*req*/, Response &res) {
res.set_content_provider(
0, "text/plain",
[&](size_t /*offset*/, size_t /*length*/, DataSink & /*sink*/) -> bool {
EXPECT_TRUE(false);
return true;
},
[](bool success) { ASSERT_FALSE(success); });
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
{
auto res = cli.Get("/hi", [&](const char * /*data*/,
size_t /*data_length*/) { return false; });
ASSERT_FALSE(res);
}
{
auto res = cli.Get("/empty", [&](const char * /*data*/,
size_t /*data_length*/) { return false; });
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
}
}
TEST(ContentProviderTest, ProviderMakingNoProgressFails) {
// A provider that reports success without writing anything and without
// calling done() used to be handed the same offset and length again on every
// pass, so it spun for as long as the peer stayed connected.
Server svr;
svr.Post("/", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
std::atomic<int> call_count{0};
Client cli(HOST, port);
auto res = cli.Post(
"/", 1024,
[&](size_t /*offset*/, size_t /*length*/, DataSink & /*sink*/) {
// Give up after enough passes to show the spin, so that losing the
// check below fails this test instead of hanging it.
return ++call_count < 100;
},
"text/plain");
ASSERT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
EXPECT_EQ(1, call_count.load());
}
TEST(DataSinkTest, OptionalCallbacksAreCallableByDefault) {
// A writer only has to assign `write`. The other three used to be left as
// empty std::functions, so a provider calling one threw
// std::bad_function_call out of the thread running it and took the whole
// process down.
DataSink sink;
std::string written;
sink.write = [&](const char *data, size_t data_len) {
written.append(data, data_len);
return true;
};
EXPECT_TRUE(sink.is_writable());
sink.done();
sink.done_with_trailer(Headers{{"X-Trailer", "value"}});
EXPECT_TRUE(written.empty());
}
TEST(DataSinkTest, DoneWithTrailerFallsBackToDone) {
// A sink that cannot carry trailers still has to finish.
DataSink sink;
auto done_count = 0;
sink.done = [&]() { done_count++; };
sink.done_with_trailer(Headers{{"X-Trailer", "value"}});
EXPECT_EQ(1, done_count);
}
TEST(DataSinkTest, LengthFramedProviderMayCallDoneAfterWritingEverything) {
Server svr;
const std::string body(4096, 'x');
svr.Get("/", [&](const Request & /*req*/, Response &res) {
res.set_content_provider(body.size(), "text/plain",
[&](size_t offset, size_t length, DataSink &sink) {
sink.write(body.data() + offset, length);
sink.done();
return true;
});
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(body, res->body);
}
TEST(DataSinkTest, LengthFramedProviderThatFinishesEarlyFailsTheResponse) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
1024, "text/plain",
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.write("hello", 5);
sink.done(); // short of the 1024 bytes the response promised
return true;
});
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_read_timeout(5, 0);
// The response is short of its Content-Length, so the client cannot read a
// complete body. What matters is that this returns at all: a no-op done()
// would leave the writer looping over a provider that never makes progress.
auto res = cli.Get("/");
EXPECT_FALSE(res);
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST(DataSinkTest, CompressedRequestProviderThatFinishesEarlyFails) {
// The compressed path builds the whole body before connecting, so this
// fails client-side and never reaches a server.
Client cli(HOST, PORT);
cli.set_compress(true);
auto res = cli.Post(
"/", 1024,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
sink.write("hello", 5);
sink.done(); // short of the 1024 bytes the request announced
return true;
},
"text/plain");
ASSERT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
}
#endif
TEST(ErrorHandlerWithContentProviderTest, ErrorHandler) {
Server svr;
svr.set_error_handler([](Request const &, Response &res) -> void {
res.set_chunked_content_provider(
"text/plain", [](std::size_t const, DataSink &sink) -> bool {
sink.os << "hello";
sink.os << "world";
sink.done();
return true;
});
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
EXPECT_EQ("helloworld", res->body);
}
TEST(LongPollingTest, ClientCloseDetection) {
Server svr;
svr.Get("/events", [&](const Request & /*req*/, Response &res) {
res.set_chunked_content_provider(
"text/plain", [](std::size_t const, DataSink &sink) -> bool {
EXPECT_TRUE(sink.is_writable()); // the socket is alive
sink.os << "hello";
auto count = 10;
while (count > 0 && sink.is_writable()) {
this_thread::sleep_for(chrono::milliseconds(10));
count--;
}
EXPECT_FALSE(sink.is_writable()); // the socket is closed
return true;
});
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
auto res = cli.Get("/events", [&](const char *data, size_t data_length) {
EXPECT_EQ("hello", string(data, data_length));
return false; // close the socket immediately.
});
ASSERT_FALSE(res);
}
TEST(LongPollingTest, ClientCloseDetectionWithStreamOperator) {
Server svr;
svr.Get("/events", [&](const Request & /*req*/, Response &res) {
res.set_chunked_content_provider(
"text/plain", [](std::size_t const, DataSink &sink) -> bool {
EXPECT_TRUE(sink.is_writable()); // the socket is alive
sink.os << "hello";
EXPECT_TRUE(sink.os.good());
// Wait for the client to close the connection
auto count = 10;
while (count > 0 && sink.is_writable()) {
this_thread::sleep_for(chrono::milliseconds(10));
count--;
}
// After client disconnect, write repeatedly until the socket
// write actually fails (small writes may be absorbed by the
// kernel buffer)
std::string chunk(1024, 'x');
for (int i = 0; i < 1000 && sink.os.good(); i++) {
sink.os << chunk;
}
EXPECT_TRUE(sink.os.fail());
return true;
});
});
auto port = svr.bind_to_any_port("localhost");
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", port);
auto res = cli.Get("/events", [&](const char *data, size_t data_length) {
EXPECT_EQ("hello", string(data, data_length));
return false; // close the socket immediately.
});
ASSERT_FALSE(res);
}
TEST(GetWithParametersTest, GetWithParameters) {
Server svr;
svr.Get("/", [&](const Request &req, Response &) {
EXPECT_EQ("world", req.get_param_value("hello"));
EXPECT_EQ("world2", req.get_param_value("hello2"));
EXPECT_EQ("world3", req.get_param_value("hello3"));
});
svr.Get("/params", [&](const Request &req, Response &) {
EXPECT_EQ("world", req.get_param_value("hello"));
EXPECT_EQ("world2", req.get_param_value("hello2"));
EXPECT_EQ("world3", req.get_param_value("hello3"));
});
svr.Get(R"(/resources/([a-z0-9\\-]+))", [&](const Request &req, Response &) {
EXPECT_EQ("resource-id", req.matches[1]);
EXPECT_EQ("foo", req.get_param_value("param1"));
EXPECT_EQ("bar", req.get_param_value("param2"));
});
svr.Get("/users/:id", [&](const Request &req, Response &) {
EXPECT_EQ("user-id", req.path_params.at("id"));
EXPECT_EQ("foo", req.get_param_value("param1"));
EXPECT_EQ("bar", req.get_param_value("param2"));
});
auto listen_thread = std::thread([&svr]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli(HOST, PORT);
Params params;
params.emplace("hello", "world");
params.emplace("hello2", "world2");
params.emplace("hello3", "world3");
auto res = cli.Get("/", params, Headers{});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
Client cli(HOST, PORT);
auto res = cli.Get("/params?hello=world&hello2=world2&hello3=world3");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
Client cli(HOST, PORT);
auto res = cli.Get("/resources/resource-id?param1=foo&param2=bar");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
{
Client cli(HOST, PORT);
auto res = cli.Get("/users/user-id?param1=foo&param2=bar");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
}
TEST(GetWithParametersTest, GetWithParameters2) {
Server svr;
svr.Get("/", [&](const Request &req, Response &res) {
auto text = req.get_param_value("hello");
res.set_content(text, "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
Params params;
params.emplace("hello", "world");
std::string body;
auto res = cli.Get("/", params, Headers{},
[&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("world", body);
}
TEST(GetWithParametersTest, GetWithParamsOnlyNoHeaders) {
Server svr;
svr.Get("/search", [&](const Request &req, Response &res) {
auto q = req.get_param_value("q");
res.set_content(q, "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
// Verify that Get(path, params) works without requiring Headers argument
auto res = cli.Get("/search", httplib::Params{{"q", "cpp-httplib"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("cpp-httplib", res->body);
}
TEST(ClientDefaultHeadersTest, DefaultHeaders_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/range/32"};
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli(host);
#else
Client cli(host);
#endif
cli.set_default_headers({make_range_header({{1, 10}})});
cli.set_connection_timeout(5);
{
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("bcdefghijk", res->body);
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
}
{
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("bcdefghijk", res->body);
EXPECT_EQ(StatusCode::PartialContent_206, res->status);
}
}
TEST(ServerDefaultHeadersTest, DefaultHeaders) {
Server svr;
svr.set_default_headers({{"Hello", "World"}});
svr.Get("/", [&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("localhost", PORT);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
EXPECT_EQ("World", res->get_header_value("Hello"));
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(KeepAliveTest, ReadTimeoutSSL) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/a", [&](const Request & /*req*/, Response &res) {
std::this_thread::sleep_for(std::chrono::seconds(2));
res.set_content("a", "text/plain");
});
svr.Get("/b", [&](const Request & /*req*/, Response &res) {
res.set_content("b", "text/plain");
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli("localhost", PORT);
cli.enable_server_certificate_verification(false);
cli.set_keep_alive(true);
cli.set_read_timeout(std::chrono::seconds(1));
auto resa = cli.Get("/a");
ASSERT_TRUE(!resa);
EXPECT_EQ(Error::Read, resa.error());
auto resb = cli.Get("/b");
ASSERT_TRUE(resb);
EXPECT_EQ(StatusCode::OK_200, resb->status);
EXPECT_EQ("b", resb->body);
}
// Closing an idle keep-alive connection sends close_notify and returns. The
// server must not wait for the client's close_notify: an idle client never
// sends one, so the worker would be held until the read timeout expires, and
// stop() would wait for it.
TEST(KeepAliveTest, SSLIdleCloseDoesNotWaitForPeer) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.set_keep_alive_timeout(1);
svr.set_read_timeout(10, 0);
svr.Get("/", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
if (listen_thread.joinable()) {
svr.stop();
listen_thread.join();
}
});
svr.wait_until_ready();
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(false);
cli.set_keep_alive(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// Stay idle past the keep-alive timeout so the server closes the connection.
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
auto start = std::chrono::steady_clock::now();
svr.stop();
listen_thread.join();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
EXPECT_LT(elapsed, 3000);
}
#endif
class ServerTestWithAI_PASSIVE : public ::testing::Test {
protected:
ServerTestWithAI_PASSIVE()
: cli_(HOST, PORT)
#ifdef CPPHTTPLIB_SSL_ENABLED
,
svr_(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE)
#endif
{
#ifdef CPPHTTPLIB_SSL_ENABLED
cli_.enable_server_certificate_verification(false);
#endif
}
virtual void SetUp() {
svr_.Get("/hi", [&](const Request & /*req*/, Response &res) {
res.set_content("Hello World!", "text/plain");
});
t_ = thread(
[&]() { ASSERT_TRUE(svr_.listen(std::string(), PORT, AI_PASSIVE)); });
svr_.wait_until_ready();
}
virtual void TearDown() {
svr_.stop();
t_.join();
}
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli_;
SSLServer svr_;
#else
Client cli_;
Server svr_;
#endif
thread t_;
};
TEST_F(ServerTestWithAI_PASSIVE, GetMethod200) {
auto res = cli_.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("text/plain", res->get_header_value("Content-Type"));
EXPECT_EQ("Hello World!", res->body);
}
class ServerUpDownTest : public ::testing::Test {
protected:
ServerUpDownTest() : cli_(HOST, PORT) {}
virtual void SetUp() {
t_ = thread([&]() {
svr_.bind_to_any_port(HOST);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
ASSERT_TRUE(svr_.listen_after_bind());
});
svr_.wait_until_ready();
}
virtual void TearDown() {
svr_.stop();
t_.join();
}
Client cli_;
Server svr_;
thread t_;
};
TEST_F(ServerUpDownTest, QuickStartStop) {
// Should not crash, especially when run with
// --gtest_filter=ServerUpDownTest.QuickStartStop --gtest_repeat=1000
}
class PayloadMaxLengthTest : public ::testing::Test {
protected:
PayloadMaxLengthTest()
: cli_(HOST, PORT)
#ifdef CPPHTTPLIB_SSL_ENABLED
,
svr_(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE)
#endif
{
#ifdef CPPHTTPLIB_SSL_ENABLED
cli_.enable_server_certificate_verification(false);
#endif
}
virtual void SetUp() {
svr_.set_payload_max_length(8);
svr_.Post("/test", [&](const Request & /*req*/, Response &res) {
res.set_content("test", "text/plain");
});
t_ = thread([&]() { ASSERT_TRUE(svr_.listen(HOST, PORT)); });
svr_.wait_until_ready();
}
virtual void TearDown() {
svr_.stop();
t_.join();
}
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli_;
SSLServer svr_;
#else
Client cli_;
Server svr_;
#endif
thread t_;
};
TEST_F(PayloadMaxLengthTest, ExceedLimit) {
auto res = cli_.Post("/test", "123456789", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
res = cli_.Post("/test", "12345678", "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(PayloadMaxLengthTest, ChunkedEncodingSecurityTest) {
// Test chunked encoding with payload exceeding the 8-byte limit
std::string large_chunked_data(16, 'A'); // 16 bytes, exceeds 8-byte limit
auto res = cli_.Post("/test", large_chunked_data, "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
}
TEST_F(PayloadMaxLengthTest, ChunkedEncodingWithinLimit) {
// Test chunked encoding with payload within the 8-byte limit
std::string small_chunked_data(4, 'B'); // 4 bytes, within 8-byte limit
auto res = cli_.Post("/test", small_chunked_data, "text/plain");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(PayloadMaxLengthTest, RawSocketChunkedTest) {
// Test using send_request to send chunked data exceeding payload limit
std::string chunked_request = "POST /test HTTP/1.1\r\n"
"Host: " +
std::string(HOST) + ":" + std::to_string(PORT) +
"\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"\r\n"
"a\r\n" // 10 bytes chunk (exceeds 8-byte limit)
"0123456789\r\n"
"0\r\n" // End chunk
"\r\n";
std::string response;
bool result = send_request(1, chunked_request, &response);
if (!result) {
// If send_request fails, it might be because the server closed the
// connection due to payload limit enforcement, which is acceptable
SUCCEED()
<< "Server rejected oversized chunked request (connection closed)";
} else {
// If we got a response, check if it's an error response or connection was
// closed early Short response length indicates connection was closed due to
// payload limit
if (response.length() <= 10) {
SUCCEED() << "Server closed connection for oversized chunked request";
} else {
// Check for error status codes
EXPECT_TRUE(response.find("413") != std::string::npos ||
response.find("Payload Too Large") != std::string::npos ||
response.find("400") != std::string::npos);
}
}
}
TEST_F(PayloadMaxLengthTest, NoContentLengthPayloadLimit) {
// Test request without Content-Length header exceeding payload limit
std::string request_without_content_length = "POST /test HTTP/1.1\r\n"
"Host: " +
std::string(HOST) + ":" +
std::to_string(PORT) +
"\r\n"
"Connection: close\r\n"
"\r\n";
// Add payload exceeding the 8-byte limit
std::string large_payload(16, 'X'); // 16 bytes, exceeds 8-byte limit
request_without_content_length += large_payload;
std::string response;
bool result = send_request(1, request_without_content_length, &response);
if (!result) {
// If send_request fails, server likely closed connection due to payload
// limit
SUCCEED() << "Server rejected oversized request without Content-Length "
"(connection closed)";
} else {
// Check if server responded with error or closed connection early
if (response.length() <= 10) {
SUCCEED() << "Server closed connection for oversized request without "
"Content-Length";
} else {
// Check for error status codes
EXPECT_TRUE(response.find("413") != std::string::npos ||
response.find("Payload Too Large") != std::string::npos ||
response.find("400") != std::string::npos);
}
}
}
TEST_F(PayloadMaxLengthTest, NoContentLengthWithinLimit) {
// Test request without Content-Length header within payload limit
std::string request_without_content_length = "POST /test HTTP/1.1\r\n"
"Host: " +
std::string(HOST) + ":" +
std::to_string(PORT) +
"\r\n"
"Connection: close\r\n"
"\r\n";
// Add payload within the 8-byte limit
std::string small_payload(4, 'Y'); // 4 bytes, within 8-byte limit
request_without_content_length += small_payload;
std::string response;
bool result = send_request(1, request_without_content_length, &response);
// For requests without Content-Length, the server may have different behavior
// The key is that it should not reject due to payload limit for small
// payloads
if (result) {
// Check for any HTTP response (success or error, but not connection closed)
if (response.length() > 10) {
SUCCEED()
<< "Server processed request without Content-Length within limit";
} else {
// Short response might indicate connection closed, which is acceptable
SUCCEED() << "Server closed connection for request without "
"Content-Length (acceptable behavior)";
}
} else {
// Connection failure might be due to protocol requirements
SUCCEED() << "Connection issue with request without Content-Length "
"(environment-specific)";
}
}
class LargePayloadMaxLengthTest : public ::testing::Test {
protected:
LargePayloadMaxLengthTest()
: cli_(HOST, PORT)
#ifdef CPPHTTPLIB_SSL_ENABLED
,
svr_(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE)
#endif
{
#ifdef CPPHTTPLIB_SSL_ENABLED
cli_.enable_server_certificate_verification(false);
#endif
}
virtual void SetUp() {
// Set 10MB payload limit
const size_t LARGE_PAYLOAD_LIMIT = 10 * 1024 * 1024; // 10MB
svr_.set_payload_max_length(LARGE_PAYLOAD_LIMIT);
svr_.Post("/test", [&](const Request & /*req*/, Response &res) {
res.set_content("Large payload test", "text/plain");
});
t_ = thread([&]() { ASSERT_TRUE(svr_.listen(HOST, PORT)); });
svr_.wait_until_ready();
}
virtual void TearDown() {
svr_.stop();
t_.join();
}
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLClient cli_;
SSLServer svr_;
#else
Client cli_;
Server svr_;
#endif
thread t_;
};
TEST_F(LargePayloadMaxLengthTest, ChunkedEncodingWithin10MB) {
// Test chunked encoding with payload within 10MB limit
std::string medium_payload(5 * 1024 * 1024,
'A'); // 5MB payload, within 10MB limit
auto res = cli_.Post("/test", medium_payload, "application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST_F(LargePayloadMaxLengthTest, ChunkedEncodingExceeds10MB) {
// Test chunked encoding with payload exceeding 10MB limit
std::string large_payload(12 * 1024 * 1024,
'B'); // 12MB payload, exceeds 10MB limit
auto res = cli_.Post("/test", large_payload, "application/octet-stream");
// Server may either return 413 or close the connection
if (res) {
EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
} else {
SUCCEED() << "Server closed connection for payload exceeding 10MB limit";
}
}
TEST_F(LargePayloadMaxLengthTest, NoContentLengthWithin10MB) {
// Test request without Content-Length header within 10MB limit
std::string request_without_content_length = "POST /test HTTP/1.1\r\n"
"Host: " +
std::string(HOST) + ":" +
std::to_string(PORT) +
"\r\n"
"Connection: close\r\n"
"\r\n";
// Add 1MB payload (within 10MB limit)
std::string medium_payload(1024 * 1024, 'C'); // 1MB payload
request_without_content_length += medium_payload;
std::string response;
bool result = send_request(5, request_without_content_length, &response);
if (result) {
// Should get a proper HTTP response for payloads within limit
if (response.length() > 10) {
SUCCEED() << "Server processed 1MB request without Content-Length within "
"10MB limit";
} else {
SUCCEED() << "Server closed connection (acceptable behavior for no "
"Content-Length)";
}
} else {
SUCCEED() << "Connection issue with 1MB payload (environment-specific)";
}
}
TEST_F(LargePayloadMaxLengthTest, NoContentLengthExceeds10MB) {
// Test request without Content-Length header exceeding 10MB limit
std::string request_without_content_length = "POST /test HTTP/1.1\r\n"
"Host: " +
std::string(HOST) + ":" +
std::to_string(PORT) +
"\r\n"
"Connection: close\r\n"
"\r\n";
// Add 12MB payload (exceeds 10MB limit)
std::string large_payload(12 * 1024 * 1024, 'D'); // 12MB payload
request_without_content_length += large_payload;
std::string response;
bool result = send_request(10, request_without_content_length, &response);
if (!result) {
// Server should close connection due to payload limit
SUCCEED() << "Server rejected 12MB request without Content-Length "
"(connection closed)";
} else {
// Check for error response
if (response.length() <= 10) {
SUCCEED()
<< "Server closed connection for 12MB request exceeding 10MB limit";
} else {
EXPECT_TRUE(response.find("413") != std::string::npos ||
response.find("Payload Too Large") != std::string::npos ||
response.find("400") != std::string::npos);
}
}
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
// `payload_max_length` is not enforced on decompressed body in ContentReader
// path.
TEST(PayloadLimitBypassTest, StreamingGzipDecompression) {
Server svr;
const size_t LIMIT = 64 * 1024; // 64KB
svr.set_payload_max_length(LIMIT);
size_t total = 0;
svr.Post("/stream", [&](const Request & /*req*/, Response &res,
const ContentReader &content_reader) {
content_reader([&](const char * /*data*/, size_t len) {
total += len;
return true;
});
res.status = 200;
res.set_content("stream_ok", "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();
// Prepare 256KB raw data and gzip-compress it
std::string raw(256 * 1024, 'A');
std::string gz;
{
z_stream zs{};
deflateInit2(&zs, Z_BEST_COMPRESSION, Z_DEFLATED, 15 + 16, 8,
Z_DEFAULT_STRATEGY);
zs.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(raw.data()));
zs.avail_in = static_cast<uInt>(raw.size());
char outbuf[4096];
int ret;
do {
zs.next_out = reinterpret_cast<Bytef *>(outbuf);
zs.avail_out = sizeof(outbuf);
ret = deflate(&zs, Z_FINISH);
gz.append(outbuf, sizeof(outbuf) - zs.avail_out);
} while (ret != Z_STREAM_END);
deflateEnd(&zs);
}
Client cli(HOST, PORT);
cli.set_connection_timeout(std::chrono::seconds(5));
Headers headers = {{"Content-Encoding", "gzip"}};
auto res = cli.Post("/stream", headers, gz.data(), gz.size(),
"application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
// Server must reject oversized decompressed payloads with 413.
EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
// Decompressed bytes delivered to the handler must not exceed LIMIT.
EXPECT_LE(total, LIMIT);
}
#ifndef CPPHTTPLIB_SSL_ENABLED
// For a request with neither Content-Length nor Transfer-Encoding, the
// non-SSL raw-socket fallback in read_content_core() used to hand the
// compressed wire bytes straight to the ContentReader without ever routing
// them through the decompressor, so payload_max_length_ only bounded the
// compressed size on the wire, not the decompressed size.
TEST(PayloadLimitBypassTest, UnframedGzipDecompression) {
Server svr;
const size_t LIMIT = 64 * 1024; // 64KB
svr.set_payload_max_length(LIMIT);
size_t total = 0;
svr.Post("/stream", [&](const Request & /*req*/, Response &res,
const ContentReader &content_reader) {
content_reader([&](const char * /*data*/, size_t len) {
total += len;
return true;
});
res.status = 200;
res.set_content("stream_ok", "text/plain");
});
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();
// Prepare 256KB raw data and gzip-compress it, same payload as the
// StreamingGzipDecompression test above.
std::string raw(256 * 1024, 'A');
std::string gz;
{
httplib::detail::gzip_compressor compressor;
bool result = compressor.compress(raw.data(), raw.size(), /*last=*/true,
[&](const char *chunk, size_t len) {
gz.append(chunk, len);
return true;
});
ASSERT_TRUE(result);
}
// Send the gzip body over raw TCP with neither Content-Length nor
// Transfer-Encoding, and Connection: close so the server's stray-body scan
// kicks in.
std::string req = "POST /stream HTTP/1.1\r\n"
"Host: " +
std::string(HOST) + ":" + std::to_string(port) +
"\r\n"
"Content-Encoding: gzip\r\n"
"Connection: close\r\n"
"\r\n" +
gz;
std::string response;
ASSERT_TRUE(send_request(5, req, &response, port));
// Decompressed bytes delivered to the handler must not exceed LIMIT.
EXPECT_LE(total, LIMIT);
}
#endif
#endif
// Some servers misuse Content-Encoding to advertise a character set, e.g.
// `Content-Encoding: UTF-8` on a JPEG. Such a value is not a content coding, so
// the payload must be passed through untouched instead of being rejected.
TEST(ContentEncodingTest, UnknownEncodingIsPassedThrough) {
const std::string body = "\xff\xd8\xff\xe0 not really a jpeg";
Server svr;
svr.Get("/image", [&](const Request & /*req*/, Response &res) {
res.set_content(body, "image/jpeg");
res.set_header("Content-Encoding", "UTF-8");
});
svr.Get("/identity", [&](const Request & /*req*/, Response &res) {
res.set_content(body, "image/jpeg");
res.set_header("Content-Encoding", "identity");
});
svr.Post("/echo", [](const Request &req, Response &res) {
res.set_content(req.body, "image/jpeg");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
{
auto res = cli.Get("/image");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(body, res->body);
}
{
auto res = cli.Get("/identity");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(body, res->body);
}
{
// The same applies to a request body reaching the server.
Headers headers = {{"Content-Encoding", "UTF-8"}};
auto res = cli.Post("/echo", headers, body, "image/jpeg");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(body, res->body);
}
}
// "Hello World!" as gzip. Hard-coded so that the test below can serve a
// gzip-encoded response even when the build has no zlib support.
static const char GZIPPED_HELLO_WORLD[] = {
'\x1f', '\x8b', '\x08', '\x00', '\x00', '\x00', '\x00', '\x00',
'\x02', '\x03', '\xf3', '\x48', '\xcd', '\xc9', '\xc9', '\x57',
'\x08', '\xcf', '\x2f', '\xca', '\x49', '\x51', '\x04', '\x00',
'\xa3', '\x1c', '\x29', '\x1c', '\x0c', '\x00', '\x00', '\x00'};
// A content coding is a whole token, not something the field value merely
// contains. Matching "br" and "zstd" as substrings made unrelated values such
// as "fibre" look like Brotli, and turned a multi-coding value like
// "gzip, br" into a Brotli-labeled body, so a decompressor was run over data
// it was never meant to see.
TEST(ContentEncodingTest, SubstringOfACodingIsNotTheCoding) {
const std::string body = "\xff\xd8\xff\xe0 not really a jpeg";
Server svr;
svr.Get("/fibre", [&](const Request & /*req*/, Response &res) {
res.set_content(body, "image/jpeg");
res.set_header("Content-Encoding", "fibre");
});
svr.Get("/multi", [&](const Request & /*req*/, Response &res) {
res.set_content(body, "image/jpeg");
res.set_header("Content-Encoding", "gzip, br");
});
svr.Get("/zstdish", [&](const Request & /*req*/, Response &res) {
res.set_content(body, "image/jpeg");
res.set_header("Content-Encoding", "x-zstd-ish");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
for (const char *path : {"/fibre", "/multi", "/zstdish"}) {
auto res = cli.Get(path);
ASSERT_TRUE(res) << path << " -> " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status) << path;
// Not a coding we recognize, so the payload comes back untouched
EXPECT_EQ(body, res->body) << path;
}
}
// A content coding cpp-httplib recognizes but was not built with must be
// reported as such. Handing the still-compressed payload back to the caller
// would silently corrupt it.
TEST(ContentEncodingTest, KnownEncodingWithoutSupportIsReported) {
const std::string gzipped(GZIPPED_HELLO_WORLD, sizeof(GZIPPED_HELLO_WORLD));
Server svr;
// "image/jpeg" keeps the server from applying a content coding of its own,
// so the hand-crafted Content-Encoding below survives.
svr.Get("/gzipped", [&](const Request & /*req*/, Response &res) {
res.set_content(gzipped, "image/jpeg");
res.set_header("Content-Encoding", "gzip");
});
// Content codings are case-insensitive (RFC 9110 8.4.1).
svr.Get("/gzipped-uppercase", [&](const Request & /*req*/, Response &res) {
res.set_content(gzipped, "image/jpeg");
res.set_header("Content-Encoding", "GZIP");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
{
auto res = cli.Get("/gzipped");
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Hello World!", res->body);
#else
ASSERT_FALSE(res);
EXPECT_EQ(Error::UnsupportedContentEncoding, res.error());
#endif
}
{
// open_stream() must behave the same way.
auto handle = cli.open_stream("GET", "/gzipped");
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
ASSERT_TRUE(handle.is_valid());
std::string received;
char buf[256];
ssize_t n;
while ((n = handle.read(buf, sizeof(buf))) > 0) {
received.append(buf, static_cast<size_t>(n));
}
EXPECT_EQ("Hello World!", received);
#else
EXPECT_FALSE(handle.is_valid());
EXPECT_EQ(Error::UnsupportedContentEncoding, handle.error);
#endif
}
{
// A differently-cased coding must not be mistaken for an unknown one, or
// the body would be handed back still compressed.
auto res = cli.Get("/gzipped-uppercase");
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Hello World!", res->body);
#else
ASSERT_FALSE(res);
EXPECT_EQ(Error::UnsupportedContentEncoding, res.error());
#endif
}
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
// A handler serving pre-compressed content (e.g. build-time gzipped static
// assets) sets Content-Encoding itself. The server used to pick a coding from
// Accept-Encoding and the content type alone, gzipping the already-gzipped
// body a second time and appending a second Content-Encoding field line, so
// clients that decode one coding per listed value handed back raw gzip bytes.
TEST(ContentEncodingTest, PreEncodedResponseIsNotCompressedAgain) {
const std::string gzipped(GZIPPED_HELLO_WORLD, sizeof(GZIPPED_HELLO_WORLD));
Server svr;
// text/plain is a compressible type, so only the pre-set Content-Encoding
// keeps the server from applying a coding of its own.
svr.Get("/pre-gzipped", [&](const Request & /*req*/, Response &res) {
res.set_content(gzipped, "text/plain");
res.set_header("Content-Encoding", "gzip");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
Headers headers = {{"Accept-Encoding", "gzip"}};
auto res = cli.Get("/pre-gzipped", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("Hello World!", res->body);
}
// A chunked provider settles its coding where the headers are written and
// reuses it at write time. Both ends of that have to hold: a handler's own
// Content-Encoding suppresses the coding, and a response without one is still
// compressed as it always was.
TEST(ContentEncodingTest, PreEncodedChunkedResponseIsNotCompressedAgain) {
const std::string gzipped(GZIPPED_HELLO_WORLD, sizeof(GZIPPED_HELLO_WORLD));
const std::string plain = "Hello World! Hello World! Hello World!";
Server svr;
svr.Get("/pre-gzipped", [&](const Request & /*req*/, Response &res) {
res.set_header("Content-Encoding", "gzip");
res.set_chunked_content_provider(
"text/plain", [gzipped](size_t /*offset*/, DataSink &sink) {
sink.write(gzipped.data(), gzipped.size());
sink.done();
return true;
});
});
svr.Get("/negotiated", [&](const Request & /*req*/, Response &res) {
res.set_chunked_content_provider(
"text/plain", [plain](size_t /*offset*/, DataSink &sink) {
sink.write(plain.data(), plain.size());
sink.done();
return true;
});
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
Headers headers = {{"Accept-Encoding", "gzip"}};
{
auto res = cli.Get("/pre-gzipped", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("Hello World!", res->body);
}
{
auto res = cli.Get("/negotiated", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ(plain, res->body);
}
}
#endif
// RFC 9110 Section 5.3: a Content-Encoding split over several field lines is
// the same message as the comma-joined one, so both have to be read the same
// way. Reading only the first line made "gzip" followed by "gzip" look like a
// single gzip coding, and a body the sender says was encoded twice was handed
// back after one pass, still compressed but presented as decoded.
TEST(ContentEncodingTest, DuplicateFieldLinesAreTheSameAsTheJoinedValue) {
const std::string body = "\xff\xd8\xff\xe0 not really a jpeg";
Server svr;
svr.Get("/split", [&](const Request & /*req*/, Response &res) {
res.set_content(body, "image/jpeg");
res.headers.emplace("Content-Encoding", "gzip");
res.headers.emplace("Content-Encoding", "gzip");
});
svr.Get("/joined", [&](const Request & /*req*/, Response &res) {
res.set_content(body, "image/jpeg");
res.set_header("Content-Encoding", "gzip, gzip");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
// Two codings is not something cpp-httplib decodes, so both representations
// take the pass-through path rather than one of them being gunzipped once.
for (const char *path : {"/split", "/joined"}) {
auto res = cli.Get(path);
ASSERT_TRUE(res) << path << " -> " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status) << path;
EXPECT_EQ(body, res->body) << path;
}
}
// Regression test for DoS vulnerability: a malicious server sending a response
// without Content-Length header must not cause unbounded memory consumption on
// the client side. The client should stop reading after a reasonable limit,
// similar to the server-side set_payload_max_length protection.
TEST(ClientVulnerabilityTest, UnboundedReadWithoutContentLength) {
constexpr size_t CLIENT_READ_LIMIT = 2 * 1024 * 1024; // 2MB safety limit
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto server_thread = std::thread([] {
constexpr size_t MALICIOUS_DATA_SIZE = 10 * 1024 * 1024; // 10MB from server
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
::listen(srv, 1);
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
char buf[4096];
::recv(cli, buf, sizeof(buf), 0);
// Malicious response: no Content-Length, no chunked encoding
std::string response_header = "HTTP/1.1 200 OK\r\n"
"Connection: close\r\n"
"\r\n";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response_header.c_str()),
static_cast<int>(response_header.size()),
#else
response_header.c_str(), response_header.size(),
#endif
0);
// Send 10MB of data
std::string chunk(64 * 1024, 'A');
size_t total_sent = 0;
while (total_sent < MALICIOUS_DATA_SIZE) {
auto to_send = std::min(chunk.size(), MALICIOUS_DATA_SIZE - total_sent);
auto sent = ::send(cli,
#ifdef _WIN32
static_cast<const char *>(chunk.c_str()),
static_cast<int>(to_send),
#else
chunk.c_str(), to_send,
#endif
0);
if (sent <= 0) break;
total_sent += static_cast<size_t>(sent);
}
detail::close_socket(cli);
}
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
size_t total_read = 0;
{
Client cli("127.0.0.1", PORT + 2);
cli.set_read_timeout(5, 0);
cli.set_payload_max_length(CLIENT_READ_LIMIT);
auto stream = cli.open_stream("GET", "/malicious");
ASSERT_TRUE(stream.is_valid());
char buffer[64 * 1024];
ssize_t n;
while ((n = stream.read(buffer, sizeof(buffer))) > 0) {
total_read += static_cast<size_t>(n);
}
} // StreamHandle and Client destroyed here, closing the socket
server_thread.join();
// With set_payload_max_length, the client must stop reading before consuming
// all 10MB. The read loop should be cut off at or near the configured limit.
EXPECT_LE(total_read, CLIENT_READ_LIMIT)
<< "Client read " << total_read << " bytes, exceeding the configured "
<< "payload_max_length of " << CLIENT_READ_LIMIT << " bytes.";
}
// Verify that set_payload_max_length(0) means "no limit" and allows reading
// the entire response body without truncation.
TEST(ClientVulnerabilityTest, PayloadMaxLengthZeroMeansNoLimit) {
static constexpr size_t DATA_SIZE = 4 * 1024 * 1024; // 4MB from server
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto server_thread = std::thread([] {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
::listen(srv, 1);
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
char buf[4096];
::recv(cli, buf, sizeof(buf), 0);
std::string response_header = "HTTP/1.1 200 OK\r\n"
"Connection: close\r\n"
"\r\n";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response_header.c_str()),
static_cast<int>(response_header.size()),
#else
response_header.c_str(), response_header.size(),
#endif
0);
std::string chunk(64 * 1024, 'A');
size_t total_sent = 0;
while (total_sent < DATA_SIZE) {
auto to_send = std::min(chunk.size(), DATA_SIZE - total_sent);
auto sent = ::send(cli,
#ifdef _WIN32
static_cast<const char *>(chunk.c_str()),
static_cast<int>(to_send),
#else
chunk.c_str(), to_send,
#endif
0);
if (sent <= 0) break;
total_sent += static_cast<size_t>(sent);
}
#ifdef _WIN32
::shutdown(cli, SD_SEND);
#else
::shutdown(cli, SHUT_WR);
#endif
// Drain until the client closes its end, ensuring all data is delivered
char drain[1024];
while (::recv(cli, drain, sizeof(drain), 0) > 0) {}
detail::close_socket(cli);
}
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
size_t total_read = 0;
{
Client cli("127.0.0.1", PORT + 2);
cli.set_read_timeout(5, 0);
cli.set_payload_max_length(0); // 0 means no limit
auto stream = cli.open_stream("GET", "/data");
ASSERT_TRUE(stream.is_valid());
char buffer[64 * 1024];
ssize_t n;
while ((n = stream.read(buffer, sizeof(buffer))) > 0) {
total_read += static_cast<size_t>(n);
}
}
server_thread.join();
EXPECT_EQ(total_read, DATA_SIZE)
<< "With payload_max_length(0), the client should read all " << DATA_SIZE
<< " bytes without truncation, but only read " << total_read << " bytes.";
}
// Regression test for OSS-Fuzz issue 508342856: a malicious server sending an
// enormous Content-Length must not cause the client to pre-allocate a huge
// response body buffer. The reservation is capped at payload_max_length_, and
// the read itself fails when the body exceeds the limit.
TEST(ClientVulnerabilityTest, HugeContentLengthDoesNotPreallocate) {
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto server_thread = std::thread([] {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
::listen(srv, 1);
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
char buf[4096];
::recv(cli, buf, sizeof(buf), 0);
// Malicious response: claim a 20GB body but send only a tiny payload.
std::string response = "HTTP/1.1 200 OK\r\n"
"Content-Length: 20000000000\r\n"
"\r\n"
"abc";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response.c_str()),
static_cast<int>(response.size()),
#else
response.c_str(), response.size(),
#endif
0);
detail::close_socket(cli);
}
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
{
Client cli("127.0.0.1", PORT + 2);
cli.set_read_timeout(5, 0);
// Default payload_max_length_ is 100MB; a 20GB Content-Length must not
// result in a 20GB pre-allocation. The Get() call is expected to fail
// (server claims more bytes than payload_max_length permits), but it must
// not exhaust memory before getting there.
auto res = cli.Get("/malicious");
EXPECT_FALSE(res); // Read fails because body exceeds payload_max_length_
}
server_thread.join();
}
// Verify that content_receiver bypasses the default payload_max_length,
// allowing streaming downloads larger than 100MB without requiring an explicit
// set_payload_max_length call.
TEST(ClientVulnerabilityTest, ContentReceiverBypassesDefaultPayloadMaxLength) {
static constexpr size_t DATA_SIZE = 200 * 1024 * 1024; // 200MB from server
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto server_thread = std::thread([] {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
::listen(srv, 1);
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
char buf[4096];
::recv(cli, buf, sizeof(buf), 0);
// Response with Content-Length larger than default 100MB limit
auto content_length = std::to_string(DATA_SIZE);
std::string response_header = "HTTP/1.1 200 OK\r\n"
"Content-Length: " +
content_length +
"\r\n"
"Connection: close\r\n"
"\r\n";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response_header.c_str()),
static_cast<int>(response_header.size()),
#else
response_header.c_str(), response_header.size(),
#endif
0);
std::string chunk(64 * 1024, 'A');
size_t total_sent = 0;
while (total_sent < DATA_SIZE) {
auto to_send = std::min(chunk.size(), DATA_SIZE - total_sent);
auto sent = ::send(cli,
#ifdef _WIN32
static_cast<const char *>(chunk.c_str()),
static_cast<int>(to_send),
#else
chunk.c_str(), to_send,
#endif
0);
if (sent <= 0) break;
total_sent += static_cast<size_t>(sent);
}
detail::close_socket(cli);
}
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
size_t total_received = 0;
{
Client cli("127.0.0.1", PORT + 2);
cli.set_read_timeout(10, 0);
// Do NOT call set_payload_max_length — use the default 100MB limit
auto res =
cli.Get("/large", [&](const char * /*data*/, size_t data_length) {
total_received += data_length;
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
server_thread.join();
EXPECT_EQ(total_received, DATA_SIZE)
<< "With content_receiver, the client should read all " << DATA_SIZE
<< " bytes despite the default 100MB payload_max_length, but only read "
<< total_received << " bytes.";
}
// Verify that an explicit set_payload_max_length smaller than the response is
// enforced even when a content_receiver is used.
TEST(ClientVulnerabilityTest,
ContentReceiverRespectsExplicitPayloadMaxLength150MB) {
static constexpr size_t DATA_SIZE = 200 * 1024 * 1024; // 200MB from server
static constexpr size_t EXPLICIT_LIMIT = 150 * 1024 * 1024; // 150MB limit
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto server_thread = std::thread([] {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
::listen(srv, 1);
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
char buf[4096];
::recv(cli, buf, sizeof(buf), 0);
auto content_length = std::to_string(DATA_SIZE);
std::string response_header = "HTTP/1.1 200 OK\r\n"
"Content-Length: " +
content_length +
"\r\n"
"Connection: close\r\n"
"\r\n";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response_header.c_str()),
static_cast<int>(response_header.size()),
#else
response_header.c_str(), response_header.size(),
#endif
0);
std::string chunk(64 * 1024, 'A');
size_t total_sent = 0;
while (total_sent < DATA_SIZE) {
auto to_send = std::min(chunk.size(), DATA_SIZE - total_sent);
auto sent = ::send(cli,
#ifdef _WIN32
static_cast<const char *>(chunk.c_str()),
static_cast<int>(to_send),
#else
chunk.c_str(), to_send,
#endif
0);
if (sent <= 0) break;
total_sent += static_cast<size_t>(sent);
}
detail::close_socket(cli);
}
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
size_t total_received = 0;
{
Client cli("127.0.0.1", PORT + 2);
cli.set_read_timeout(10, 0);
cli.set_payload_max_length(EXPLICIT_LIMIT); // Explicit 150MB limit
auto res =
cli.Get("/large", [&](const char * /*data*/, size_t data_length) {
total_received += data_length;
return true;
});
// Should fail because 200MB exceeds the explicit 150MB limit
EXPECT_FALSE(res);
}
server_thread.join();
EXPECT_LE(total_received, EXPLICIT_LIMIT)
<< "Client with content_receiver should respect the explicit "
<< "payload_max_length of " << EXPLICIT_LIMIT << " bytes, but read "
<< total_received << " bytes.";
}
// Verify that an explicit set_payload_max_length larger than the response
// allows the content_receiver to read all data successfully.
TEST(ClientVulnerabilityTest,
ContentReceiverRespectsExplicitPayloadMaxLength250MB) {
static constexpr size_t DATA_SIZE = 200 * 1024 * 1024; // 200MB from server
static constexpr size_t EXPLICIT_LIMIT = 250 * 1024 * 1024; // 250MB limit
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
auto server_thread = std::thread([] {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 2));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
::listen(srv, 1);
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
char buf[4096];
::recv(cli, buf, sizeof(buf), 0);
auto content_length = std::to_string(DATA_SIZE);
std::string response_header = "HTTP/1.1 200 OK\r\n"
"Content-Length: " +
content_length +
"\r\n"
"Connection: close\r\n"
"\r\n";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response_header.c_str()),
static_cast<int>(response_header.size()),
#else
response_header.c_str(), response_header.size(),
#endif
0);
std::string chunk(64 * 1024, 'A');
size_t total_sent = 0;
while (total_sent < DATA_SIZE) {
auto to_send = std::min(chunk.size(), DATA_SIZE - total_sent);
auto sent = ::send(cli,
#ifdef _WIN32
static_cast<const char *>(chunk.c_str()),
static_cast<int>(to_send),
#else
chunk.c_str(), to_send,
#endif
0);
if (sent <= 0) break;
total_sent += static_cast<size_t>(sent);
}
#ifdef _WIN32
::shutdown(cli, SD_SEND);
#else
::shutdown(cli, SHUT_WR);
#endif
char drain[1024];
while (::recv(cli, drain, sizeof(drain), 0) > 0) {}
detail::close_socket(cli);
}
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
size_t total_received = 0;
{
Client cli("127.0.0.1", PORT + 2);
cli.set_read_timeout(10, 0);
cli.set_payload_max_length(EXPLICIT_LIMIT); // Explicit 250MB limit
auto res =
cli.Get("/large", [&](const char * /*data*/, size_t data_length) {
total_received += data_length;
return true;
});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
server_thread.join();
EXPECT_EQ(total_received, DATA_SIZE)
<< "With explicit payload_max_length of " << EXPLICIT_LIMIT
<< " bytes (larger than " << DATA_SIZE
<< " bytes response), content_receiver should read all data, but only "
"read "
<< total_received << " bytes.";
}
#if defined(CPPHTTPLIB_ZLIB_SUPPORT) && !defined(_WIN32)
// Regression test for "zip bomb" attack on the client side: a malicious server
// sends a small gzip-compressed response that decompresses to a huge payload.
// The client must enforce payload_max_length on the decompressed size.
TEST(ClientVulnerabilityTest, ZipBombWithoutContentLength) {
constexpr size_t DECOMPRESSED_SIZE =
10 * 1024 * 1024; // 10MB after decompression
constexpr size_t CLIENT_READ_LIMIT = 2 * 1024 * 1024; // 2MB safety limit
// Prepare gzip-compressed data: 10MB of zeros compresses to a few KB
std::string uncompressed(DECOMPRESSED_SIZE, '\0');
std::string compressed;
{
httplib::detail::gzip_compressor compressor;
bool ok =
compressor.compress(uncompressed.data(), uncompressed.size(),
/*last=*/true, [&](const char *buf, size_t len) {
compressed.append(buf, len);
return true;
});
ASSERT_TRUE(ok);
}
// Sanity: compressed data should be much smaller than the decompressed size
ASSERT_LT(compressed.size(), DECOMPRESSED_SIZE / 10);
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
// Set up the listening socket in the main thread so the server is guaranteed
// to be ready before the client connects (eliminates race condition).
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 3));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
ASSERT_EQ(0, ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, ::listen(srv, 1));
auto server_thread = std::thread([&compressed, srv] {
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
// Read the full HTTP request (until \r\n\r\n)
char buf[4096];
size_t total = 0;
while (total < sizeof(buf)) {
auto n = ::recv(cli, buf + total, sizeof(buf) - total, 0);
if (n <= 0) break;
total += static_cast<size_t>(n);
// Check for end of headers
if (total >= 4) {
std::string req(buf, total);
if (req.find("\r\n\r\n") != std::string::npos) break;
}
}
// Malicious response: gzip-compressed body, no Content-Length
std::string response_header = "HTTP/1.1 200 OK\r\n"
"Content-Encoding: gzip\r\n"
"Connection: close\r\n"
"\r\n";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response_header.c_str()),
static_cast<int>(response_header.size()),
#else
response_header.c_str(), response_header.size(),
#endif
0);
// Send the compressed payload (small on the wire, huge when decompressed)
size_t total_sent = 0;
while (total_sent < compressed.size()) {
auto to_send = std::min(compressed.size() - total_sent,
static_cast<size_t>(64 * 1024));
auto sent =
::send(cli,
#ifdef _WIN32
static_cast<const char *>(compressed.c_str() + total_sent),
static_cast<int>(to_send),
#else
compressed.c_str() + total_sent, to_send,
#endif
0);
if (sent <= 0) break;
total_sent += static_cast<size_t>(sent);
}
detail::close_socket(cli);
}
});
auto se = detail::scope_exit([&] {
detail::close_socket(srv);
server_thread.join();
});
size_t total_decompressed = 0;
{
Client cli("127.0.0.1", PORT + 3);
cli.set_read_timeout(5, 0);
cli.set_decompress(true);
cli.set_payload_max_length(CLIENT_READ_LIMIT);
auto stream = cli.open_stream("GET", "/zipbomb");
ASSERT_TRUE(stream.is_valid());
char buffer[64 * 1024];
ssize_t n;
while ((n = stream.read(buffer, sizeof(buffer))) > 0) {
total_decompressed += static_cast<size_t>(n);
}
}
// The decompressed size must be capped by payload_max_length. Without
// protection, the client would decompress the full 10MB from a tiny
// compressed payload, enabling a zip bomb DoS attack.
EXPECT_LE(total_decompressed, CLIENT_READ_LIMIT)
<< "Client decompressed " << total_decompressed
<< " bytes from a gzip response. The decompressed size should be "
<< "limited by set_payload_max_length to prevent zip bomb attacks.";
}
#endif
TEST(HostAndPortPropertiesTest, NoSSL) {
httplib::Client cli("www.google.com", 1234);
ASSERT_EQ("www.google.com", cli.host());
ASSERT_EQ(1234, cli.port());
}
TEST(HostAndPortPropertiesTest, NoSSLWithSimpleAPI) {
httplib::Client cli("www.google.com:1234");
ASSERT_EQ("www.google.com", cli.host());
ASSERT_EQ(1234, cli.port());
}
TEST(HostAndPortPropertiesTest, OverflowPortNumber) {
// Port number that overflows int — should not crash, client becomes invalid
httplib::Client cli("http://www.google.com:99999999999999999999");
ASSERT_FALSE(cli.is_valid());
}
TEST(HostAndPortPropertiesTest, PortOutOfRange) {
// Port 99999 exceeds valid range (1-65535) — should not crash
httplib::Client cli("http://www.google.com:99999");
ASSERT_FALSE(cli.is_valid());
}
TEST(HostAndPortPropertiesTest, TrailingCharactersInPort) {
httplib::Client cli("http://www.google.com:80abc");
ASSERT_FALSE(cli.is_valid());
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(HostAndPortPropertiesTest, SSL) {
httplib::SSLClient cli("www.google.com");
ASSERT_EQ("www.google.com", cli.host());
ASSERT_EQ(443, cli.port());
}
TEST(SSLClientTest, UpdateCAStoreWithPem_Online) {
// Test updating CA store multiple times using PEM-based load_ca_cert_store
std::string cert;
read_file(CA_CERT_FILE, cert);
httplib::SSLClient httplib_client("www.google.com");
// Load CA store first time
httplib_client.load_ca_cert_store(cert.data(), cert.size());
// Load CA store second time (update)
httplib_client.load_ca_cert_store(cert.data(), cert.size());
// Verify client is still valid and can make connections
httplib_client.enable_server_certificate_verification(true);
auto res = httplib_client.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
// Google may return 200 or 301 depending on various factors
EXPECT_TRUE(res->status == StatusCode::OK_200 ||
res->status == StatusCode::MovedPermanently_301);
}
TEST(SSLClientTest, ServerNameIndication_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/get"};
SSLClient cli(host, 443);
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientTest, ServerCertificateVerificationError_Online) {
// Use a site that will cause SSL verification failure due to self-signed cert
SSLClient cli("self-signed.badssl.com", 443);
cli.enable_server_certificate_verification(true);
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::SSLServerVerification, res.error());
// Verify backend error is captured for SSLServerVerification
// This occurs when certificate verification fails
// OpenSSL: X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT (18)
// Mbed TLS: MBEDTLS_X509_BADCERT_NOT_TRUSTED or similar flags
EXPECT_NE(0UL, res.ssl_backend_error());
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
// For OpenSSL, ssl_error is 0 for verification errors
EXPECT_EQ(0, res.ssl_error());
#if !defined(_WIN32) || \
defined(CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE)
// On non-Windows or when Windows Schannel is disabled, the error comes
// from OpenSSL's verification
EXPECT_EQ(static_cast<unsigned long>(X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT),
res.ssl_backend_error());
#endif
#endif
}
TEST(SSLClientTest, ServerHostnameVerificationError_Online) {
// Use a site where hostname doesn't match the certificate
// badssl.com provides wrong.host.badssl.com which has cert for *.badssl.com
SSLClient cli("wrong.host.badssl.com", 443);
cli.enable_server_certificate_verification(true);
cli.enable_server_hostname_verification(true);
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::SSLServerHostnameVerification, res.error());
// Verify backend error is captured for hostname verification failure
EXPECT_NE(0UL, res.ssl_backend_error());
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
// For OpenSSL, ssl_error is 0 for verification errors
EXPECT_EQ(0, res.ssl_error());
#if !defined(_WIN32) || \
defined(CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE)
// On non-Windows or when Windows Schannel is disabled, the error comes
// from OpenSSL's hostname verification
EXPECT_EQ(static_cast<unsigned long>(X509_V_ERR_HOSTNAME_MISMATCH),
res.ssl_backend_error());
#endif
#endif
}
#if defined(_WIN32) && defined(CPPHTTPLIB_SSL_ENABLED) && \
!defined(CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE)
TEST(SSLClientTest, WindowsCertificateVerification_DefaultEnabled) {
SSLClient cli("www.google.com", 443);
cli.enable_server_certificate_verification(true);
auto res = cli.Get("/");
if (res) { EXPECT_NE(StatusCode::InternalServerError_500, res->status); }
}
TEST(SSLClientTest, WindowsCertificateVerification_Disabled) {
SSLClient cli("www.google.com", 443);
cli.enable_server_certificate_verification(true);
cli.enable_windows_certificate_verification(false);
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();
}
// Windows, not the backend, decides on the chain: the error is a CryptoAPI
// policy status, which no backend reports for a self-signed certificate.
TEST(SSLClientTest, WindowsCertificateVerification_RejectsUntrustedRoot) {
SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli("127.0.0.1", PORT);
cli.set_connection_timeout(30);
auto res = cli.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLServerVerification, res.error());
EXPECT_EQ(static_cast<DWORD>(CERT_E_UNTRUSTEDROOT), res.ssl_backend_error());
}
#endif
TEST(SSLClientTest, ServerCertificateVerification1_Online) {
Client cli("https://google.com");
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::MovedPermanently_301, res->status);
}
TEST(SSLClientTest, ServerCertificateVerification2_Online) {
SSLClient cli("google.com");
cli.set_ca_cert_path(CA_CERT_FILE);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::MovedPermanently_301, res->status);
}
TEST(SSLClientTest, ServerCertificateVerification3_Online) {
SSLClient cli("google.com");
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path("hello");
auto res = cli.Get("/");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::SSLLoadingCerts, res.error());
// For SSL_CTX operations, ssl_error should be 0, only ssl_backend_error
// should be set
EXPECT_EQ(0, res.ssl_error());
// Verify backend error is captured for SSLLoadingCerts
// This error occurs when loading CA certificates fails
EXPECT_NE(0UL, res.ssl_backend_error());
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
// OpenSSL specific error codes:
// > openssl errstr 0x80000002
// error:80000002:system library::No such file or directory
// > openssl errstr 0xA000126
// error:0A000126:SSL routines::unexpected eof while reading
EXPECT_TRUE(res.ssl_backend_error() == 0x80000002 ||
res.ssl_backend_error() == 0xA000126);
#endif
}
TEST(SSLClientTest, ServerCertificateVerification4) {
SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
svr.stop();
ASSERT_TRUE(true);
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
auto se = detail::scope_exit([&] {
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli("127.0.0.1", PORT);
cli.set_ca_cert_path(SERVER_CERT2_FILE);
cli.enable_server_certificate_verification(true);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientTest, ServerCertificateVerification5_Online) {
std::string cert;
read_file(CA_CERT_FILE, cert);
SSLClient cli("google.com");
cli.load_ca_cert_store(cert.data(), cert.size());
const auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::MovedPermanently_301, res->status);
}
TEST(SSLClientTest, ServerCertificateVerification6_Online) {
// clang-format off
static constexpr char cert[] =
"GlobalSign Root CA\n"
"==================\n"
"-----BEGIN CERTIFICATE-----\n"
"MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkGA1UEBhMCQkUx\n"
"GTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jvb3QgQ0ExGzAZBgNVBAMTEkds\n"
"b2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAwMDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNV\n"
"BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYD\n"
"VQQDExJHbG9iYWxTaWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa\n"
"DuaZjc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavpxy0Sy6sc\n"
"THAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp1Wrjsok6Vjk4bwY8iGlb\n"
"Kk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdGsnUOhugZitVtbNV4FpWi6cgKOOvyJBNP\n"
"c1STE4U6G7weNLWLBYy5d4ux2x8gkasJU26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrX\n"
"gzT/LCrBbBlDSgeF59N89iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n"
"HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0BAQUF\n"
"AAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOzyj1hTdNGCbM+w6Dj\n"
"Y1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE38NflNUVyRRBnMRddWQVDf9VMOyG\n"
"j/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymPAbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhH\n"
"hm4qxFYxldBniYUr+WymXUadDKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveC\n"
"X4XSQRjbgbMEHMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==\n"
"-----END CERTIFICATE-----\n";
// clang-format on
SSLClient cli("google.com");
cli.load_ca_cert_store(cert, sizeof(cert));
const auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::MovedPermanently_301, res->status);
}
TEST(SSLClientTest, WildcardHostNameMatch_Online) {
SSLClient cli("www.youtube.com");
cli.set_ca_cert_path(CA_CERT_FILE);
cli.enable_server_certificate_verification(true);
cli.set_follow_location(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientTest, WildcardHostNameMatchCase_Online) {
SSLClient cli("wWw.YouTube.Com");
cli.set_ca_cert_path(CA_CERT_FILE);
cli.enable_server_certificate_verification(true);
cli.enable_server_hostname_verification(true);
cli.set_follow_location(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientTest, HostNameMatchCase_Online) {
SSLClient cli("gOoGlE.COm");
cli.enable_server_certificate_verification(true);
cli.enable_server_hostname_verification(true);
cli.set_follow_location(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientTest, Issue2004_Online) {
Client client("https://google.com");
client.set_follow_location(true);
auto res = client.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
auto body = res->body;
EXPECT_EQ(body.substr(0, 15), "<!doctype html>");
}
TEST(SSLClientTest, ErrorReportingWhenInvalid) {
// Create SSLClient with invalid cert/key to make is_valid() return false
SSLClient cli("localhost", 8080, "nonexistent_cert.pem",
"nonexistent_key.pem");
// is_valid() should be false due to cert loading failure
ASSERT_FALSE(cli.is_valid());
auto res = cli.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLConnection, res.error());
}
TEST(SSLClientTest, Issue2251_SwappedClientCertAndKey) {
// Test for Issue #2251: SSL error not properly reported when client cert
// and key paths are swapped or mismatched
// This simulates the scenario where user accidentally swaps the cert and key
// files
// Using client cert file as private key and vice versa (completely wrong)
SSLClient cli("localhost", 8080, "client.key.pem", "client.cert.pem");
// Should fail validation due to cert/key mismatch
ASSERT_FALSE(cli.is_valid());
// Attempt to make a request should fail with proper error
auto res = cli.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLConnection, res.error());
// SSL error should be recorded in the Result object (this is the key fix for
// Issue #2251)
auto backend_error = res.ssl_backend_error();
EXPECT_NE(0u, backend_error);
}
// Tests cert/key mismatch detection at the TLS context level
TEST(TlsApiTest, ClientCertKeyMismatch) {
// Test that using mismatched cert/key causes connection failure.
// We verify this at the SSLClient level rather than through internal
// TLS API functions.
SSLClient cli(HOST, PORT, "client.cert.pem", "key.pem");
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(2);
// The mismatch should cause a connection or handshake error
auto res = cli.Get("/test");
// OpenSSL detects mismatch at context setup, MbedTLS at handshake
// Either way, the request should fail
EXPECT_FALSE(res);
}
#endif
#if 0
TEST(SSLClientTest, SetInterfaceWithINET6) {
auto cli = std::make_shared<httplib::Client>("https://httpcan.org");
ASSERT_TRUE(cli != nullptr);
cli->set_address_family(AF_INET6);
cli->set_interface("en0");
auto res = cli->Get("/get");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
#endif
// ClientCertPresent uses get_peer_cert() - works with all TLS backends
#ifdef CPPHTTPLIB_SSL_ENABLED
void ClientCertPresent(
const std::string &client_cert_file,
const std::string &client_private_key_file,
const std::string &client_encrypted_private_key_pass = std::string()) {
using namespace httplib::tls;
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE,
CLIENT_CA_CERT_DIR);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &req, Response &res) {
res.set_content("test", "text/plain");
auto cert = req.peer_cert();
ASSERT_TRUE(static_cast<bool>(cert));
std::string common_name = cert.subject_cn();
EXPECT_EQ("Common Name", common_name);
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT, client_cert_file, client_private_key_file,
client_encrypted_private_key_pass);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientServerTest, ClientCertPresent) {
ClientCertPresent(CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
}
TEST(SSLClientServerTest, ClientEncryptedCertPresent) {
ClientCertPresent(CLIENT_ENCRYPTED_CERT_FILE,
CLIENT_ENCRYPTED_PRIVATE_KEY_FILE,
CLIENT_ENCRYPTED_PRIVATE_KEY_PASS);
}
// PEM memory-based constructor tests (works with all TLS backends)
void PemMemoryClientCertPresent(
const std::string &client_cert_file,
const std::string &client_private_key_file,
const std::string &client_encrypted_private_key_pass = std::string()) {
// Read PEM files into memory
std::string server_cert_pem, server_key_pem;
std::string client_ca_pem;
std::string client_cert_pem, client_key_pem;
read_file(SERVER_CERT_FILE, server_cert_pem);
read_file(SERVER_PRIVATE_KEY_FILE, server_key_pem);
read_file(CLIENT_CA_CERT_FILE, client_ca_pem);
read_file(client_cert_file, client_cert_pem);
read_file(client_private_key_file, client_key_pem);
// Create server with PEM memory
SSLServer::PemMemory server_pem = {
server_cert_pem.c_str(),
server_cert_pem.size(),
server_key_pem.c_str(),
server_key_pem.size(),
client_ca_pem.c_str(),
client_ca_pem.size(),
nullptr // no password for server key
};
SSLServer svr(server_pem);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Create client with PEM memory
const char *password = client_encrypted_private_key_pass.empty()
? nullptr
: client_encrypted_private_key_pass.c_str();
SSLClient::PemMemory client_pem = {
client_cert_pem.c_str(), client_cert_pem.size(), client_key_pem.c_str(),
client_key_pem.size(), password};
SSLClient cli(HOST, PORT, client_pem);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientServerTest, PemMemoryClientCertPresent) {
PemMemoryClientCertPresent(CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
}
TEST(SSLClientServerTest, PemMemoryClientEncryptedCertPresent) {
PemMemoryClientCertPresent(CLIENT_ENCRYPTED_CERT_FILE,
CLIENT_ENCRYPTED_PRIVATE_KEY_FILE,
CLIENT_ENCRYPTED_PRIVATE_KEY_PASS);
}
TEST(SSLClientServerTest, ClientCertMissing) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE,
CLIENT_CA_CERT_DIR);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &) { ASSERT_TRUE(false); });
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.set_connection_timeout(30);
// cert.pem does not match HOST. Skip the hostname check, which runs before
// the chain check on Windows, so the result does not depend on the order.
cli.enable_server_hostname_verification(false);
auto res = cli.Get("/test");
ASSERT_TRUE(!res);
// When client cert is missing and server requires it, connection fails
// Error type depends on backend implementation
EXPECT_TRUE(res.error() == Error::SSLServerVerification ||
res.error() == Error::SSLConnection);
// Verify backend error is captured
// Note: This test may have different error codes depending on the exact
// verification failure
EXPECT_NE(0UL, res.ssl_backend_error());
}
TEST(SSLClientServerTest, TrustDirOptional) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientServerTest, SSLConnectTimeout) {
class NoListenSSLServer : public SSLServer {
public:
NoListenSSLServer(const char *cert_path, const char *private_key_path,
const char *client_ca_cert_file_path,
const char *client_ca_cert_dir_path = nullptr)
: SSLServer(cert_path, private_key_path, client_ca_cert_file_path,
client_ca_cert_dir_path),
stop_(false) {}
std::atomic_bool stop_;
private:
bool process_and_close_socket(socket_t /*sock*/) override {
// Don't create SSL context
while (!stop_.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return true;
}
};
NoListenSSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE,
CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop_ = true;
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(1);
auto res = cli.Get("/test");
ASSERT_TRUE(!res);
EXPECT_EQ(Error::SSLConnection, res.error());
// Timeout results in WantRead error code (maps to backend-specific value)
EXPECT_NE(0, res.ssl_error());
}
TEST(SSLClientServerTest, CustomizeServerSSLCtxGeneric) {
// Test SSLServer with client certificate verification using the standard
// constructor (ContextSetupCallback is tested by backend-specific tests)
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE,
CLIENT_CA_CERT_DIR);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &req, Response &res) {
res.set_content("test", "text/plain");
auto cert = req.peer_cert();
ASSERT_TRUE(static_cast<bool>(cert));
auto common_name = cert.subject_cn();
EXPECT_EQ("Common Name", common_name);
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
// Test verify_hostname for both OpenSSL and MbedTLS backends
// Verifies that wildcard matching and exact matching work consistently
TEST(SSLClientServerTest, TlsVerifyHostname) {
using namespace httplib::tls;
// We need a running server to test against
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
bool verify_callback_called = false;
bool verify_result_wrong = false;
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
// Note: Test certificate has CN="Common Name", not "localhost"
bool verify_result_cn = false;
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
// Test 1: "Common Name" should match (our test server cert CN)
verify_result_cn = ctx.check_hostname("Common Name");
// Test 2: wrong hostname should not match
verify_result_wrong = ctx.check_hostname("wronghost.example.com");
return true; // Accept for the purpose of this test
});
auto res = cli.Get("/test");
// The request may succeed or fail depending on cert configuration
// but the callback should have been called
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
// CN="Common Name" should match our test certificate
//
// BoringSSL intentionally drops CN-based hostname matching per RFC 6125
// §6.4.4 — only SubjectAltName is consulted. Other backends (OpenSSL,
// MbedTLS, wolfSSL) still honor the CN fallback, so flip the expectation
// for BoringSSL builds. OPENSSL_IS_BORINGSSL is defined by BoringSSL's
// <openssl/base.h>, which is included transitively when
// CPPHTTPLIB_OPENSSL_SUPPORT is set against a BoringSSL install.
#if defined(OPENSSL_IS_BORINGSSL)
EXPECT_FALSE(verify_result_cn)
<< "BoringSSL should reject CN-based hostname matching (SAN-only)";
#else
EXPECT_TRUE(verify_result_cn)
<< "verify_hostname should match 'Common Name' (certificate CN)";
#endif
// Wrong hostname should not match
EXPECT_FALSE(verify_result_wrong)
<< "verify_hostname should not match 'wronghost.example.com'";
}
// An IP-literal host must only be authenticated via an iPAddress SAN, never via
// the certificate's Common Name (RFC 9110). This mirrors the OpenSSL backend's
// X509_check_ip behavior and must hold for every backend.
TEST(SSLClientServerTest, TlsVerifyHostnameIpNotMatchedByCommonName) {
using namespace httplib::tls;
// Certificate CN is the IPv4 literal "127.0.0.1" and it carries no SAN.
SSLServer svr(SERVER_CERT_IP_CN_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
bool verify_callback_called = false;
bool ip_matched_via_cn = true;
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
// The IP appears only in the CN, so it must NOT be accepted.
ip_matched_via_cn = ctx.check_hostname("127.0.0.1");
return true; // Accept for the purpose of this test
});
cli.Get("/test");
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
EXPECT_FALSE(ip_matched_via_cn)
<< "An IP host must not be authenticated via the certificate CN";
}
// IPv6 hosts must be matched against IPv6 iPAddress SANs (and only those).
TEST(SSLClientServerTest, TlsVerifyHostnameIpv6San) {
using namespace httplib::tls;
// Certificate CN is "::1" and it carries an IPv6 SAN for "2001:db8::1".
SSLServer svr(SERVER_CERT_IPV6_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
bool verify_callback_called = false;
bool san_matched = false;
bool wrong_ipv6_matched = true;
bool cn_ipv6_matched = true;
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
// Matches the IPv6 iPAddress SAN.
san_matched = ctx.check_hostname("2001:db8::1");
// A different IPv6 address must not match.
wrong_ipv6_matched = ctx.check_hostname("2001:db8::2");
// "::1" lives only in the CN, so it must not be accepted.
cn_ipv6_matched = ctx.check_hostname("::1");
return true; // Accept for the purpose of this test
});
cli.Get("/test");
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
EXPECT_TRUE(san_matched)
<< "verify_hostname should match an IPv6 iPAddress SAN";
EXPECT_FALSE(wrong_ipv6_matched)
<< "verify_hostname should not match a non-matching IPv6 address";
EXPECT_FALSE(cn_ipv6_matched)
<< "An IPv6 host must not be authenticated via the certificate CN";
}
// A SAN entry must only match a host of its own type: the bytes of the dNSName
// "a.zz" are also 97.46.122.122, and 42.46.122.122 reads as "*.zz".
TEST(SSLClientServerTest, TlsVerifyHostnameSanType) {
using namespace httplib::tls;
// SANs: DNS:a.zz, IP:42.46.122.122
SSLServer svr(SERVER_CERT_SAN_TYPES_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
bool verify_callback_called = false;
bool dns_san_matched = false;
bool ip_san_matched = false;
bool ip_matched_via_dns_san = true;
bool dns_matched_via_ip_san = true;
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
dns_san_matched = ctx.check_hostname("a.zz");
ip_san_matched = ctx.check_hostname("42.46.122.122");
ip_matched_via_dns_san = ctx.check_hostname("97.46.122.122");
dns_matched_via_ip_san = ctx.check_hostname("b.zz");
return true; // Accept for the purpose of this test
});
cli.Get("/test");
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
EXPECT_TRUE(dns_san_matched) << "verify_hostname should match a dNSName SAN";
EXPECT_TRUE(ip_san_matched)
<< "verify_hostname should match an iPAddress SAN";
EXPECT_FALSE(ip_matched_via_dns_san)
<< "An IP host must not be authenticated via a dNSName SAN";
EXPECT_FALSE(dns_matched_via_ip_san)
<< "A DNS host must not be authenticated via an iPAddress SAN";
}
// RFC 6125 6.4.3: only the leftmost label of a dNSName may be a wildcard, so
// "www.*.example.test" names one host and not every host under example.test.
TEST(SSLClientServerTest, TlsVerifyHostnameWildcardLabel) {
using namespace httplib::tls;
// SANs: DNS:*.leftmost.example.test, DNS:www.*.example.test
SSLServer svr(SERVER_CERT_WILDCARD_SAN_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
bool verify_callback_called = false;
bool leftmost_wildcard_matched = false;
bool wildcard_spanned_labels = true;
bool inner_wildcard_matched = true;
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
leftmost_wildcard_matched = ctx.check_hostname("a.leftmost.example.test");
wildcard_spanned_labels = ctx.check_hostname("a.b.leftmost.example.test");
inner_wildcard_matched = ctx.check_hostname("www.evil.example.test");
return true; // Accept for the purpose of this test
});
cli.Get("/test");
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
EXPECT_TRUE(leftmost_wildcard_matched)
<< "A leftmost wildcard should match a single label";
EXPECT_FALSE(wildcard_spanned_labels)
<< "A wildcard label must not match more than one label";
EXPECT_FALSE(inner_wildcard_matched)
<< "A wildcard outside the leftmost label must not be honoured";
}
// sans() must report each SAN entry under its own type.
TEST(SSLClientServerTest, TlsCertSansEntryTypes) {
using namespace httplib::tls;
// SANs: DNS:a.zz, IP:42.46.122.122
SSLServer svr(SERVER_CERT_SAN_TYPES_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
bool verify_callback_called = false;
std::vector<SanEntry> sans;
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(true);
cli.set_ca_cert_path(CA_CERT_FILE);
cli.set_connection_timeout(5);
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
verify_callback_called = true;
if (!ctx.cert) return false;
sans = ctx.sans();
return true; // Accept for the purpose of this test
});
cli.Get("/test");
ASSERT_TRUE(verify_callback_called)
<< "Verify callback should have been called";
auto has_san = [&](SanType type, const std::string &value) {
return std::any_of(sans.begin(), sans.end(), [&](const SanEntry &san) {
return san.type == type && san.value == value;
});
};
EXPECT_TRUE(has_san(SanType::DNS, "a.zz"))
<< "sans() should report the dNSName SAN";
EXPECT_TRUE(has_san(SanType::IP, "42.46.122.122"))
<< "sans() should report the iPAddress SAN";
EXPECT_FALSE(has_san(SanType::IP, "97.46.122.122"))
<< "sans() must not report the dNSName SAN as an address";
EXPECT_FALSE(has_san(SanType::DNS, "*.zz"))
<< "sans() must not report the iPAddress SAN as a DNS name";
}
#endif
// mbedTLS-specific callback constructor test
// Tests that the void* callback can customize TLS settings via MbedTlsContext
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(SSLClientServerTest, ClientCAListSentToClient) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
// Set up a handler to verify client certificate is present
bool client_cert_verified = false;
svr.Get("/test", [&](const Request & /*req*/, Response &res) {
// Verify that client certificate was provided
client_cert_verified = true;
res.set_content("success", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Client with certificate
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_TRUE(client_cert_verified);
EXPECT_EQ("success", res->body);
}
#endif
// ClientCAListSetInContext uses get_peer_cert() - works with all TLS
// backends
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(SSLClientServerTest, ClientCAListSetInContext) {
using namespace httplib::tls;
// Test that when client CA cert file is provided,
// the server properly requests and validates client certificates
// Create a server with client authentication
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
bool handler_called = false;
svr.Get("/test", [&](const Request &req, Response &res) {
handler_called = true;
// Verify that a client certificate was provided
auto cert = req.peer_cert();
ASSERT_TRUE(static_cast<bool>(cert));
// Get the issuer name
std::string issuer_str = cert.issuer_name();
ASSERT_FALSE(issuer_str.empty());
// The client certificate should be issued by our test CA
EXPECT_TRUE(issuer_str.find("Root CA Name") != std::string::npos);
res.set_content("authenticated", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Connect with a client certificate issued by the CA
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
ASSERT_TRUE(handler_called);
EXPECT_EQ("authenticated", res->body);
}
TEST(TlsCertIntrospectionTest, GetCertSANs) {
using namespace httplib::tls;
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
bool cert_checked = false;
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
if (ctx.cert) {
auto sans = ctx.sans();
// Test certificate may or may not have SANs - just verify the API
// works If SANs exist, verify the types are valid
for (const auto &san : sans) {
EXPECT_TRUE(san.type == SanType::DNS || san.type == SanType::IP ||
san.type == SanType::EMAIL || san.type == SanType::URI ||
san.type == SanType::OTHER);
EXPECT_FALSE(san.value.empty());
}
cert_checked = true;
}
return true;
});
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_TRUE(cert_checked);
}
TEST(TlsCertIntrospectionTest, GetCertValidity) {
using namespace httplib::tls;
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
bool validity_checked = false;
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
if (ctx.cert) {
time_t not_before = 0, not_after = 0;
bool result = ctx.validity(not_before, not_after);
EXPECT_TRUE(result);
// Verify that not_before < now < not_after for a valid cert
time_t now = time(nullptr);
EXPECT_LT(not_before, now);
EXPECT_GT(not_after, now);
validity_checked = true;
}
return true;
});
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_TRUE(validity_checked);
}
TEST(TlsCertIntrospectionTest, GetCertSerial) {
using namespace httplib::tls;
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
bool serial_checked = false;
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
if (ctx.cert) {
std::string serial = ctx.serial();
EXPECT_FALSE(serial.empty());
// Serial should be a hex string
for (char c : serial) {
EXPECT_TRUE((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') ||
(c >= 'a' && c <= 'f'));
}
serial_checked = true;
}
return true;
});
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_TRUE(serial_checked);
}
TEST(SSLClientServerTest, ClientCAListLoadErrorRecorded) {
// Test 1: Valid CA file - no error should be recorded
{
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE,
CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
// With valid setup, last_ssl_error should be 0
EXPECT_EQ(0, svr.ssl_last_error());
}
// Test 2: Invalid CA file content
// When SSL_load_client_CA_file fails, last_ssl_error_ should be set
{
// Create a temporary file with completely invalid content
const char *temp_invalid_ca = "./temp_invalid_ca_for_test.txt";
{
std::ofstream ofs(temp_invalid_ca);
ofs << "This is not a certificate file at all\n";
ofs << "Just plain text content\n";
}
// Create server with invalid CA file
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, temp_invalid_ca);
// Clean up temporary file
std::remove(temp_invalid_ca);
// When there's an SSL error (from either SSL_CTX_load_verify_locations
// or SSL_load_client_CA_file), last_ssl_error_ should be non-zero
// Note: SSL_CTX_load_verify_locations typically fails first,
// but our error handling code path is still exercised
if (!svr.is_valid()) { EXPECT_NE(0, svr.ssl_last_error()); }
}
}
TEST(VerifyCallbackTest, VerifyContextFields) {
using namespace httplib::tls;
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
int callback_count = 0;
bool saw_leaf_cert = false;
cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool {
if (ctx.cert) {
callback_count++;
// We should see at least one certificate (the leaf)
std::string cn = ctx.subject_cn();
if (!cn.empty()) { saw_leaf_cert = true; }
// Verify context fields are populated
EXPECT_NE(ctx.session, nullptr);
EXPECT_GE(ctx.depth, 0);
}
return true;
});
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GT(callback_count, 0);
EXPECT_TRUE(saw_leaf_cert);
}
TEST(TlsVerifyErrorTest, GetVerifyErrorString) {
using httplib::tls::TlsError;
// Test that verify_error_to_string returns empty for success
std::string success_str = TlsError::verify_error_to_string(0);
EXPECT_TRUE(success_str.empty());
// Test that verify_error_to_string returns non-empty for error codes
// Using a common error code (certificate expired)
std::string error_str =
TlsError::verify_error_to_string(10); // X509_V_ERR_CERT_HAS_EXPIRED
EXPECT_FALSE(error_str.empty());
}
TEST(SessionVerifierTest, CertificateAccepted) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
bool callback_called = false;
cli.set_session_verifier([&](tls::session_t session) -> SSLVerifierResponse {
EXPECT_NE(session, nullptr);
callback_called = true;
return SSLVerifierResponse::CertificateAccepted;
});
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_TRUE(callback_called);
}
TEST(SessionVerifierTest, CertificateRejected) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
bool callback_called = false;
cli.set_session_verifier([&](tls::session_t session) -> SSLVerifierResponse {
EXPECT_NE(session, nullptr);
callback_called = true;
return SSLVerifierResponse::CertificateRejected;
});
auto res = cli.Get("/test");
EXPECT_FALSE(res);
EXPECT_TRUE(callback_called);
}
TEST(SessionVerifierTest, NoDecisionFallsThrough) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
// NoDecisionMade with verification disabled should succeed (no default check)
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
bool callback_called = false;
cli.set_session_verifier([&](tls::session_t session) -> SSLVerifierResponse {
EXPECT_NE(session, nullptr);
callback_called = true;
return SSLVerifierResponse::NoDecisionMade;
});
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_TRUE(callback_called);
}
TEST(SessionVerifierTest, NoDecisionWithVerificationEnabled) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
// NoDecisionMade with verification enabled should fail (self-signed cert).
// Note: On MbedTLS, the handshake itself fails before reaching the verifier,
// so we only check that the request fails, not whether the callback was
// called.
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(true);
cli.set_connection_timeout(30);
cli.set_session_verifier([&](tls::session_t session) -> SSLVerifierResponse {
EXPECT_NE(session, nullptr);
return SSLVerifierResponse::NoDecisionMade;
});
auto res = cli.Get("/test");
EXPECT_FALSE(res);
}
TEST(SSLClientServerTest, ClientCAListFromPem) {
// Test SSL server using PemMemory constructor with client CA certificates
// Read PEM files
std::string server_cert_pem, server_key_pem, client_ca_pem;
read_file(SERVER_CERT_FILE, server_cert_pem);
read_file(SERVER_PRIVATE_KEY_FILE, server_key_pem);
read_file(CLIENT_CA_CERT_FILE, client_ca_pem);
// Create SSLServer with PemMemory constructor including client CA
SSLServer::PemMemory server_pem = {
server_cert_pem.c_str(),
server_cert_pem.size(),
server_key_pem.c_str(),
server_key_pem.size(),
client_ca_pem.c_str(),
client_ca_pem.size(),
nullptr // no password for server key
};
SSLServer svr(server_pem);
ASSERT_TRUE(svr.is_valid());
// No SSL error should be recorded for valid setup
EXPECT_EQ(0, svr.ssl_last_error());
// Set up server endpoints
svr.Get("/test-pem-ca", [&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
// Start server in a thread
auto server_thread = thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
// Connect with client certificate (using constructor with paths)
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
auto res = cli.Get("/test-pem-ca");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(200, res->status);
EXPECT_EQ("ok", res->body);
svr.stop();
server_thread.join();
}
#endif
#ifdef _WIN32
TEST(CleanupTest, WSACleanup) {
int ret = WSACleanup();
ASSERT_EQ(0, ret);
}
#endif
#ifndef CPPHTTPLIB_SSL_ENABLED
TEST(NoSSLSupport, SimpleInterface) {
ASSERT_ANY_THROW(Client cli("https://yahoo.com"));
}
#endif
#ifndef CPPHTTPLIB_NO_EXCEPTIONS
TEST(InvalidScheme, SimpleInterface) {
ASSERT_ANY_THROW(Client cli("scheme://yahoo.com"));
}
#endif
TEST(NoScheme, SimpleInterface) {
Client cli("yahoo.com:80");
ASSERT_TRUE(cli.is_valid());
}
TEST(SendAPI, SimpleInterface_Online) {
Client cli("http://yahoo.com");
Request req;
req.method = "GET";
req.path = "/";
auto res = cli.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MovedPermanently_301, res->status);
}
TEST(SendAPI, WithParamsInRequest) {
Server svr;
svr.Get("/", [&](const Request &req, Response & /*res*/) {
EXPECT_TRUE(req.has_param("test"));
EXPECT_EQ("test_value", req.get_param_value("test"));
});
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
{
Request req;
req.method = "GET";
req.path = "/";
req.params.emplace("test", "test_value");
auto res = cli.send(req);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
}
{
auto res = cli.Get("/", {{"test", "test_value"}}, Headers{});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
}
}
TEST(ClientImplMethods, GetSocketTest) {
httplib::Server svr;
svr.Get("/", [&](const httplib::Request & /*req*/, httplib::Response &res) {
res.status = StatusCode::OK_200;
});
auto port = svr.bind_to_any_port("127.0.0.1");
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();
{
httplib::Client cli("127.0.0.1", port);
cli.set_keep_alive(true);
// Use the behavior of cpp-httplib of opening the connection
// only when the first request happens. If that changes,
// this test would be obsolete.
EXPECT_EQ(cli.socket(), INVALID_SOCKET);
// This also implicitly tests the server. But other tests would fail much
// earlier than this one to be considered.
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
ASSERT_TRUE(cli.socket() != INVALID_SOCKET);
}
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(YahooRedirectTest2, SimpleInterface_Online) {
Client cli("http://yahoo.com");
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MovedPermanently_301, res->status);
cli.set_follow_location(true);
res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("https://www.yahoo.com/", res->location);
}
TEST(YahooRedirectTest3, SimpleInterface_Online) {
Client cli("https://yahoo.com");
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MovedPermanently_301, res->status);
cli.set_follow_location(true);
res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("https://www.yahoo.com/", res->location);
}
TEST(YahooRedirectTest3, NewResultInterface_Online) {
Client cli("https://yahoo.com");
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_FALSE(!res);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_FALSE(res == nullptr);
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(Error::Success, res.error());
EXPECT_EQ(StatusCode::MovedPermanently_301, res.value().status);
EXPECT_EQ(StatusCode::MovedPermanently_301, (*res).status);
EXPECT_EQ(StatusCode::MovedPermanently_301, res->status);
cli.set_follow_location(true);
res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(Error::Success, res.error());
EXPECT_EQ(StatusCode::OK_200, res.value().status);
EXPECT_EQ(StatusCode::OK_200, (*res).status);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("https://www.yahoo.com/", res->location);
}
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
TEST(DecodeWithChunkedEncoding, BrotliEncoding_Online) {
Client cli("https://cdnjs.cloudflare.com");
auto res = cli.Get("/ajax/libs/jquery/3.5.1/jquery.js",
Headers{{"Accept-Encoding", "br"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(287630U, res->body.size());
EXPECT_EQ("application/javascript; charset=utf-8",
res->get_header_value("Content-Type"));
}
#endif
// Previously "https://nghttp2.org" "/httpbin/redirect-to"
#undef REDIR_HOST // Silence compiler warning
#define REDIR_HOST "https://httpbingo.org"
TEST(HttpsToHttpRedirectTest, SimpleInterface_Online) {
Client cli(REDIR_HOST);
cli.set_follow_location(true);
auto res =
cli.Get(REDIR_PATH "?url=http%3A%2F%2Fexample.com&status_code=302");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(HttpsToHttpRedirectTest2, SimpleInterface_Online) {
Client cli(REDIR_HOST);
cli.set_follow_location(true);
Params params;
params.emplace("url", "http://example.com");
params.emplace("status_code", "302");
auto res = cli.Get(REDIR_PATH, params, Headers{});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(HttpsToHttpRedirectTest3, SimpleInterface_Online) {
Client cli(REDIR_HOST);
cli.set_follow_location(true);
Params params;
params.emplace("url", "http://example.com");
auto res = cli.Get(REDIR_PATH "?status_code=302", params, Headers{});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(HttpToHttpsRedirectTest, CertFile) {
auto ssl_port = PORT + 1;
Server svr;
ASSERT_TRUE(svr.is_valid());
svr.Get("/index", [&](const Request &, Response &res) {
res.set_redirect("https://127.0.0.1:" + std::to_string(ssl_port) +
"/index");
svr.stop();
});
SSLServer ssl_svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(ssl_svr.is_valid());
ssl_svr.Get("/index", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
ssl_svr.stop();
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
thread t2 =
thread([&]() { ASSERT_TRUE(ssl_svr.listen("127.0.0.1", ssl_port)); });
auto se = detail::scope_exit([&] {
t2.join();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
ssl_svr.wait_until_ready();
Client cli("127.0.0.1", PORT);
cli.set_ca_cert_path(SERVER_CERT2_FILE);
cli.enable_server_certificate_verification(true);
cli.set_follow_location(true);
cli.set_connection_timeout(30);
auto res = cli.Get("/index");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
TEST(SSLClientRedirectTest, CertFile) {
auto ssl_port = PORT + 1;
SSLServer ssl_svr1(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(ssl_svr1.is_valid());
ssl_svr1.Get("/index", [&](const Request &, Response &res) {
res.set_redirect("https://127.0.0.1:" + std::to_string(ssl_port) +
"/index");
ssl_svr1.stop();
});
SSLServer ssl_svr2(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(ssl_svr2.is_valid());
ssl_svr2.Get("/index", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
ssl_svr2.stop();
});
thread t = thread([&]() { ASSERT_TRUE(ssl_svr1.listen("127.0.0.1", PORT)); });
thread t2 =
thread([&]() { ASSERT_TRUE(ssl_svr2.listen("127.0.0.1", ssl_port)); });
auto se = detail::scope_exit([&] {
t2.join();
t.join();
ASSERT_FALSE(ssl_svr1.is_running());
});
ssl_svr1.wait_until_ready();
ssl_svr2.wait_until_ready();
SSLClient cli("127.0.0.1", PORT);
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_server_certificate_verification(true);
cli.set_follow_location(true);
cli.set_connection_timeout(30);
auto res = cli.Get("/index");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
#endif
#ifdef CPPHTTPLIB_SSL_ENABLED
// Test that set_ca_cert_store() skips system certs (consistent with
// set_ca_cert_path behavior). When a custom cert store is set, only those certs
// should be trusted - system certs should NOT be loaded.
TEST(SSLClientTest, SetCaCertStoreSkipsSystemCerts_Online) {
// Load a specific cert that is NOT a system CA cert
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
SSLClient cli("google.com");
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_server_certificate_verification(true);
// This should FAIL because:
// 1. We loaded only SERVER_CERT2 (a test cert, not a CA for google.com)
// 2. System certs should NOT be loaded when custom store is set
// If system certs WERE loaded, this would succeed
auto res = cli.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLServerVerification, res.error());
}
// Same as above, but through the native store handle path. Regression test:
// set_ca_cert_store() used to leave no trace on the client, so load_certs()
// merged system certs into the user-provided store.
TEST(SSLClientTest, SetCaCertStoreNativeSkipsSystemCerts_Online) {
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
SSLClient cli("google.com");
cli.set_ca_cert_store(tls::create_ca_store(cert.c_str(), cert.size()));
cli.enable_server_certificate_verification(true);
auto res = cli.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLServerVerification, res.error());
}
// A custom store set via the native handle must still verify a server whose
// cert it does contain.
TEST(SSLClientTest, SetCaCertStoreNativeVerifiesLocalServer) {
SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli("127.0.0.1", PORT);
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
cli.set_ca_cert_store(tls::create_ca_store(cert.c_str(), cert.size()));
cli.enable_server_certificate_verification(true);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
// CA certs loaded through the universal Client must be transferred to the
// client created internally for following an HTTPS redirect. Regression test:
// Client::load_ca_cert_store() used to bypass the PEM-based path that stores
// the CA data for redirect transfer.
TEST(UniversalClientRedirectTest, LoadCaCertStore) {
auto ssl_port = PORT + 1;
SSLServer ssl_svr1(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(ssl_svr1.is_valid());
ssl_svr1.Get("/index", [&](const Request &, Response &res) {
res.set_redirect("https://127.0.0.1:" + std::to_string(ssl_port) +
"/index");
});
SSLServer ssl_svr2(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(ssl_svr2.is_valid());
ssl_svr2.Get("/index", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(ssl_svr1.listen("127.0.0.1", PORT)); });
thread t2 =
thread([&]() { ASSERT_TRUE(ssl_svr2.listen("127.0.0.1", ssl_port)); });
auto se = detail::scope_exit([&] {
ssl_svr2.stop();
ssl_svr1.stop();
t2.join();
t.join();
ASSERT_FALSE(ssl_svr1.is_running());
});
ssl_svr1.wait_until_ready();
ssl_svr2.wait_until_ready();
Client cli("https://127.0.0.1:" + std::to_string(PORT));
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_server_certificate_verification(true);
cli.set_follow_location(true);
cli.set_connection_timeout(30);
auto res = cli.Get("/index");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
// enable_system_ca(true) loads system CA certs in addition to a custom CA,
// so a host signed by a system CA verifies even though a custom CA is set
TEST(SSLClientTest, EnableSystemCaWithCustomCa_Online) {
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
SSLClient cli("google.com");
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_system_ca(true);
cli.enable_server_certificate_verification(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::MovedPermanently_301, res->status);
}
// The custom CA remains effective when combined with the system CA
TEST(SSLClientTest, EnableSystemCaCustomCaVerifiesLocalServer) {
SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli("127.0.0.1", PORT);
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_system_ca(true);
cli.enable_server_certificate_verification(true);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
// Regression test: for IP hosts the Mbed TLS and wolfSSL backends used to
// skip chain verification entirely (only the certificate identity was
// checked), so a server presenting an untrusted certificate with a matching
// IP SAN was accepted. The chain must be validated for IP hosts too.
TEST(SSLClientTest, IpHostUntrustedChainFails) {
SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request &, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli("127.0.0.1", PORT);
std::string cert;
// A trusted CA that did not sign the server certificate. The server cert
// (cert2) carries the matching IP SAN, so only chain validation can reject
// this connection.
read_file(CLIENT_CA_CERT_FILE, cert);
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_server_certificate_verification(true);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_FALSE(res);
}
// enable_system_ca(false) prevents system CA loading entirely
TEST(SSLClientTest, DisableSystemCa_Online) {
SSLClient cli("google.com");
cli.enable_system_ca(false);
cli.enable_server_certificate_verification(true);
auto res = cli.Get("/");
ASSERT_FALSE(res);
// OpenSSL reports a verification failure; Mbed TLS fails the handshake
// itself when the CA chain is empty
EXPECT_TRUE(res.error() == Error::SSLServerVerification ||
res.error() == Error::SSLConnection);
}
// The universal Client forwards enable_system_ca()
TEST(UniversalClientTest, EnableSystemCaWithCustomCa_Online) {
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
Client cli("https://google.com");
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_system_ca(true);
cli.enable_server_certificate_verification(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::MovedPermanently_301, res->status);
}
// The system CA policy must carry over to the client created internally for
// following a cross-host HTTPS redirect
TEST(UniversalClientRedirectTest, EnableSystemCaCarriesOver_Online) {
SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/index", [&](const Request &, Response &res) {
res.set_redirect("https://www.google.com/");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli("https://127.0.0.1:" + std::to_string(PORT));
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
cli.load_ca_cert_store(cert.c_str(), cert.size());
cli.enable_system_ca(true);
cli.enable_server_certificate_verification(true);
cli.set_follow_location(true);
cli.set_connection_timeout(30);
// Receiving any response proves the redirect client completed the TLS
// handshake with a system-CA-signed host
auto res = cli.Get("/index");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
}
TEST(MultipartFormDataTest, LargeData) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
svr.Post("/post", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
if (req.is_multipart_form_data()) {
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
EXPECT_TRUE(std::string(items[0].name) == "document");
EXPECT_EQ(size_t(1024 * 1024 * 2), items[0].content.size());
EXPECT_TRUE(items[0].filename == "2MB_data");
EXPECT_TRUE(items[0].content_type == "application/octet-stream");
EXPECT_TRUE(items[1].name == "hello");
EXPECT_TRUE(items[1].content == "world");
EXPECT_TRUE(items[1].filename == "");
EXPECT_TRUE(items[1].content_type == "");
} else {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
}
});
auto port = svr.bind_to_any_port(HOST);
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
std::string data(1024 * 1024 * 2, '.');
std::stringstream buffer;
buffer << data;
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(false);
UploadFormDataItems items{
{"document", buffer.str(), "2MB_data", "application/octet-stream"},
{"hello", "world", "", ""},
};
auto res = cli.Post("/post", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
}
TEST(MultipartFormDataTest, DataProviderItems) {
std::random_device seed_gen;
std::mt19937 random(seed_gen());
std::string rand1;
rand1.resize(1000);
std::generate(rand1.begin(), rand1.end(), [&]() { return random(); });
std::string rand2;
rand2.resize(3000);
std::generate(rand2.begin(), rand2.end(), [&]() { return random(); });
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
svr.Post("/post-none", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
ASSERT_FALSE(req.is_multipart_form_data());
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
EXPECT_EQ(body, "");
});
svr.Post("/post-items", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
ASSERT_TRUE(req.is_multipart_form_data());
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
ASSERT_TRUE(items.size() == 2);
EXPECT_EQ(std::string(items[0].name), "name1");
EXPECT_EQ(items[0].content, "Testing123");
EXPECT_EQ(items[0].filename, "filename1");
EXPECT_EQ(items[0].content_type, "application/octet-stream");
EXPECT_EQ(items[1].name, "name2");
EXPECT_EQ(items[1].content, "Testing456");
EXPECT_EQ(items[1].filename, "");
EXPECT_EQ(items[1].content_type, "");
});
svr.Post("/post-providers", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
ASSERT_TRUE(req.is_multipart_form_data());
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
ASSERT_TRUE(items.size() == 2);
EXPECT_EQ(items[0].name, "name3");
EXPECT_EQ(items[0].content, rand1);
EXPECT_EQ(items[0].filename, "filename3");
EXPECT_EQ(items[0].content_type, "");
EXPECT_EQ(items[1].name, "name4");
EXPECT_EQ(items[1].content, rand2);
EXPECT_EQ(items[1].filename, "filename4");
EXPECT_EQ(items[1].content_type, "");
});
svr.Post("/post-both", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
ASSERT_TRUE(req.is_multipart_form_data());
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
ASSERT_TRUE(items.size() == 4);
EXPECT_EQ(std::string(items[0].name), "name1");
EXPECT_EQ(items[0].content, "Testing123");
EXPECT_EQ(items[0].filename, "filename1");
EXPECT_EQ(items[0].content_type, "application/octet-stream");
EXPECT_EQ(items[1].name, "name2");
EXPECT_EQ(items[1].content, "Testing456");
EXPECT_EQ(items[1].filename, "");
EXPECT_EQ(items[1].content_type, "");
EXPECT_EQ(items[2].name, "name3");
EXPECT_EQ(items[2].content, rand1);
EXPECT_EQ(items[2].filename, "filename3");
EXPECT_EQ(items[2].content_type, "");
EXPECT_EQ(items[3].name, "name4");
EXPECT_EQ(items[3].content, rand2);
EXPECT_EQ(items[3].filename, "filename4");
EXPECT_EQ(items[3].content_type, "");
});
auto port = svr.bind_to_any_port("localhost");
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
SSLClient cli("localhost", port);
cli.enable_server_certificate_verification(false);
UploadFormDataItems items{
{"name1", "Testing123", "filename1", "application/octet-stream"},
{"name2", "Testing456", "", ""}, // not a file
};
{
auto res = cli.Post("/post-none", {}, {}, {});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
FormDataProviderItems providers;
{
auto res =
cli.Post("/post-items", {}, items, providers); // empty providers
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
providers.push_back({"name3",
[&](size_t offset, httplib::DataSink &sink) -> bool {
// test the offset is given correctly at each step
if (!offset)
sink.os.write(rand1.data(), 30);
else if (offset == 30)
sink.os.write(rand1.data() + 30, 300);
else if (offset == 330)
sink.os.write(rand1.data() + 330, 670);
else if (offset == rand1.size())
sink.done();
return true;
},
"filename3",
{}});
providers.push_back({"name4",
[&](size_t offset, httplib::DataSink &sink) -> bool {
// test the offset is given correctly at each step
if (!offset)
sink.os.write(rand2.data(), 2000);
else if (offset == 2000)
sink.os.write(rand2.data() + 2000, 1);
else if (offset == 2001)
sink.os.write(rand2.data() + 2001, 999);
else if (offset == rand2.size())
sink.done();
return true;
},
"filename4",
{}});
{
auto res = cli.Post("/post-providers", {}, {}, providers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
{
auto res = cli.Post("/post-both", {}, items, providers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
}
}
TEST(MultipartFormDataTest, BadHeader) {
Server svr;
svr.Post("/post", [&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string body =
"This is the preamble. It is to be ignored, though it\r\n"
"is a handy place for composition agents to include an\r\n"
"explanatory note to non-MIME conformant readers.\r\n"
"\r\n"
"\r\n"
"--simple boundary\r\n"
"Content-Disposition: form-data; name=\"field1\"\r\n"
": BAD...\r\n"
"\r\n"
"value1\r\n"
"--simple boundary\r\n"
"Content-Disposition: form-data; name=\"field2\"; "
"filename=\"example.txt\"\r\n"
"\r\n"
"value2\r\n"
"--simple boundary--\r\n"
"This is the epilogue. It is also to be ignored.\r\n";
std::string content_type =
R"(multipart/form-data; boundary="simple boundary")";
Client cli(HOST, PORT);
auto res = cli.Post("/post", body, content_type.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
}
TEST(MultipartFormDataTest, WithPreamble) {
Server svr;
svr.Post("/post", [&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string body =
"This is the preamble. It is to be ignored, though it\r\n"
"is a handy place for composition agents to include an\r\n"
"explanatory note to non-MIME conformant readers.\r\n"
"\r\n"
"\r\n"
"--simple boundary\r\n"
"Content-Disposition: form-data; name=\"field1\"\r\n"
"\r\n"
"value1\r\n"
"--simple boundary\r\n"
"Content-Disposition: form-data; name=\"field2\"; "
"filename=\"example.txt\"\r\n"
"\r\n"
"value2\r\n"
"--simple boundary--\r\n"
"This is the epilogue. It is also to be ignored.\r\n";
std::string content_type =
R"(multipart/form-data; boundary="simple boundary")";
Client cli(HOST, PORT);
auto res = cli.Post("/post", body, content_type.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MultipartFormDataTest, PostCustomBoundary) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
svr.Post("/post_customboundary", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
if (req.is_multipart_form_data()) {
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
EXPECT_TRUE(std::string(items[0].name) == "document");
EXPECT_EQ(size_t(1024 * 1024 * 2), items[0].content.size());
EXPECT_TRUE(items[0].filename == "2MB_data");
EXPECT_TRUE(items[0].content_type == "application/octet-stream");
EXPECT_TRUE(items[1].name == "hello");
EXPECT_TRUE(items[1].content == "world");
EXPECT_TRUE(items[1].filename == "");
EXPECT_TRUE(items[1].content_type == "");
} else {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
}
});
auto port = svr.bind_to_any_port("localhost");
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
std::string data(1024 * 1024 * 2, '.');
std::stringstream buffer;
buffer << data;
SSLClient cli("localhost", port);
cli.enable_server_certificate_verification(false);
UploadFormDataItems items{
{"document", buffer.str(), "2MB_data", "application/octet-stream"},
{"hello", "world", "", ""},
};
auto res = cli.Post("/post_customboundary", {}, items, "abc-abc");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
}
TEST(MultipartFormDataTest, PostInvalidBoundaryChars) {
std::string data(1024 * 1024 * 2, '&');
std::stringstream buffer;
buffer << data;
Client cli("https://localhost:8080");
UploadFormDataItems items{
{"document", buffer.str(), "2MB_data", "application/octet-stream"},
{"hello", "world", "", ""},
};
for (const char &c : " \t\r\n") {
auto res =
cli.Post("/invalid_boundary", {}, items, string("abc123").append(1, c));
ASSERT_EQ(Error::UnsupportedMultipartBoundaryChars, res.error());
ASSERT_FALSE(res);
}
}
TEST(MultipartFormDataTest, PutFormData) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
svr.Put("/put", [&](const Request &req, const Response & /*res*/,
const ContentReader &content_reader) {
if (req.is_multipart_form_data()) {
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
EXPECT_TRUE(std::string(items[0].name) == "document");
EXPECT_EQ(size_t(1024 * 1024 * 2), items[0].content.size());
EXPECT_TRUE(items[0].filename == "2MB_data");
EXPECT_TRUE(items[0].content_type == "application/octet-stream");
EXPECT_TRUE(items[1].name == "hello");
EXPECT_TRUE(items[1].content == "world");
EXPECT_TRUE(items[1].filename == "");
EXPECT_TRUE(items[1].content_type == "");
} else {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
}
});
auto port = svr.bind_to_any_port("localhost");
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
std::string data(1024 * 1024 * 2, '&');
std::stringstream buffer;
buffer << data;
SSLClient cli("localhost", port);
cli.enable_server_certificate_verification(false);
UploadFormDataItems items{
{"document", buffer.str(), "2MB_data", "application/octet-stream"},
{"hello", "world", "", ""},
};
auto res = cli.Put("/put", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
}
TEST(MultipartFormDataTest, PutFormDataCustomBoundary) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
svr.Put("/put_customboundary",
[&](const Request &req, const Response & /*res*/,
const ContentReader &content_reader) {
if (req.is_multipart_form_data()) {
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
EXPECT_TRUE(std::string(items[0].name) == "document");
EXPECT_EQ(size_t(1024 * 1024 * 2), items[0].content.size());
EXPECT_TRUE(items[0].filename == "2MB_data");
EXPECT_TRUE(items[0].content_type == "application/octet-stream");
EXPECT_TRUE(items[1].name == "hello");
EXPECT_TRUE(items[1].content == "world");
EXPECT_TRUE(items[1].filename == "");
EXPECT_TRUE(items[1].content_type == "");
} else {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
}
});
auto port = svr.bind_to_any_port("localhost");
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
std::string data(1024 * 1024 * 2, '&');
std::stringstream buffer;
buffer << data;
SSLClient cli("localhost", port);
cli.enable_server_certificate_verification(false);
UploadFormDataItems items{
{"document", buffer.str(), "2MB_data", "application/octet-stream"},
{"hello", "world", "", ""},
};
auto res = cli.Put("/put_customboundary", {}, items, "abc-abc_");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
}
TEST(MultipartFormDataTest, PutInvalidBoundaryChars) {
std::string data(1024 * 1024 * 2, '&');
std::stringstream buffer;
buffer << data;
Client cli("https://localhost:8080");
cli.enable_server_certificate_verification(false);
UploadFormDataItems items{
{"document", buffer.str(), "2MB_data", "application/octet-stream"},
{"hello", "world", "", ""},
};
for (const char &c : " \t\r\n") {
auto res = cli.Put("/put", {}, items, string("abc123").append(1, c));
ASSERT_EQ(Error::UnsupportedMultipartBoundaryChars, res.error());
ASSERT_FALSE(res);
}
}
TEST(MultipartFormDataTest, AlternateFilename) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &req, Response &res) {
ASSERT_EQ(2u, req.form.files.size());
ASSERT_EQ(1u, req.form.fields.size());
// Test files
const auto &file1 = req.form.get_file("file1");
ASSERT_EQ("file1", file1.name);
ASSERT_EQ("A.txt", file1.filename);
ASSERT_EQ("text/plain", file1.content_type);
ASSERT_EQ("Content of a.txt.\r\n", file1.content);
const auto &file2 = req.form.get_file("file2");
ASSERT_EQ("file2", file2.name);
ASSERT_EQ("a.html", file2.filename);
ASSERT_EQ("text/html", file2.content_type);
ASSERT_EQ("<!DOCTYPE html><title>Content of a.html.</title>\r\n",
file2.content);
// Test text field
const auto &text = req.form.get_field("text");
ASSERT_EQ("text default", text);
res.set_content("ok", "text/plain");
handled = true;
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
auto req = "POST /test HTTP/1.1\r\n"
"Content-Type: multipart/form-data;boundary=--------\r\n"
"Content-Length: 399\r\n"
"\r\n"
"----------\r\n"
"Content-Disposition: form-data; name=\"text\"\r\n"
"\r\n"
"text default\r\n"
"----------\r\n"
"Content-Disposition: form-data; filename*=\"UTF-8''%41.txt\"; "
"filename=\"a.txt\"; name=\"file1\"\r\n"
"Content-Type: text/plain\r\n"
"\r\n"
"Content of a.txt.\r\n"
"\r\n"
"----------\r\n"
"Content-Disposition: form-data; name=\"file2\" ;filename = "
"\"a.html\"\r\n"
"Content-Type: text/html\r\n"
"\r\n"
"<!DOCTYPE html><title>Content of a.html.</title>\r\n"
"\r\n"
"------------\r\n";
ASSERT_TRUE(send_request(1, req));
}
TEST(MultipartFormDataTest, AlternateFilenameLongValueAndCaseInsensitive) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &req, Response &res) {
// Case-insensitive "utf-8''" prefix with a long value
const auto &file = req.form.get_file("file1");
ASSERT_EQ("file1", file.name);
// 8000 chars exercises both the Content-Disposition parser and the
// filename* parser near the CPPHTTPLIB_HEADER_MAX_LENGTH limit (8192).
// Prior to the fix, std::regex_match on this header would cause O(N)
// stack recursion in libstdc++, leading to SIGSEGV.
std::string expected_filename(8000, 'A');
ASSERT_EQ(expected_filename, file.filename);
res.set_content("ok", "text/plain");
handled = true;
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
// Build body with a long filename* value using mixed-case prefix "Utf-8''"
// Regression test for GHSA-qq6v-r583-3h69
std::string long_filename(8000, 'A');
std::string body = "----------\r\n"
"Content-Disposition: form-data; name=\"file1\"; "
"filename*=\"Utf-8''" +
long_filename +
"\"\r\n"
"\r\n"
"hello\r\n"
"------------\r\n";
auto req = "POST /test HTTP/1.1\r\n"
"Content-Type: multipart/form-data;boundary=--------\r\n"
"Content-Length: " +
std::to_string(body.size()) + "\r\n\r\n" + body;
ASSERT_TRUE(send_request(1, req));
}
TEST(MultipartFormDataTest, CloseDelimiterWithoutCRLF) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &req, Response &) {
ASSERT_EQ(2u, req.form.fields.size());
const auto &text1 = req.form.get_field("text1");
ASSERT_EQ("text1", text1);
const auto &text2 = req.form.get_field("text2");
ASSERT_EQ("text2", text2);
handled = true;
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
auto req = "POST /test HTTP/1.1\r\n"
"Content-Type: multipart/form-data;boundary=--------\r\n"
"Content-Length: 146\r\n"
"\r\n----------\r\n"
"Content-Disposition: form-data; name=\"text1\"\r\n"
"\r\n"
"text1"
"\r\n----------\r\n"
"Content-Disposition: form-data; name=\"text2\"\r\n"
"\r\n"
"text2"
"\r\n------------";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
ASSERT_EQ("200", response.substr(9, 3));
}
TEST(MultipartFormDataTest, ContentLength) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &req, Response &) {
ASSERT_EQ(2u, req.form.fields.size());
const auto &text1 = req.form.get_field("text1");
ASSERT_EQ("text1", text1);
const auto &text2 = req.form.get_field("text2");
ASSERT_EQ("text2", text2);
handled = true;
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
auto req = "POST /test HTTP/1.1\r\n"
"Content-Type: multipart/form-data;boundary=--------\r\n"
"Content-Length: 167\r\n"
"\r\n----------\r\n"
"Content-Disposition: form-data; name=\"text1\"\r\n"
"Content-Length: 5\r\n"
"\r\n"
"text1"
"\r\n----------\r\n"
"Content-Disposition: form-data; name=\"text2\"\r\n"
"\r\n"
"text2"
"\r\n------------\r\n";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
ASSERT_EQ("200", response.substr(9, 3));
}
TEST(MultipartFormDataTest, AccessPartHeaders) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &req, Response &) {
ASSERT_EQ(2u, req.form.fields.size());
const auto &text1 = req.form.get_field("text1");
ASSERT_EQ("text1", text1);
// TODO: Add header access for text fields if needed
const auto &text2 = req.form.get_field("text2");
ASSERT_EQ("text2", text2);
// TODO: Header access for text fields needs to be implemented
// auto &headers = it->second.headers;
// ASSERT_EQ(3U, headers.size());
// auto custom_header = headers.find("x-whatever");
// ASSERT_TRUE(custom_header != headers.end());
// ASSERT_NE("customvalue", custom_header->second);
// ASSERT_EQ("CustomValue", custom_header->second);
// ASSERT_TRUE(headers.find("X-Test") == headers.end()); // text1 header
handled = true;
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
auto req = "POST /test HTTP/1.1\r\n"
"Content-Type: multipart/form-data;boundary=--------\r\n"
"Content-Length: 232\r\n"
"\r\n----------\r\n"
"Content-Disposition: form-data; name=\"text1\"\r\n"
"Content-Length: 5\r\n"
"X-Test: 1\r\n"
"\r\n"
"text1"
"\r\n----------\r\n"
"Content-Disposition: form-data; name=\"text2\"\r\n"
"Content-Type: text/plain\r\n"
"X-Whatever: CustomValue\r\n"
"\r\n"
"text2"
"\r\n------------\r\n"
"That should be disregarded. Not even read";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
ASSERT_EQ("200", response.substr(9, 3));
}
TEST(MultipartFormDataTest, LargeHeader) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &req, Response &) {
ASSERT_EQ(1u, req.form.fields.size());
const auto &text = req.form.get_field("name1");
ASSERT_EQ("text1", text);
handled = true;
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
auto boundary = std::string("cpp-httplib-multipart-data");
std::string content = "--" + boundary +
"\r\n"
"Content-Disposition: form-data; name=\"name1\"\r\n"
"\r\n"
"text1\r\n"
"--" +
boundary + "--\r\n";
std::string header_prefix = "POST /test HTTP/1.1\r\n"
"Content-Type: multipart/form-data;boundary=" +
boundary +
"\r\n"
"Content-Length: " +
std::to_string(content.size()) +
"\r\n"
"Dummy-Header: ";
std::string header_suffix = "\r\n"
"\r\n";
size_t read_buff_size = 1024u * 4; // SocketStream::read_buff_size_
size_t header_dummy_size =
read_buff_size -
(header_prefix.size() + header_suffix.size() + boundary.size() / 2);
auto header_dummy = std::string(header_dummy_size, '@');
auto req = header_prefix + header_dummy + header_suffix + content;
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
ASSERT_EQ("200", response.substr(9, 3));
}
TEST(MultipartFormDataTest, UploadItemsHasContentLength) {
// Verify that Post(path, headers, UploadFormDataItems) sends Content-Length
// (not chunked Transfer-Encoding) after the streaming refactor.
auto handled = false;
Server svr;
svr.Post("/upload", [&](const Request &req, Response &res) {
auto cl_it = req.headers.find("Content-Length");
EXPECT_TRUE(cl_it != req.headers.end());
auto te_it = req.headers.find("Transfer-Encoding");
EXPECT_TRUE(te_it == req.headers.end());
EXPECT_EQ(2u, req.form.fields.size() + req.form.files.size());
res.set_content("ok", "text/plain");
handled = true;
});
auto port = svr.bind_to_any_port(HOST);
auto t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
UploadFormDataItems items = {
{"field1", "hello", "", "text/plain"},
{"file1", "world", "test.txt", "application/octet-stream"},
};
Client cli(HOST, port);
auto res = cli.Post("/upload", {}, items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MultipartFormDataTest, ContentProviderCoalescesWrites) {
// Verify that make_multipart_content_provider coalesces many small segments
// into fewer sink.write() calls to avoid TCP packet fragmentation (#2410).
constexpr size_t kItemCount = 1000;
UploadFormDataItems items;
items.reserve(kItemCount);
for (size_t i = 0; i < kItemCount; i++) {
items.push_back(
{"field" + std::to_string(i), "value" + std::to_string(i), "", ""});
}
const std::string boundary = "----test-boundary";
auto content_length = detail::get_multipart_content_length(items, boundary);
auto provider = detail::make_multipart_content_provider(items, boundary);
// Drive the provider the same way write_content_with_progress does
size_t write_count = 0;
size_t total_bytes = 0;
DataSink sink;
size_t offset = 0;
sink.write = [&](const char *d, size_t l) -> bool {
(void)d;
write_count++;
total_bytes += l;
offset += l;
return true;
};
sink.is_writable = []() -> bool { return true; };
while (offset < content_length) {
ASSERT_TRUE(provider(offset, content_length - offset, sink));
}
EXPECT_EQ(content_length, total_bytes);
// The total number of segments is 3 * kItemCount + 1 = 3001.
// With buffering into 64KB blocks, write_count should be much smaller.
auto segment_count = 3 * kItemCount + 1;
EXPECT_LT(write_count, segment_count / 10);
}
TEST(MultipartFormDataTest, ManyItemsEndToEnd) {
// Integration test: send many UploadFormDataItems and verify the server
// receives all of them correctly (#2410).
constexpr size_t kItemCount = 500;
auto handled = false;
Server svr;
svr.Post("/upload", [&](const Request &req, Response &res) {
EXPECT_EQ(kItemCount, req.form.fields.size());
for (size_t i = 0; i < kItemCount; i++) {
auto key = "field" + std::to_string(i);
auto val = "value" + std::to_string(i);
auto it = req.form.fields.find(key);
if (it != req.form.fields.end()) {
EXPECT_EQ(val, it->second.content);
} else {
ADD_FAILURE() << "Missing field: " << key;
}
}
res.set_content("ok", "text/plain");
handled = true;
});
auto port = svr.bind_to_any_port(HOST);
auto t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
UploadFormDataItems items;
items.reserve(kItemCount);
for (size_t i = 0; i < kItemCount; i++) {
items.push_back(
{"field" + std::to_string(i), "value" + std::to_string(i), "", ""});
}
Client cli(HOST, port);
auto res = cli.Post("/upload", items);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MultipartFormDataTest, MakeFileProvider) {
// Verify make_file_provider sends a file's contents correctly.
const std::string file_content(4096, 'Z');
const std::string tmp_path = "./httplib_test_make_file_provider.bin";
{
std::ofstream ofs(tmp_path, std::ios::binary);
ofs.write(file_content.data(),
static_cast<std::streamsize>(file_content.size()));
}
auto handled = false;
Server svr;
svr.Post("/upload", [&](const Request &req, Response & /*res*/,
const ContentReader &content_reader) {
ASSERT_TRUE(req.is_multipart_form_data());
std::vector<FormData> items;
content_reader(
[&](const FormData &file) {
items.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
items.back().content.append(data, data_length);
return true;
});
ASSERT_EQ(1u, items.size());
EXPECT_EQ("myfile", items[0].name);
EXPECT_EQ("data.bin", items[0].filename);
EXPECT_EQ("application/octet-stream", items[0].content_type);
EXPECT_EQ(file_content, items[0].content);
handled = true;
});
auto port = svr.bind_to_any_port(HOST);
auto t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
std::remove(tmp_path.c_str());
});
svr.wait_until_ready();
FormDataProviderItems providers;
providers.push_back(make_file_provider("myfile", tmp_path, "data.bin",
"application/octet-stream"));
Client cli(HOST, port);
auto res = cli.Post("/upload", {}, {}, providers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MultipartFormDataTest, ExcessivePartHeaders) {
// Verify that a multipart part with more than CPPHTTPLIB_HEADER_MAX_COUNT
// (100) headers is rejected with a 400 Bad Request response.
Server svr;
svr.Post("/post", [&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Build a multipart part with 101 custom headers (exceeding
// CPPHTTPLIB_HEADER_MAX_COUNT=100)
std::stringstream body;
body << "--simpleboundary\r\n"
<< "Content-Disposition: form-data; name=\"field1\"\r\n";
for (int i = 0; i < 101; i++) {
body << "X-Custom-Header-" << i << ": value\r\n";
}
body << "\r\n"
<< "value1\r\n"
<< "--simpleboundary--\r\n";
std::string content_type = "multipart/form-data; boundary=simpleboundary";
Client cli(HOST, PORT);
auto res = cli.Post("/post", body.str(), content_type.c_str());
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
}
TEST(MultipartFormDataTest, NoInitialBoundaryParsingIsNotQuadratic) {
// A body that never contains the declared boundary must not be accumulated
// while the parser waits for it. Buffering it would also make every read
// rescan everything seen so far, which is quadratic in the body size.
Server svr;
svr.Post("/post", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_read_timeout(60, 0);
cli.set_write_timeout(60, 0);
// '-' is the worst case: it matches the first character of the boundary, so
// every position has to be checked against it.
auto post_dashes = [&](size_t size) {
const std::string body(size, '-');
auto start = std::chrono::steady_clock::now();
auto res =
cli.Post("/post", body, "multipart/form-data; boundary=simpleboundary");
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
EXPECT_TRUE(res) << "Error: " << to_string(res.error());
if (res) { EXPECT_EQ(StatusCode::BadRequest_400, res->status); }
return ms;
};
// Not named `small`/`large`: <rpcndr.h> defines `small` as a macro on
// Windows.
auto ms_4mb = post_dashes(4u * 1024 * 1024);
auto ms_16mb = post_dashes(16u * 1024 * 1024);
// Comparing the two sizes rather than checking an absolute duration keeps
// this meaningful across build types and CI load, both of which move the
// absolute numbers by more than an order of magnitude. Four times the bytes
// costs about four times the time when parsing is linear, and about sixteen
// times when the body is buffered and rescanned.
ASSERT_GT(ms_4mb, 0) << "timer resolution too coarse to compare";
EXPECT_LT(ms_16mb, ms_4mb * 10)
<< "4MB took " << ms_4mb << "ms but 16MB took " << ms_16mb
<< "ms, which suggests the body is being buffered and rescanned";
}
TEST(MultipartFormDataTest, BoundaryNotFollowedByDelimiterFailsFast) {
// A boundary can only be followed by CRLF or by "--", so anything else is
// malformed no matter what comes next. The parser must say so right away
// instead of buffering the rest of the declared body.
Server svr;
svr.Post("/post", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Announce a 1 MB body but send only the malformed prefix. A parser that
// buffers instead of failing would still be waiting for the remaining bytes.
const std::string body = "--zzzz\r\n"
"Content-Disposition: form-data; name=\"text1\"\r\n"
"\r\n"
"text1"
"\r\n--zzzzXX";
const std::string req = "POST /post HTTP/1.1\r\n"
"Content-Type: multipart/form-data; boundary=zzzz\r\n"
"Content-Length: 1048576\r\n"
"\r\n" +
body;
std::string response;
ASSERT_TRUE(send_request(3, req, &response, port));
ASSERT_GE(response.size(), 12u) << "no response before the read timeout";
EXPECT_EQ("400", response.substr(9, 3));
}
// Posts a multipart body split into two writes, so that the server sees the
// split at whatever offset the caller chose, and expects the single "text1"
// field to come through.
static void expect_split_multipart_ok(const std::string &body1,
const std::string &body2) {
auto handled = false;
Server svr;
svr.Post("/post", [&](const Request &req, Response &) {
EXPECT_EQ(1u, req.form.fields.size());
EXPECT_EQ("text1", req.form.get_field("text1"));
handled = true;
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
// "Connection: close" makes the server close once it has answered, so the
// response drain ends immediately instead of idling until the read timeout.
const std::string head =
"POST /post HTTP/1.1\r\n"
"Content-Type: multipart/form-data; boundary=zzzz\r\n"
"Connection: close\r\n"
"Content-Length: " +
std::to_string(body1.size() + body2.size()) + "\r\n\r\n";
std::string response;
ASSERT_TRUE(send_request_in_parts(3, {head + body1, body2}, &response, port));
ASSERT_GE(response.size(), 12u) << "no response";
EXPECT_EQ("200", response.substr(9, 3));
}
TEST(MultipartFormDataTest, EpilogueSplitAcrossReadsIsIgnored) {
// RFC 2046: everything after the close-delimiter is an epilogue and is to be
// ignored, including when it does not arrive in the same read as the
// close-delimiter itself.
expect_split_multipart_ok("--zzzz\r\n"
"Content-Disposition: form-data; name=\"text1\"\r\n"
"\r\n"
"text1"
"\r\n--zzzz--",
"\r\nthis epilogue must be ignored\r\n");
}
TEST(MultipartFormDataTest, InitialBoundarySplitAfterLongPreamble) {
// The initial boundary may be preceded by a preamble of any length and may
// straddle a read boundary. Discarding data while waiting for it must not
// throw away a partial boundary sitting at the end of the buffer.
expect_split_multipart_ok(std::string(64u * 1024, 'p') + "--zz",
"zz\r\n"
"Content-Disposition: form-data; name=\"text1\"\r\n"
"\r\n"
"text1"
"\r\n--zzzz--\r\n");
}
TEST(MultipartFormDataTest, BoundaryLengthLimitIsEnforced) {
// The received boundary was only checked for being non-empty, so it could be
// as long as a header is allowed to be. The parser scans the body for
// "--" + boundary, so a body crafted to repeat that delimiter's leading bytes
// costs a nearly full comparison at nearly every position, making the
// boundary's length a multiplier on the worst-case parsing cost. RFC 2046
// 5.1.1 caps a boundary at 70 characters; honoring that caps the multiplier.
Server svr;
svr.Post("/post", [](const Request &req, Response &res) {
EXPECT_EQ(1u, req.form.fields.size());
EXPECT_EQ("text1", req.form.get_field("text1"));
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto post_with_boundary_of_length = [&](size_t len) {
const std::string boundary(len, 'a');
const std::string body =
"--" + boundary +
"\r\n"
"Content-Disposition: form-data; name=\"text1\"\r\n"
"\r\n"
"text1"
"\r\n--" +
boundary + "--\r\n";
return cli.Post("/post", body, "multipart/form-data; boundary=" + boundary);
};
auto res = post_with_boundary_of_length(70);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
res = post_with_boundary_of_length(71);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
}
TEST(MakeFileBodyTest, Basic) {
const std::string file_content(4096, 'Z');
const std::string tmp_path = "./httplib_test_make_file_body.bin";
{
std::ofstream ofs(tmp_path, std::ios::binary);
ofs.write(file_content.data(),
static_cast<std::streamsize>(file_content.size()));
}
auto handled = false;
Server svr;
svr.Post("/upload", [&](const Request &req, Response &res) {
EXPECT_EQ(file_content, req.body);
handled = true;
res.status = StatusCode::OK_200;
});
auto port = svr.bind_to_any_port(HOST);
auto t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
std::remove(tmp_path.c_str());
});
svr.wait_until_ready();
auto fb = make_file_body(tmp_path);
ASSERT_GT(fb.first, 0u);
Client cli(HOST, port);
auto res =
cli.Post("/upload", fb.first, fb.second, "application/octet-stream");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(MakeFileBodyTest, TruncatedFileMakesTheProviderFail) {
const std::string path = "./httplib_test_make_file_body_truncated.bin";
{
std::ofstream ofs(path, std::ios::binary);
const std::string content(100, 'A');
ofs.write(content.data(), static_cast<std::streamsize>(content.size()));
}
auto cleanup = detail::scope_exit([&] { std::remove(path.c_str()); });
auto body = make_file_body(path);
ASSERT_EQ(100u, body.first);
ASSERT_TRUE(static_cast<bool>(body.second));
// Rewritten shorter after its length was measured - and, on a server, after
// that length has already gone out as Content-Length.
{
std::ofstream ofs(path, std::ios::binary | std::ios::trunc);
const std::string content(10, 'A');
ofs.write(content.data(), static_cast<std::streamsize>(content.size()));
}
std::string written;
DataSink sink;
sink.write = [&](const char *d, size_t l) {
written.append(d, l);
return true;
};
// The provider has to report failure. write_content_with_progress() advances
// its offset only by what was written, so a provider that returns true
// without completing the length it promised is called again straight away,
// and again.
EXPECT_FALSE(body.second(0, body.first, sink));
EXPECT_EQ(std::string(10, 'A'), written);
}
TEST(MakeFileBodyTest, WholeFileIsSent) {
const std::string path = "./httplib_test_make_file_body_whole.bin";
const std::string content(9000, 'Z'); // spans more than one 8 KiB read
{
std::ofstream ofs(path, std::ios::binary);
ofs.write(content.data(), static_cast<std::streamsize>(content.size()));
}
auto cleanup = detail::scope_exit([&] { std::remove(path.c_str()); });
auto body = make_file_body(path);
ASSERT_EQ(content.size(), body.first);
ASSERT_TRUE(static_cast<bool>(body.second));
std::string written;
DataSink sink;
sink.write = [&](const char *d, size_t l) {
written.append(d, l);
return true;
};
EXPECT_TRUE(body.second(0, body.first, sink));
EXPECT_EQ(content, written);
}
TEST(TaskQueueTest, IncreaseAtomicInteger) {
static constexpr unsigned int number_of_tasks{1000000};
std::atomic_uint count{0};
std::unique_ptr<TaskQueue> task_queue{
new ThreadPool{CPPHTTPLIB_THREAD_POOL_COUNT}};
for (unsigned int i = 0; i < number_of_tasks; ++i) {
auto queued = task_queue->enqueue(
[&count] { count.fetch_add(1, std::memory_order_relaxed); });
EXPECT_TRUE(queued);
}
#ifdef CPPHTTPLIB_NO_EXCEPTIONS
task_queue->shutdown();
#else
EXPECT_NO_THROW(task_queue->shutdown());
#endif
EXPECT_EQ(number_of_tasks, count.load());
}
TEST(TaskQueueTest, IncreaseAtomicIntegerWithQueueLimit) {
static constexpr unsigned int number_of_tasks{1000000};
static constexpr unsigned int qlimit{2};
unsigned int queued_count{0};
std::atomic_uint count{0};
std::unique_ptr<TaskQueue> task_queue{
new ThreadPool{/*num_threads=*/1, /*max_threads=*/1, qlimit}};
for (unsigned int i = 0; i < number_of_tasks; ++i) {
if (task_queue->enqueue(
[&count] { count.fetch_add(1, std::memory_order_relaxed); })) {
queued_count++;
}
}
#ifdef CPPHTTPLIB_NO_EXCEPTIONS
task_queue->shutdown();
#else
EXPECT_NO_THROW(task_queue->shutdown());
#endif
EXPECT_EQ(queued_count, count.load());
EXPECT_TRUE(queued_count <= number_of_tasks);
EXPECT_TRUE(queued_count >= qlimit);
}
TEST(TaskQueueTest, IdleTimeoutAtRuntime) {
// Use a short idle timeout so a dynamic thread spawns, times out and
// exits during the test, and the pool keeps working afterwards.
std::unique_ptr<TaskQueue> task_queue{new ThreadPool{
/*num_threads=*/1, /*max_threads=*/2, /*max_queued_requests=*/0,
/*idle_timeout_sec=*/1}};
std::atomic_uint count{0};
std::condition_variable cv;
std::mutex mtx;
bool release = false;
// Block the base thread so the second task spawns a dynamic thread.
EXPECT_TRUE(task_queue->enqueue([&] {
std::unique_lock<std::mutex> lock(mtx);
while (!release) {
cv.wait(lock);
}
count++;
}));
EXPECT_TRUE(task_queue->enqueue([&] { count++; }));
// Let the dynamic thread finish its task and exceed the idle timeout.
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
{
std::unique_lock<std::mutex> lock(mtx);
release = true;
}
cv.notify_all();
// The pool must still accept and run tasks after the dynamic thread exited.
EXPECT_TRUE(task_queue->enqueue([&] { count++; }));
task_queue->shutdown();
EXPECT_EQ(3u, count.load());
}
TEST(TaskQueueTest, MaxQueuedRequests) {
static constexpr unsigned int qlimit{3};
std::unique_ptr<TaskQueue> task_queue{new ThreadPool{1, 1, qlimit}};
std::condition_variable sem_cv;
std::mutex sem_mtx;
int credits = 0;
bool queued;
/* Fill up the queue with tasks that will block until we give them credits to
* complete. */
for (unsigned int n = 0; n <= qlimit;) {
queued = task_queue->enqueue([&sem_mtx, &sem_cv, &credits] {
std::unique_lock<std::mutex> lock(sem_mtx);
while (credits <= 0) {
sem_cv.wait(lock);
}
/* Consume the credit and signal the test code if they are all gone. */
if (--credits == 0) { sem_cv.notify_one(); }
});
if (n < qlimit) {
/* The first qlimit enqueues must succeed. */
EXPECT_TRUE(queued);
} else {
/* The last one will succeed only when the worker thread
* starts and dequeues the first blocking task. Although
* not necessary for the correctness of this test, we sleep for
* a short while to avoid busy waiting. */
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (queued) { n++; }
}
/* Further enqueues must fail since the queue is full. */
for (auto i = 0; i < 4; i++) {
queued = task_queue->enqueue([] {});
EXPECT_FALSE(queued);
}
/* Give the credits to allow the previous tasks to complete. */
{
std::unique_lock<std::mutex> lock(sem_mtx);
credits += qlimit + 1;
}
sem_cv.notify_all();
/* Wait for all the credits to be consumed. */
{
std::unique_lock<std::mutex> lock(sem_mtx);
while (credits > 0) {
sem_cv.wait(lock);
}
}
/* Check that we are able again to enqueue at least qlimit tasks. */
for (unsigned int i = 0; i < qlimit; i++) {
queued = task_queue->enqueue([] {});
EXPECT_TRUE(queued);
}
#ifdef CPPHTTPLIB_NO_EXCEPTIONS
task_queue->shutdown();
#else
EXPECT_NO_THROW(task_queue->shutdown());
#endif
}
TEST(RedirectTest, SeeOtherDoesNotResendContentProviderBody) {
Server svr;
std::string method;
std::string body;
auto has_content_length = false;
auto has_transfer_encoding = false;
std::atomic<int> bad_requests{0};
// Leftover body bytes get parsed as a malformed request and answered with
// 400
svr.set_logger([&](const Request & /*req*/, const Response &res) {
if (res.status == StatusCode::BadRequest_400) { bad_requests++; }
});
svr.Post("/up", [](const Request & /*req*/, Response &res) {
res.set_redirect("/down", StatusCode::SeeOther_303);
});
svr.Get("/down", [&](const Request &req, Response &res) {
method = req.method;
body = req.body;
has_content_length = req.has_header("Content-Length");
has_transfer_encoding = req.has_header("Transfer-Encoding");
res.set_content("ok", "text/plain");
});
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();
const std::string payload = "SECRET-BODY";
auto check = [&](Client &cli, const Result &res) {
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
EXPECT_EQ("GET", method);
EXPECT_TRUE(body.empty());
EXPECT_FALSE(has_content_length);
EXPECT_FALSE(has_transfer_encoding);
// Nothing of the original body may be left on the connection
auto res2 = cli.Get("/down");
ASSERT_TRUE(res2) << "Error: " << to_string(res2.error());
EXPECT_EQ(StatusCode::OK_200, res2->status);
EXPECT_EQ("ok", res2->body);
EXPECT_EQ(0, bad_requests);
};
// With content length
{
Client cli(HOST, port);
cli.set_keep_alive(true);
cli.set_follow_location(true);
auto res = cli.Post(
"/up", payload.size(),
[&](size_t offset, size_t length, DataSink &sink) {
return sink.write(payload.data() + offset, length);
},
"text/plain");
check(cli, res);
}
// Without content length (chunked)
{
Client cli(HOST, port);
cli.set_keep_alive(true);
cli.set_follow_location(true);
auto res = cli.Post(
"/up",
[&](size_t /*offset*/, DataSink &sink) {
sink.write(payload.data(), payload.size());
sink.done();
return true;
},
"text/plain");
check(cli, res);
}
}
TEST(RedirectTest, LocationPathIsNotDecoded) {
Server svr;
std::string target;
svr.Get(R"(/start/.*)", [](const Request &req, Response &res) {
// Echo the still-encoded name back in the Location
const std::string prefix = "/start/";
res.status = StatusCode::Found_302;
res.set_header("Location", "/dest/" + req.target.substr(prefix.size()));
});
svr.Get(R"(/dest.*)", [&](const Request &req, Response &res) {
target = req.target;
res.set_content("ok", "text/plain");
});
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();
// Decoding the Location path would turn the first four into a path
// separator, a query delimiter, a fragment delimiter and a different
// percent-encoded octet. The rest must keep reaching the server as they are.
const std::vector<std::string> names = {
"a%2Fb", "x%3Fy", "x%23y", "a%2520b", "a%20b", "%C3%BC", "a-b_c.d~e",
};
for (auto path_encode : {true, false}) {
Client cli(HOST, port);
cli.set_follow_location(true);
cli.set_path_encode(path_encode);
for (const auto &name : names) {
target.clear();
auto res = cli.Get("/start/" + name);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("/dest/" + name, target);
}
}
}
TEST(RedirectTest, RedirectToUrlWithQueryParameters) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_redirect(R"(/hello?key=val%26key2%3Dval2)");
});
svr.Get("/hello", [](const Request &req, Response &res) {
res.set_content(req.get_param_value("key"), "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();
{
Client cli(HOST, PORT);
cli.set_follow_location(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("val&key2=val2", res->body);
}
}
#endif
TEST(RedirectTest, RedirectToUrlWithPlusInQueryParameters) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_redirect(R"(/hello?key=AByz09+~-._%20%26%3F%C3%BC%2B)");
});
svr.Get("/hello", [](const Request &req, Response &res) {
res.set_content(req.get_param_value("key"), "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();
{
Client cli(HOST, PORT);
cli.set_follow_location(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("AByz09 ~-._ &?ü+", res->body);
}
}
TEST(RedirectTest, RedirectWithPlusInPath) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_redirect("/a+b");
});
// Route pattern uses regex; escape + as \\+
svr.Get(R"(/a\+b)", [](const Request &req, Response &res) {
res.set_content(req.path, "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();
{
Client cli(HOST, PORT);
cli.set_follow_location(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("/a+b", res->body);
}
}
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");
client.set_follow_location(true);
auto res = client.Get("/Coollab-Art/Coollab/releases/download/1.1.1_UI-Scale/"
"Coollab-Windows.zip");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(9920427U, res->body.size());
}
#endif
TEST(VulnerabilityTest, CRLFInjection) {
Server svr;
svr.Post("/test1", [](const Request & /*req*/, Response &res) {
res.set_content("Hello 1", "text/plain");
});
svr.Delete("/test2", [](const Request & /*req*/, Response &res) {
res.set_content("Hello 2", "text/plain");
});
svr.Put("/test3", [](const Request & /*req*/, Response &res) {
res.set_content("Hello 3", "text/plain");
});
svr.Patch("/test4", [](const Request & /*req*/, Response &res) {
res.set_content("Hello 4", "text/plain");
});
svr.set_logger([](const Request &req, const Response & /*res*/) {
for (const auto &x : req.headers) {
auto key = x.first;
EXPECT_STRNE("evil", key.c_str());
}
});
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();
{
Client cli(HOST, PORT);
cli.Post("/test1", "A=B",
"application/x-www-form-urlencoded\r\nevil: hello1");
cli.Delete("/test2", "A=B", "text/plain\r\nevil: hello2");
cli.Put("/test3", "text", "text/plain\r\nevil: hello3");
cli.Patch("/test4", "content", "text/plain\r\nevil: hello4");
}
}
TEST(VulnerabilityTest, CRLFInjectionInHeaders) {
auto server_thread = std::thread([] {
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);
::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr));
::listen(srv, 1);
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
detail::set_socket_opt_time(cli, SOL_SOCKET, SO_RCVTIMEO, 1, 0);
std::string buf_all;
char buf[2048];
ssize_t n;
while ((n = ::recv(cli, buf, sizeof(buf), 0)) > 0) {
buf_all.append(buf, static_cast<size_t>(n));
size_t pos;
while ((pos = buf_all.find("\r\n\r\n")) != std::string::npos) {
auto request_block = buf_all.substr(0, pos + 4); // include separator
auto e = request_block.find("\r\n");
if (e != std::string::npos) {
auto request_line = request_block.substr(0, e);
std::string msg =
"CRLF injection detected in request line: '" + request_line + "'";
EXPECT_FALSE(true) << msg;
}
std::string resp = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello";
::send(cli,
#ifdef _WIN32
static_cast<const char *>(resp.c_str()),
static_cast<int>(resp.size()),
#else
resp.c_str(), resp.size(),
#endif
0);
buf_all.erase(0, pos + 4);
}
}
detail::close_socket(cli);
detail::close_socket(srv);
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
auto cli = Client("127.0.0.1", PORT + 1);
auto headers = Headers{
{"A", "B\r\n\r\nGET /pwned HTTP/1.1\r\nHost: 127.0.0.1:1234\r\n\r\n"},
{"Connection", "keep-alive"}};
auto res = cli.Get("/hi", headers);
EXPECT_FALSE(res);
EXPECT_EQ(Error::InvalidHeaders, res.error());
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);
Request request;
request.path = "/users/all";
ASSERT_TRUE(matcher.match(request));
std::unordered_map<std::string, std::string> expected_params = {};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(PathParamsTest, StaticMismatch) {
const auto pattern = "/users/all";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users/1";
ASSERT_FALSE(matcher.match(request));
}
TEST(PathParamsTest, SingleParamInTheMiddle) {
const auto pattern = "/users/:id/subscriptions";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users/42/subscriptions";
ASSERT_TRUE(matcher.match(request));
std::unordered_map<std::string, std::string> expected_params = {{"id", "42"}};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(PathParamsTest, SingleParamInTheEnd) {
const auto pattern = "/users/:id";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users/24";
ASSERT_TRUE(matcher.match(request));
std::unordered_map<std::string, std::string> expected_params = {{"id", "24"}};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(PathParamsTest, SingleParamInTheEndTrailingSlash) {
const auto pattern = "/users/:id/";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users/42/";
ASSERT_TRUE(matcher.match(request));
std::unordered_map<std::string, std::string> expected_params = {{"id", "42"}};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(PathParamsTest, EmptyParam) {
const auto pattern = "/users/:id/";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users//";
ASSERT_TRUE(matcher.match(request));
std::unordered_map<std::string, std::string> expected_params = {{"id", ""}};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(PathParamsTest, FragmentMismatch) {
const auto pattern = "/users/:id/";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/admins/24/";
ASSERT_FALSE(matcher.match(request));
}
TEST(PathParamsTest, ExtraFragments) {
const auto pattern = "/users/:id";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users/42/subscriptions";
ASSERT_FALSE(matcher.match(request));
}
TEST(PathParamsTest, MissingTrailingParam) {
const auto pattern = "/users/:id";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users";
ASSERT_FALSE(matcher.match(request));
}
TEST(PathParamsTest, MissingParamInTheMiddle) {
const auto pattern = "/users/:id/subscriptions";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users/subscriptions";
ASSERT_FALSE(matcher.match(request));
}
TEST(PathParamsTest, MultipleParams) {
const auto pattern = "/users/:userid/subscriptions/:subid";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/users/42/subscriptions/2";
ASSERT_TRUE(matcher.match(request));
std::unordered_map<std::string, std::string> expected_params = {
{"userid", "42"}, {"subid", "2"}};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(PathParamsTest, SequenceOfParams) {
const auto pattern = "/values/:x/:y/:z";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/values/1/2/3";
ASSERT_TRUE(matcher.match(request));
std::unordered_map<std::string, std::string> expected_params = {
{"x", "1"}, {"y", "2"}, {"z", "3"}};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(PathParamsTest, SemicolonInTheMiddleIsNotAParam) {
const auto pattern = "/prefix:suffix";
detail::PathParamsMatcher matcher(pattern);
Request request;
request.path = "/prefix:suffix";
ASSERT_TRUE(matcher.match(request));
const std::unordered_map<std::string, std::string> expected_params = {};
EXPECT_EQ(request.path_params, expected_params);
}
TEST(RegexMatcherTest, OverlongPathIsRejectedBeforeRegexMatch) {
// std::regex_match's backtracking (most acute on libstdc++) can exhaust the
// calling thread's stack on a long enough path for a quantified pattern;
// RegexMatcher must refuse to run regex matching on paths beyond
// CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH instead of invoking it.
detail::RegexMatcher matcher("/files/(.*)");
Request within_limit;
within_limit.path = "/files/" + std::string(10, 'A');
EXPECT_TRUE(matcher.match(within_limit));
Request over_limit;
over_limit.path =
"/files/" + std::string(CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH, 'A');
EXPECT_FALSE(matcher.match(over_limit));
}
TEST(RegexMatcherTest, ServerSurvivesOverlongPathOnRegexRoute) {
Server svr;
svr.Get(R"(/files/(.*))", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
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();
// Comfortably past CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH, well within the
// request URI limit; this used to be able to crash the process.
std::string path =
"/files/" + std::string(CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH * 4, 'A');
std::string req = "GET " + path +
" HTTP/1.1\r\n"
"Host: " +
std::string(HOST) + ":" + std::to_string(port) +
"\r\n"
"Connection: close\r\n"
"\r\n";
std::string response;
ASSERT_TRUE(send_request(5, req, &response, port));
EXPECT_NE(std::string::npos, response.find("404"));
}
TEST(RouteMatcherTest, LiteralPatternsAndRegexPatterns) {
Server svr;
auto handler = [](const Request & /*req*/, Response &res) {
res.set_content("hit", "text/plain");
};
// No regex metacharacter, so this one is compared literally
svr.Get("/literal/route", handler);
// '.' is a regex metacharacter, so this one must keep regex semantics
svr.Get("/a.c", handler);
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
struct {
const char *path;
int status;
} cases[] = {
{"/literal/route", StatusCode::OK_200},
{"/literal/routes", StatusCode::NotFound_404},
{"/literal/rout", StatusCode::NotFound_404},
{"/abc", StatusCode::OK_200},
{"/a.c", StatusCode::OK_200},
};
for (const auto &x : cases) {
auto res = cli.Get(x.path);
ASSERT_TRUE(res) << x.path;
EXPECT_EQ(x.status, res->status) << x.path;
}
}
TEST(RouteMatcherTest, LongLiteralPatternIsNotSubjectToTheRegexPathLimit) {
// CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH exists to keep std::regex_match
// from exhausting the stack, so it must only constrain routes that actually
// run a regular expression. A literal route is matched by comparison and
// stays usable at any length.
Server svr;
const std::string pattern =
"/files/" + std::string(CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH * 2, 'a');
svr.Get(pattern, [](const Request & /*req*/, Response &res) {
res.set_content("hit", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Get(pattern);
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
TEST(ParseUrlTest, VariousPatterns) {
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("http://example.com:8080/path?q=1#frag", uc));
EXPECT_EQ("http", uc.scheme);
EXPECT_EQ("example.com", uc.host);
EXPECT_EQ("8080", uc.port);
EXPECT_EQ("/path", uc.path);
EXPECT_EQ("?q=1", uc.query);
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("https://example.com/path", uc));
EXPECT_EQ("https", uc.scheme);
EXPECT_EQ("example.com", uc.host);
EXPECT_TRUE(uc.port.empty());
EXPECT_EQ("/path", uc.path);
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("http://[::1]:8080/path", uc));
EXPECT_EQ("::1", uc.host);
EXPECT_EQ("8080", uc.port);
EXPECT_EQ("/path", uc.path);
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("http://[::1/path", uc));
}
{
// Bytes after the IPv6 literal must be a delimiter, not folded into
// the path while the connection still targets the bracketed host.
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("http://[::1]evil.com/", uc));
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("http://[::1]evil", uc));
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("http://[::1]/path", uc));
EXPECT_EQ("::1", uc.host);
EXPECT_TRUE(uc.port.empty());
EXPECT_EQ("/path", uc.path);
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("//example.com/path?q=1", uc));
EXPECT_TRUE(uc.scheme.empty());
EXPECT_EQ("example.com", uc.host);
EXPECT_EQ("/path", uc.path);
EXPECT_EQ("?q=1", uc.query);
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("/path?q=1", uc));
EXPECT_TRUE(uc.host.empty());
EXPECT_EQ("/path", uc.path);
EXPECT_EQ("?q=1", uc.query);
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("example.com:8080", uc));
EXPECT_EQ("example.com", uc.host);
EXPECT_EQ("8080", uc.port);
}
{
// Unix socket path — must not be parsed as host
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("./httplib-server.sock", uc));
EXPECT_TRUE(uc.host.empty());
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("", uc));
EXPECT_TRUE(uc.host.empty());
EXPECT_TRUE(uc.path.empty());
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("HTTP://example.com/path", uc));
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("h2://example.com/path", uc));
}
{
// Accepted by parse_url; callers restrict to http/https
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("ftp://example.com/", uc));
EXPECT_EQ("ftp", uc.scheme);
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("http://[::1<script>]/path", uc));
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("http://[]/path", uc));
}
}
TEST(ParseUrlTest, FragmentHandling) {
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("http://example.com/path#frag", uc));
EXPECT_EQ("/path", uc.path);
EXPECT_TRUE(uc.query.empty());
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("#frag", uc));
EXPECT_TRUE(uc.path.empty());
EXPECT_TRUE(uc.query.empty());
}
}
TEST(ParseUrlTest, UserinfoHandling) {
// Userinfo with @ but no colon — host includes @
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("http://user@host.com/path", uc));
EXPECT_EQ("user@host.com", uc.host);
EXPECT_EQ("/path", uc.path);
}
TEST(ParseUrlTest, IPv6EdgeCases) {
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("[::1]:8080", uc));
EXPECT_TRUE(uc.scheme.empty());
EXPECT_EQ("::1", uc.host);
EXPECT_EQ("8080", uc.port);
}
{
// Zone ID '%25' is not in [a-fA-F0-9:]
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("http://[fe80::1%25eth0]:443/path", uc));
}
}
TEST(ParseUrlTest, SchemeEdgeCases) {
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("://evil.com/path", uc));
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("ht-tp://evil.com/path", uc));
}
{
detail::UrlComponents uc;
ASSERT_FALSE(detail::parse_url("h.t://evil.com/path", uc));
}
}
TEST(ParseUrlTest, PortEdgeCases) {
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("http://example.com:/path", uc));
EXPECT_TRUE(uc.port.empty());
EXPECT_EQ("/path", uc.path);
}
{
// parse_url accepts any port string; validation is done by parse_port
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("http://example.com:abc/path", uc));
EXPECT_EQ("abc", uc.port);
}
}
TEST(ParseUrlTest, WebSocketPatterns) {
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("ws://echo.example.com:8080/ws", uc));
EXPECT_EQ("ws", uc.scheme);
EXPECT_EQ("echo.example.com", uc.host);
EXPECT_EQ("8080", uc.port);
EXPECT_EQ("/ws", uc.path);
}
{
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("wss://echo.example.com/ws", uc));
EXPECT_EQ("wss", uc.scheme);
EXPECT_EQ("echo.example.com", uc.host);
EXPECT_TRUE(uc.port.empty());
EXPECT_EQ("/ws", uc.path);
}
}
TEST(ParseUrlTest, QueryOnly) {
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("?q=1&r=2", uc));
EXPECT_TRUE(uc.host.empty());
EXPECT_TRUE(uc.path.empty());
EXPECT_EQ("?q=1&r=2", uc.query);
}
TEST(ParseUrlTest, SchemeRelativeWithPort) {
detail::UrlComponents uc;
ASSERT_TRUE(detail::parse_url("//example.com:443/path", uc));
EXPECT_TRUE(uc.scheme.empty());
EXPECT_EQ("example.com", uc.host);
EXPECT_EQ("443", uc.port);
EXPECT_EQ("/path", uc.path);
}
TEST(UniversalClientImplTest, Ipv6LiteralAddress) {
// If ipv6 regex working, regex match codepath is taken.
// else port will default to 80 in Client impl
int clientImplMagicPort = 80;
int port = 4321;
// above ports must be different to avoid false negative
EXPECT_NE(clientImplMagicPort, port);
std::string ipV6TestURL = "http://[ff06::c3]";
Client cli(ipV6TestURL + ":" + std::to_string(port), CLIENT_CERT_FILE,
CLIENT_PRIVATE_KEY_FILE);
EXPECT_EQ(cli.port(), port);
}
TEST(FileSystemTest, FileAndDirExistenceCheck) {
auto file_path = "./www/dir/index.html";
auto dir_path = "./www/dir";
detail::FileStat stat_file(file_path);
EXPECT_TRUE(stat_file.is_file());
EXPECT_FALSE(stat_file.is_dir());
detail::FileStat stat_dir(dir_path);
EXPECT_FALSE(stat_dir.is_file());
EXPECT_TRUE(stat_dir.is_dir());
}
TEST(MakeHostAndPortStringTest, VariousPatterns) {
// IPv4 with default HTTP port (80)
EXPECT_EQ("example.com",
detail::make_host_and_port_string("example.com", 80, false));
// IPv4 with default HTTPS port (443)
EXPECT_EQ("example.com",
detail::make_host_and_port_string("example.com", 443, true));
// IPv4 with non-default HTTP port
EXPECT_EQ("example.com:8080",
detail::make_host_and_port_string("example.com", 8080, false));
// IPv4 with non-default HTTPS port
EXPECT_EQ("example.com:8443",
detail::make_host_and_port_string("example.com", 8443, true));
// IPv6 with default HTTP port (80)
EXPECT_EQ("[::1]", detail::make_host_and_port_string("::1", 80, false));
// IPv6 with default HTTPS port (443)
EXPECT_EQ("[::1]", detail::make_host_and_port_string("::1", 443, true));
// IPv6 with non-default HTTP port
EXPECT_EQ("[::1]:8080",
detail::make_host_and_port_string("::1", 8080, false));
// IPv6 with non-default HTTPS port
EXPECT_EQ("[::1]:8443", detail::make_host_and_port_string("::1", 8443, true));
// IPv6 full address with default port
EXPECT_EQ("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]",
detail::make_host_and_port_string(
"2001:0db8:85a3:0000:0000:8a2e:0370:7334", 443, true));
// IPv6 full address with non-default port
EXPECT_EQ("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:9000",
detail::make_host_and_port_string(
"2001:0db8:85a3:0000:0000:8a2e:0370:7334", 9000, false));
// IPv6 localhost with non-default port
EXPECT_EQ("[::1]:3000",
detail::make_host_and_port_string("::1", 3000, false));
// IPv6 with zone ID (link-local address) with default port
EXPECT_EQ("[fe80::1%eth0]",
detail::make_host_and_port_string("fe80::1%eth0", 80, false));
// IPv6 with zone ID (link-local address) with non-default port
EXPECT_EQ("[fe80::1%eth0]:8080",
detail::make_host_and_port_string("fe80::1%eth0", 8080, false));
// Edge case: Port 443 with is_ssl=false (should add port)
EXPECT_EQ("example.com:443",
detail::make_host_and_port_string("example.com", 443, false));
// Edge case: Port 80 with is_ssl=true (should add port)
EXPECT_EQ("example.com:80",
detail::make_host_and_port_string("example.com", 80, true));
// IPv6 edge case: Port 443 with is_ssl=false (should add port)
EXPECT_EQ("[::1]:443", detail::make_host_and_port_string("::1", 443, false));
// IPv6 edge case: Port 80 with is_ssl=true (should add port)
EXPECT_EQ("[::1]:80", detail::make_host_and_port_string("::1", 80, true));
// Security fix: Already bracketed IPv6 should not get double brackets
EXPECT_EQ("[::1]", detail::make_host_and_port_string("[::1]", 80, false));
EXPECT_EQ("[::1]", detail::make_host_and_port_string("[::1]", 443, true));
EXPECT_EQ("[::1]:8080",
detail::make_host_and_port_string("[::1]", 8080, false));
EXPECT_EQ("[2001:db8::1]:8080",
detail::make_host_and_port_string("[2001:db8::1]", 8080, false));
EXPECT_EQ("[fe80::1%eth0]",
detail::make_host_and_port_string("[fe80::1%eth0]", 80, false));
EXPECT_EQ("[fe80::1%eth0]:8080",
detail::make_host_and_port_string("[fe80::1%eth0]", 8080, false));
// Edge case: Empty host (should return as-is)
EXPECT_EQ("", detail::make_host_and_port_string("", 80, false));
// Edge case: Colon in hostname (non-IPv6) - will be treated as IPv6
// This is a known limitation but shouldn't crash
EXPECT_EQ("[host:name]",
detail::make_host_and_port_string("host:name", 80, false));
// Port number edge cases (no validation, but should not crash)
EXPECT_EQ("example.com:0",
detail::make_host_and_port_string("example.com", 0, false));
EXPECT_EQ("example.com:-1",
detail::make_host_and_port_string("example.com", -1, false));
EXPECT_EQ("example.com:65535",
detail::make_host_and_port_string("example.com", 65535, false));
EXPECT_EQ("example.com:65536",
detail::make_host_and_port_string("example.com", 65536, false));
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(SSLClientHostHeaderTest, Issue2301_Online) {
httplib::SSLClient cli("roblox.com", 443);
cli.set_follow_location(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
}
#endif
TEST(DirtyDataRequestTest, HeadFieldValueContains_CR_LF_NUL) {
Server svr;
svr.Get("/test", [&](const Request & /*req*/, Response &res) {
EXPECT_EQ(res.status, 400);
});
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();
Client cli(HOST, PORT);
cli.Get("/test", Headers{{"Test", "_\n\r_\n\r_"}});
}
TEST(InvalidHeaderCharsTest, is_field_name) {
EXPECT_TRUE(detail::fields::is_field_name("exampleToken"));
EXPECT_TRUE(detail::fields::is_field_name("token123"));
EXPECT_TRUE(detail::fields::is_field_name("!#$%&'*+-.^_`|~"));
EXPECT_FALSE(detail::fields::is_field_name("example token"));
EXPECT_FALSE(detail::fields::is_field_name(" example_token"));
EXPECT_FALSE(detail::fields::is_field_name("example_token "));
EXPECT_FALSE(detail::fields::is_field_name("token@123"));
EXPECT_FALSE(detail::fields::is_field_name(""));
EXPECT_FALSE(detail::fields::is_field_name("example\rtoken"));
EXPECT_FALSE(detail::fields::is_field_name("example\ntoken"));
EXPECT_FALSE(detail::fields::is_field_name(std::string("\0", 1)));
EXPECT_FALSE(detail::fields::is_field_name("example\ttoken"));
}
TEST(InvalidHeaderCharsTest, is_field_value) {
EXPECT_TRUE(detail::fields::is_field_value("exampleToken"));
EXPECT_TRUE(detail::fields::is_field_value("token123"));
EXPECT_TRUE(detail::fields::is_field_value("!#$%&'*+-.^_`|~"));
EXPECT_TRUE(detail::fields::is_field_value("example token"));
EXPECT_FALSE(detail::fields::is_field_value(" example_token"));
EXPECT_FALSE(detail::fields::is_field_value("example_token "));
EXPECT_TRUE(detail::fields::is_field_value("token@123"));
EXPECT_TRUE(detail::fields::is_field_value(""));
EXPECT_FALSE(detail::fields::is_field_value("example\rtoken"));
EXPECT_FALSE(detail::fields::is_field_value("example\ntoken"));
EXPECT_FALSE(detail::fields::is_field_value(std::string("\0", 1)));
EXPECT_TRUE(detail::fields::is_field_value("example\ttoken"));
EXPECT_TRUE(detail::fields::is_field_value("0"));
}
TEST(InvalidHeaderCharsTest, OnServer) {
Server svr;
svr.Get("/test_name", [&](const Request &req, Response &res) {
std::string header = "Not Set";
if (req.has_param("header")) { header = req.get_param_value("header"); }
res.set_header(header, "value");
res.set_content("Page Content Page Content", "text/plain");
});
svr.Get("/test_value", [&](const Request &req, Response &res) {
std::string header = "Not Set";
if (req.has_param("header")) { header = req.get_param_value("header"); }
res.set_header("X-Test", header);
res.set_content("Page Content Page Content", "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();
Client cli(HOST, PORT);
{
auto res = cli.Get(
R"(/test_name?header=Value%00%0d%0aHEADER_KEY%3aHEADER_VALUE%0d%0a%0d%0aBODY_BODY_BODY)");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Page Content Page Content", res->body);
EXPECT_FALSE(res->has_header("HEADER_KEY"));
}
{
auto res = cli.Get(
R"(/test_value?header=Value%00%0d%0aHEADER_KEY%3aHEADER_VALUE%0d%0a%0d%0aBODY_BODY_BODY)");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("Page Content Page Content", res->body);
EXPECT_FALSE(res->has_header("HEADER_KEY"));
}
}
TEST(InvalidHeaderValueTest, InvalidContentLength) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &, Response &) { handled = true; });
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_FALSE(handled);
});
svr.wait_until_ready();
auto req = "POST /test HTTP/1.1\r\n"
"Content-Length: x\r\n"
"\r\n";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
ASSERT_EQ("HTTP/1.1 400 Bad Request",
response.substr(0, response.find("\r\n")));
}
// RFC 9110 Section 10.1.1: Expect is a comma-separated list, its value is
// case-insensitive, and a server that receives a 100-continue expectation in
// an HTTP/1.0 request MUST ignore it.
static void probe_expect(int port, const std::string &req, bool *got_100) {
std::string response;
ASSERT_TRUE(send_request(1, req, &response, port));
*got_100 = response.find("100 Continue") != std::string::npos;
}
class ExpectTokenTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.Post("/p", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
port_ = svr_.bind_to_any_port(HOST);
thread_ = thread([&]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) { thread_.join(); }
}
std::string request(const char *version, const char *expect_value) const {
return std::string("POST /p ") + version +
"\r\n"
"Host: localhost\r\n"
"Content-Length: 2\r\n"
"Connection: close\r\n"
"Expect: " +
expect_value +
"\r\n"
"\r\n"
"hi";
}
Server svr_;
int port_ = 0;
thread thread_;
};
TEST_F(ExpectTokenTest, Http11GetsTheInterimResponse) {
bool got_100 = false;
probe_expect(port_, request("HTTP/1.1", "100-continue"), &got_100);
EXPECT_TRUE(got_100);
}
TEST_F(ExpectTokenTest, Http10ExpectationIsIgnored) {
bool got_100 = true;
probe_expect(port_, request("HTTP/1.0", "100-continue"), &got_100);
EXPECT_FALSE(got_100);
}
TEST_F(ExpectTokenTest, ExpectationIsCaseInsensitive) {
bool got_100 = false;
probe_expect(port_, request("HTTP/1.1", "100-Continue"), &got_100);
EXPECT_TRUE(got_100);
}
TEST_F(ExpectTokenTest, ExpectationAmongOthersIsRecognized) {
bool got_100 = false;
probe_expect(port_, request("HTTP/1.1", "100-continue, foo"), &got_100);
EXPECT_TRUE(got_100);
}
// `100 Continue` is sent only when the server starts reading the body, so a
// request rejected before that never invites the client to send it. The
// requests below carry the expectation but withhold the body, as a client
// waiting for `100 Continue` would.
// A POST that expects `100 Continue` and withholds its two-byte body.
static std::string expect_headers_only(const std::string &path) {
return "POST " + path +
" HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: 2\r\n"
"Expect: 100-continue\r\n"
"\r\n";
}
class ExpectLazyContinueTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.set_pre_routing_handler([](const Request &req, Response &res) {
if (req.path == "/pre-routing") {
res.status = StatusCode::Unauthorized_401;
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
svr_.set_pre_request_handler([](const Request &req, Response &res) {
if (req.matched_route == "/pre-request") {
res.status = StatusCode::Forbidden_403;
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
svr_.Post("/pre-routing", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
svr_.Post("/pre-request", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
svr_.Post("/reader-used", [](const Request &, Response &res,
const ContentReader &content_reader) {
std::string body;
content_reader([&](const char *data, size_t len) {
body.append(data, len);
return true;
});
res.set_content(body, "text/plain");
});
svr_.Post("/reader-unused",
[](const Request &, Response &res, const ContentReader &) {
res.set_content("ignored", "text/plain");
});
port_ = svr_.bind_to_any_port(HOST);
thread_ = thread([&]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) { thread_.join(); }
}
// Sends `req` and reads until the server closes the connection. Returns
// false if the read had to wait for the client-side timeout instead.
bool send_until_closed(const std::string &req, std::string *resp) const {
auto start = std::chrono::steady_clock::now();
if (!send_request(3, req, resp, port_)) { return false; }
auto elapsed = std::chrono::steady_clock::now() - start;
return elapsed < std::chrono::seconds(2);
}
// The final response comes without `100 Continue`, and the server closes
// the connection since the body may or may not follow.
void expect_final_without_interim(const std::string &path,
const char *status_line) const {
std::string resp;
ASSERT_TRUE(send_until_closed(expect_headers_only(path), &resp));
EXPECT_EQ(std::string::npos, resp.find("100 Continue"));
EXPECT_EQ(0u, resp.find(status_line));
EXPECT_NE(std::string::npos, resp.find("Connection: close"));
}
Server svr_;
int port_ = 0;
thread thread_;
};
TEST_F(ExpectLazyContinueTest, PreRoutingRejectsWithoutInterimResponse) {
expect_final_without_interim("/pre-routing", "HTTP/1.1 401");
}
TEST_F(ExpectLazyContinueTest, PreRequestRejectsWithoutInterimResponse) {
expect_final_without_interim("/pre-request", "HTTP/1.1 403");
}
TEST_F(ExpectLazyContinueTest, UnknownRouteRejectsWithoutInterimResponse) {
expect_final_without_interim("/nowhere", "HTTP/1.1 404");
}
TEST_F(ExpectLazyContinueTest, UnreadContentReaderClosesWithoutInterim) {
expect_final_without_interim("/reader-unused", "HTTP/1.1 200");
}
TEST_F(ExpectLazyContinueTest, ContentReaderGetsInterimResponse) {
// The body follows once `100 Continue` has had time to arrive.
auto req = expect_headers_only("/reader-used");
req.insert(req.size() - 2, "Connection: close\r\n");
std::string resp;
ASSERT_TRUE(send_request_in_parts(3, {req, "hi"}, &resp, port_));
EXPECT_EQ(0u, resp.find("HTTP/1.1 100 Continue"));
EXPECT_NE(std::string::npos, resp.find("HTTP/1.1 200"));
EXPECT_NE(std::string::npos, resp.find("hi"));
}
TEST_F(ExpectLazyContinueTest, ClientWithholdsBodyWhenRejected) {
// Large enough for the client to add `Expect: 100-continue` itself.
const size_t length = CPPHTTPLIB_EXPECT_100_THRESHOLD * 4;
std::atomic<bool> body_sent{false};
Client cli(HOST, port_);
auto res = cli.Post(
"/pre-request", length,
[&](size_t /*offset*/, size_t len, DataSink &sink) {
body_sent = true;
std::string chunk(len, 'x');
sink.write(chunk.data(), chunk.size());
return true;
},
"application/octet-stream");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::Forbidden_403, res->status);
EXPECT_FALSE(body_sent);
}
TEST(Expect100ContinueHandlerTest, ExpectationFailedIsFinalResponse) {
Server svr;
svr.set_expect_100_continue_handler([](const Request &, Response &) {
return StatusCode::ExpectationFailed_417;
});
svr.Post("/p", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
std::string resp;
ASSERT_TRUE(send_request(3, expect_headers_only("/p"), &resp, port));
EXPECT_EQ(0u, resp.find("HTTP/1.1 417"));
EXPECT_NE(std::string::npos, resp.find("Connection: close"));
// Exactly one response: the route handler must not run after the 417.
EXPECT_EQ(std::string::npos, resp.find("HTTP/1.1", 1));
}
#ifndef _WIN32
TEST(Expect100ContinueTest, ServerClosesConnection) {
static constexpr char reject[] = "Unauthorized";
static constexpr char accept[] = "Upload accepted";
constexpr size_t total_size = 10 * 1024 * 1024 * 1024ULL;
Server svr;
svr.set_expect_100_continue_handler(
[](const Request & /*req*/, Response &res) {
res.status = StatusCode::Unauthorized_401;
res.set_content(reject, "text/plain");
return res.status;
});
svr.Post("/", [&](const Request & /*req*/, Response &res) {
res.set_content(accept, "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 auto curl = std::unique_ptr<CURL, decltype(&curl_easy_cleanup)>{
curl_easy_init(), &curl_easy_cleanup};
ASSERT_NE(curl, nullptr);
curl_easy_setopt(curl.get(), CURLOPT_URL, HOST);
curl_easy_setopt(curl.get(), CURLOPT_PORT, PORT);
curl_easy_setopt(curl.get(), CURLOPT_POST, 1L);
auto list = std::unique_ptr<curl_slist, decltype(&curl_slist_free_all)>{
curl_slist_append(nullptr, "Content-Type: application/octet-stream"),
&curl_slist_free_all};
ASSERT_NE(list, nullptr);
curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, list.get());
struct read_data {
size_t read_size;
size_t total_size;
} data = {0, total_size};
using read_callback_t =
size_t (*)(char *ptr, size_t size, size_t nmemb, void *userdata);
read_callback_t read_callback = [](char *ptr, size_t size, size_t nmemb,
void *userdata) -> size_t {
read_data *d = (read_data *)userdata;
if (!userdata || d->read_size >= d->total_size) { return 0; }
std::fill_n(ptr, size * nmemb, 'A');
d->read_size += size * nmemb;
return size * nmemb;
};
curl_easy_setopt(curl.get(), CURLOPT_READDATA, data);
curl_easy_setopt(curl.get(), CURLOPT_READFUNCTION, read_callback);
std::vector<char> buffer;
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &buffer);
using write_callback_t =
size_t (*)(char *ptr, size_t size, size_t nmemb, void *userdata);
write_callback_t write_callback = [](char *ptr, size_t size, size_t nmemb,
void *userdata) -> size_t {
std::vector<char> *buf = (std::vector<char> *)userdata;
buf->reserve(buf->size() + size * nmemb + 1);
buf->insert(buf->end(), (char *)ptr, (char *)ptr + size * nmemb);
return size * nmemb;
};
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, write_callback);
{
const auto res = curl_easy_perform(curl.get());
ASSERT_EQ(res, CURLE_OK);
}
{
auto response_code = long{};
const auto res =
curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response_code);
ASSERT_EQ(res, CURLE_OK);
ASSERT_EQ(response_code, StatusCode::Unauthorized_401);
}
{
auto dl = curl_off_t{};
const auto res =
curl_easy_getinfo(curl.get(), CURLINFO_SIZE_DOWNLOAD_T, &dl);
ASSERT_EQ(res, CURLE_OK);
ASSERT_EQ(dl, (curl_off_t)sizeof reject - 1);
}
{
buffer.push_back('\0');
ASSERT_STRCASEEQ(buffer.data(), reject);
}
}
}
#endif
#if defined(CPPHTTPLIB_OPENSSL_SUPPORT) && !defined(_WIN32)
// Regression test for #2458.
//
// A large request body (>= CPPHTTPLIB_EXPECT_100_THRESHOLD) makes the client
// auto-add `Expect: 100-continue`. Over TLS, the server's TLS 1.3 session
// ticket can make the client socket spuriously readable during the
// 100-continue wait. If the readiness is mistaken for an incoming response,
// the client withholds the body and then blocks reading a response that never
// comes, failing with `Failed to read connection`.
//
// A correct client (like curl) sends the body once no `100 Continue` arrives
// within the timeout. This raw OpenSSL server deliberately never sends
// `100 Continue`; the client must still deliver the body and receive 200.
TEST(Expect100ContinueTest, TLSServerOmits100Continue) {
signal(SIGPIPE, SIG_IGN);
const auto port = PORT + 4;
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_NE(srv, INVALID_SOCKET);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
::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::atomic<size_t> server_body_bytes{0};
auto server_thread = std::thread([&] {
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli == INVALID_SOCKET) { return; }
// Bound the lifetime of the server side so a buggy client (which never
// sends the body) cannot make this thread block forever on join.
detail::set_socket_opt_time(cli, SOL_SOCKET, SO_RCVTIMEO, 4, 0);
SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION);
SSL_CTX_use_certificate_file(ctx, SERVER_CERT_FILE, SSL_FILETYPE_PEM);
SSL_CTX_use_PrivateKey_file(ctx, SERVER_PRIVATE_KEY_FILE, SSL_FILETYPE_PEM);
SSL *ssl = SSL_new(ctx);
SSL_set_fd(ssl, static_cast<int>(cli));
if (SSL_accept(ssl) > 0) {
// Read the request headers. Reading here also flushes the TLS 1.3
// session tickets to the client. Deliberately do NOT send
// `100 Continue`.
std::string buf;
char tmp[1024];
while (buf.find("\r\n\r\n") == std::string::npos) {
auto n = SSL_read(ssl, tmp, sizeof(tmp));
if (n <= 0) { break; }
buf.append(tmp, static_cast<size_t>(n));
}
// A correct client sends the body now; count what arrives.
auto pos = buf.find("\r\n\r\n");
size_t body = (pos == std::string::npos) ? 0 : buf.size() - (pos + 4);
while (body < 4096) {
auto n = SSL_read(ssl, tmp, sizeof(tmp));
if (n <= 0) { break; }
body += static_cast<size_t>(n);
}
server_body_bytes = body;
std::string resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok";
SSL_write(ssl, resp.data(), static_cast<int>(resp.size()));
SSL_shutdown(ssl);
}
SSL_free(ssl);
detail::close_socket(cli);
SSL_CTX_free(ctx);
});
auto se = detail::scope_exit([&] {
server_thread.join();
detail::close_socket(srv);
});
SSLClient cli("127.0.0.1", port);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(5, 0);
cli.set_read_timeout(3, 0); // short, so a hang surfaces quickly
// Body larger than CPPHTTPLIB_EXPECT_100_THRESHOLD (1024) -> auto Expect.
std::string body(4096, 'A');
auto res = cli.Put("/api/test", body, "application/json");
ASSERT_TRUE(res) << "request failed: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(body.size(), server_body_bytes.load());
}
#endif
template <typename S, typename C>
inline void max_timeout_test(S &svr, C &cli, time_t timeout, time_t threshold) {
svr.Get("/stream", [&](const Request &, Response &res) {
auto data = new std::string("01234567890123456789");
res.set_content_provider(
data->size(), "text/plain",
[&, data](size_t offset, size_t length, DataSink &sink) {
const size_t DATA_CHUNK_SIZE = 4;
const auto &d = *data;
std::this_thread::sleep_for(std::chrono::seconds(1));
sink.write(&d[offset], std::min(length, DATA_CHUNK_SIZE));
return true;
},
[data](bool success) {
EXPECT_FALSE(success);
delete data;
});
});
svr.Get("/stream_without_length", [&](const Request &, Response &res) {
auto i = new size_t(0);
res.set_content_provider(
"text/plain",
[i](size_t, DataSink &sink) {
if (*i < 5) {
std::this_thread::sleep_for(std::chrono::seconds(1));
sink.write("abcd", 4);
(*i)++;
} else {
sink.done();
}
return true;
},
[i](bool success) {
EXPECT_FALSE(success);
delete i;
});
});
svr.Get("/chunked", [&](const Request &, Response &res) {
auto i = new size_t(0);
res.set_chunked_content_provider(
"text/plain",
[i](size_t, DataSink &sink) {
if (*i < 5) {
std::this_thread::sleep_for(std::chrono::seconds(1));
sink.os << "abcd";
(*i)++;
} else {
sink.done();
}
return true;
},
[i](bool success) {
EXPECT_FALSE(success);
delete i;
});
});
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
cli.set_max_timeout(std::chrono::milliseconds(timeout));
{
auto start = std::chrono::steady_clock::now();
auto res = cli.Get("/stream");
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
ASSERT_FALSE(res);
EXPECT_EQ(Error::Read, res.error());
EXPECT_TRUE(timeout <= elapsed && elapsed < timeout + threshold)
<< "Timeout exceeded by " << (elapsed - timeout) << "ms";
}
{
auto start = std::chrono::steady_clock::now();
auto res = cli.Get("/stream_without_length");
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
ASSERT_FALSE(res);
EXPECT_EQ(Error::Read, res.error());
EXPECT_TRUE(timeout <= elapsed && elapsed < timeout + threshold)
<< "Timeout exceeded by " << (elapsed - timeout) << "ms";
}
{
auto start = std::chrono::steady_clock::now();
auto res = cli.Get("/chunked", [&](const char *data, size_t data_length) {
EXPECT_EQ("abcd", string(data, data_length));
return true;
});
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
ASSERT_FALSE(res);
EXPECT_EQ(Error::Read, res.error());
EXPECT_TRUE(timeout <= elapsed && elapsed < timeout + threshold)
<< "Timeout exceeded by " << (elapsed - timeout) << "ms";
}
}
TEST(MaxTimeoutTest, ContentStream) {
time_t timeout = 2000;
time_t threshold = 200;
Server svr;
Client cli("localhost", PORT);
max_timeout_test(svr, cli, timeout, threshold);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(MaxTimeoutTest, ContentStreamSSL) {
time_t timeout = 2000;
time_t threshold = 1200; // SSL_shutdown is slow on some operating systems.
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
SSLClient cli("localhost", PORT);
cli.enable_server_certificate_verification(false);
max_timeout_test(svr, cli, timeout, threshold);
}
#endif
class EventDispatcher {
public:
EventDispatcher() {}
bool wait_event(DataSink *sink) {
unique_lock<mutex> lk(m_);
int id = id_;
// Wait with timeout to prevent hanging if client disconnects
if (!cv_.wait_for(lk, std::chrono::seconds(5),
[&] { return cid_ == id; })) {
return false; // Timeout occurred
}
sink->write(message_.data(), message_.size());
return true;
}
void send_event(const string &message) {
lock_guard<mutex> lk(m_);
cid_ = id_++;
message_ = message;
cv_.notify_all();
}
private:
mutex m_;
condition_variable cv_;
atomic_int id_{0};
atomic_int cid_{-1};
string message_;
};
TEST(ClientInThreadTest, Issue2068) {
EventDispatcher ed;
Server svr;
svr.Get("/event1", [&](const Request & /*req*/, Response &res) {
res.set_chunked_content_provider("text/event-stream",
[&](size_t /*offset*/, DataSink &sink) {
return ed.wait_event(&sink);
});
});
auto listen_thread = std::thread([&svr]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
thread event_thread([&] {
int id = 0;
while (svr.is_running()) {
this_thread::sleep_for(chrono::milliseconds(500));
std::stringstream ss;
ss << "data: " << id << "\n\n";
ed.send_event(ss.str());
id++;
}
});
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
event_thread.join();
ASSERT_FALSE(svr.is_running());
});
{
auto client = detail::make_unique<Client>(HOST, PORT);
client->set_read_timeout(std::chrono::minutes(10));
std::atomic<bool> stop{false};
std::thread t([&] {
client->Get("/event1",
[&](const char *, size_t) -> bool { return !stop; });
});
std::this_thread::sleep_for(std::chrono::seconds(2));
stop = true;
client->stop();
t.join();
// Reset client after thread has finished
client.reset();
}
}
TEST(RequestSmugglingTest, DuplicateContentLengthDifferentValues) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &, Response &) { handled = true; });
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_FALSE(handled);
});
svr.wait_until_ready();
// Two Content-Length headers with different values — must be rejected
auto req = "POST /test HTTP/1.1\r\n"
"Content-Length: 5\r\n"
"Content-Length: 10\r\n"
"\r\n"
"hello";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
ASSERT_EQ("HTTP/1.1 400 Bad Request",
response.substr(0, response.find("\r\n")));
}
TEST(RequestSmugglingTest, DuplicateContentLengthSameValues) {
auto handled = false;
Server svr;
svr.Post("/test", [&](const Request &, Response &res) {
handled = true;
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
ASSERT_TRUE(handled);
});
svr.wait_until_ready();
// Two Content-Length headers with same value — should be accepted (RFC 9110)
auto req = "POST /test HTTP/1.1\r\n"
"Content-Length: 5\r\n"
"Content-Length: 5\r\n"
"\r\n"
"hello";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
ASSERT_EQ("HTTP/1.1 200 OK", response.substr(0, response.find("\r\n")));
}
TEST(HostHeaderValidationTest, AmbiguousOrInvalidHostRejected) {
Server svr;
svr.Get("/test", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
auto status_line = [&](const std::string &host_lines) {
std::string res;
send_request(
5, "GET /test HTTP/1.1\r\n" + host_lines + "Connection: close\r\n\r\n",
&res, port);
return res.substr(0, res.find("\r\n"));
};
const char *accepted[] = {
"Host: localhost\r\n",
"Host: a.example:8080\r\n",
"Host: my_service\r\n",
"Host: [::1]:8080\r\n",
"Host: [fe80::1%25eth0]\r\n",
"Host: \r\n", // RFC 9110 §7.2: the target URI has no authority
};
for (auto host_lines : accepted) {
EXPECT_EQ("HTTP/1.1 200 OK", status_line(host_lines)) << host_lines;
}
const char *rejected[] = {
"Host: a.example\r\nHost: b.example\r\n",
"Host: a.example\r\nHost: a.example\r\n",
"Host: a@b.example\r\n",
"Host: a.example/b\r\n",
"Host: a.example\\b\r\n",
"Host: a.example?b\r\n",
"Host: a.example#b\r\n",
"Host: a.example b.example\r\n",
};
for (auto host_lines : rejected) {
EXPECT_EQ("HTTP/1.1 400 Bad Request", status_line(host_lines))
<< host_lines;
}
}
TEST(HeaderSmugglingTest, ChunkedTrailerHeadersMerged) {
Server svr;
svr.Get("/", [](const Request &req, Response &res) {
EXPECT_EQ(2U, req.trailers.size());
EXPECT_FALSE(req.has_trailer("[invalid key...]"));
// Denied
EXPECT_FALSE(req.has_trailer("Content-Length"));
EXPECT_FALSE(req.has_trailer("X-Forwarded-For"));
// Accepted
EXPECT_TRUE(req.has_trailer("X-Hello"));
EXPECT_EQ(req.get_trailer_value("X-Hello"), "hello");
EXPECT_TRUE(req.has_trailer("X-World"));
EXPECT_EQ(req.get_trailer_value("X-World"), "world");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET / HTTP/1.1\r\n"
"Transfer-Encoding: chunked\r\n"
"Trailer: X-Hello, X-World, X-AAA, X-BBB\r\n"
"\r\n"
"0\r\n"
"Content-Length: 10\r\n"
"Host: internal.local\r\n"
"Content-Type: malicious/content\r\n"
"Cookie: any\r\n"
"Set-Cookie: any\r\n"
"X-Forwarded-For: attacker.com\r\n"
"X-Real-Ip: 1.1.1.1\r\n"
"X-Hello: hello\r\n"
"X-World: world\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(1, req, &res));
}
// RFC 9110 Section 5.2 and 5.3: a comma-separated list field may be sent as
// several field lines, and the combined value is what has to be parsed. A
// Trailer field split across lines therefore declares every name it lists;
// reading only the first line silently drops the trailers the later lines
// declare. The prohibited-trailer filter still applies to every line.
TEST(HeaderSmugglingTest, DuplicateTrailerFieldLinesDeclareAllTrailers) {
Server svr;
size_t observed_trailer_count = 0;
std::string observed_hello;
std::string observed_world;
bool observed_content_length = true;
svr.Get("/", [&](const Request &req, Response &res) {
observed_trailer_count = req.trailers.size();
observed_hello = req.get_trailer_value("X-Hello");
observed_world = req.get_trailer_value("X-World");
observed_content_length = req.has_trailer("Content-Length");
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string req = "GET / HTTP/1.1\r\n"
"Transfer-Encoding: chunked\r\n"
"Trailer: X-Hello\r\n"
"Trailer: X-World, Content-Length\r\n"
"\r\n"
"0\r\n"
"X-Hello: hello\r\n"
"X-World: world\r\n"
"Content-Length: 10\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(1, req, &res, port));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, 15));
// Accepted: both field lines contribute to the declared set
EXPECT_EQ(2U, observed_trailer_count);
EXPECT_EQ(observed_hello, "hello");
EXPECT_EQ(observed_world, "world");
// Denied: a prohibited name stays prohibited on a later field line
EXPECT_FALSE(observed_content_length);
}
// Undeclared trailer fields must count toward the trailer limit too. Otherwise
// a peer can keep the trailer-parsing loop running indefinitely by sending an
// unbounded run of fields that are never declared, because the counter would
// only advance for declared fields.
TEST(HeaderSmugglingTest, UndeclaredTrailerFieldsCountTowardLimit) {
Server svr;
bool handler_called = false;
svr.Get("/", [&](const Request & /*req*/, Response &res) {
handler_called = true;
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Declare nothing, then send far more undeclared trailer fields than the
// header-count limit. Parsing must stop and reject the request rather than
// read every line.
std::string req = "GET / HTTP/1.1\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"0\r\n";
for (int i = 0; i < CPPHTTPLIB_HEADER_MAX_COUNT + 10; i++) {
req += "X-Undeclared-" + std::to_string(i) + ": v\r\n";
}
req += "\r\n";
std::string res;
ASSERT_TRUE(send_request(1, req, &res, port));
// The request is rejected before the handler runs.
EXPECT_FALSE(handler_called);
EXPECT_EQ("HTTP/1.1 400 Bad Request", res.substr(0, res.find("\r\n")));
}
// The set of declared trailer names is capped so a peer cannot grow it without
// bound (an unkeyed hash set would otherwise be a hash-flooding target). A name
// declared past the cap is not honored, even if the field is actually sent.
TEST(HeaderSmugglingTest, DeclaredTrailerNamesAreCappedAtHeaderMaxCount) {
Server svr;
// One name inside the cap and one past it, so the test tracks the cap rather
// than a fixed count.
constexpr int declared_count = CPPHTTPLIB_HEADER_MAX_COUNT + 50;
const std::string within_cap_name = "X-T-0";
const std::string past_cap_name = "X-T-" + std::to_string(declared_count - 1);
bool observed_within_cap = false;
bool observed_past_cap = false;
svr.Get("/", [&](const Request &req, Response &res) {
observed_within_cap = req.has_trailer(within_cap_name);
observed_past_cap = req.has_trailer(past_cap_name);
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Declare more trailer names than the cap in a single Trailer field, then
// actually send the first (within the cap) and the last (past it).
std::string trailer_decl = "Trailer: ";
for (int i = 0; i < declared_count; i++) {
if (i != 0) { trailer_decl += ", "; }
trailer_decl += "X-T-" + std::to_string(i);
}
trailer_decl += "\r\n";
const std::string req = "GET / HTTP/1.1\r\n"
"Transfer-Encoding: chunked\r\n" +
trailer_decl +
"\r\n"
"0\r\n" +
within_cap_name + ": a\r\n" + past_cap_name +
": b\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(1, req, &res, port));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, res.find("\r\n")));
// A name within the cap is honored; one declared past the cap is dropped.
EXPECT_TRUE(observed_within_cap);
EXPECT_FALSE(observed_past_cap);
}
// A direct client that is not listed in trusted_proxies must not be able to
// spoof req.remote_addr by sending an arbitrary X-Forwarded-For header. Only
// the peer address on the actual TCP connection determines whether the
// header is honored.
TEST(ForwardedHeadersTest, UntrustedDirectClientCannotSpoofXFF) {
Server svr;
// Deliberately does NOT include the loopback address the test client
// actually connects from, so the direct connection is not a trusted proxy.
svr.set_trusted_proxies({"203.0.113.66"});
std::string observed_remote_addr;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Get("/ip", Headers{{"X-Forwarded-For", "9.9.9.9"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// The connecting peer is not a trusted proxy, so the spoofed header must be
// ignored entirely and the real connection-level address retained.
EXPECT_TRUE(observed_remote_addr == "::1" ||
observed_remote_addr == "127.0.0.1");
}
TEST(ForwardedHeadersTest, NoProxiesSetting) {
Server svr;
std::string observed_remote_addr;
std::string observed_xff;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff = req.get_header_value("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Get("/ip", Headers{{"X-Forwarded-For", "203.0.113.66"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(observed_xff, "203.0.113.66");
EXPECT_TRUE(observed_remote_addr == "::1" ||
observed_remote_addr == "127.0.0.1");
}
TEST(ForwardedHeadersTest, NoForwardedHeaders) {
Server svr;
svr.set_trusted_proxies({"203.0.113.66"});
std::string observed_remote_addr;
std::string observed_xff;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff = req.get_header_value("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Get("/ip");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(observed_xff, "");
EXPECT_TRUE(observed_remote_addr == "::1" ||
observed_remote_addr == "127.0.0.1");
}
TEST(ForwardedHeadersTest, SingleTrustedProxy_UsesIPBeforeTrusted) {
Server svr;
// Include the loopback address the test client actually connects from, so
// the direct connection itself is recognized as the trusted proxy hop.
svr.set_trusted_proxies({"203.0.113.66", "::1", "127.0.0.1"});
std::string observed_remote_addr;
std::string observed_xff;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff = req.get_header_value("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Get(
"/ip", Headers{{"X-Forwarded-For", "198.51.100.23, 203.0.113.66"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(observed_xff, "198.51.100.23, 203.0.113.66");
EXPECT_EQ(observed_remote_addr, "198.51.100.23");
}
TEST(ForwardedHeadersTest, MultipleTrustedProxies_UsesClientIP) {
Server svr;
svr.set_trusted_proxies({"203.0.113.66", "192.0.2.45", "::1", "127.0.0.1"});
std::string observed_remote_addr;
std::string observed_xff;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff = req.get_header_value("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Get(
"/ip",
Headers{{"X-Forwarded-For", "198.51.100.23, 203.0.113.66, 192.0.2.45"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(observed_xff, "198.51.100.23, 203.0.113.66, 192.0.2.45");
EXPECT_EQ(observed_remote_addr, "198.51.100.23");
}
TEST(ForwardedHeadersTest, TrustedProxyNotInHeader_UsesRightmostIP) {
Server svr;
svr.set_trusted_proxies({"192.0.2.45", "::1", "127.0.0.1"});
std::string observed_remote_addr;
std::string observed_xff;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff = req.get_header_value("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Get(
"/ip", Headers{{"X-Forwarded-For", "198.51.100.23, 198.51.100.24"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// No listed hop is a trusted proxy, so the rightmost entry (the one written
// by the closest hop) is used. The leftmost entry is fully client-controlled
// and must not be selected.
EXPECT_EQ(observed_xff, "198.51.100.23, 198.51.100.24");
EXPECT_EQ(observed_remote_addr, "198.51.100.24");
}
TEST(ForwardedHeadersTest, SpoofedIPBeforeTrustedProxyIsIgnored) {
Server svr;
svr.set_trusted_proxies({"10.0.0.1", "::1", "127.0.0.1"});
std::string observed_remote_addr;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// The trusted proxy appends the address it saw (5.6.7.8). The client prepends
// a forged address and the trusted proxy's own address; the forged value must
// not win over the address the trusted proxy actually recorded.
Client cli(HOST, PORT);
auto res = cli.Get(
"/ip", Headers{{"X-Forwarded-For", "1.2.3.4, 10.0.0.1, 5.6.7.8"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(observed_remote_addr, "5.6.7.8");
}
TEST(ForwardedHeadersTest, LastHopTrusted_SelectsImmediateLeftIP) {
Server svr;
svr.set_trusted_proxies({"192.0.2.45", "::1", "127.0.0.1"});
std::string observed_remote_addr;
std::string observed_xff;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff = req.get_header_value("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res = cli.Get(
"/ip",
Headers{{"X-Forwarded-For", "198.51.100.23, 203.0.113.66, 192.0.2.45"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(observed_xff, "198.51.100.23, 203.0.113.66, 192.0.2.45");
EXPECT_EQ(observed_remote_addr, "203.0.113.66");
}
TEST(ForwardedHeadersTest, HandlesWhitespaceAroundIPs) {
Server svr;
svr.set_trusted_proxies({"192.0.2.45", "::1", "127.0.0.1"});
std::string observed_remote_addr;
std::string observed_xff;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff = req.get_header_value("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
std::string raw_req =
"GET /ip HTTP/1.1\r\n"
"Host: localhost\r\n"
"X-Forwarded-For: 198.51.100.23 , 203.0.113.66 , 192.0.2.45 \r\n"
"Connection: close\r\n"
"\r\n";
std::string out;
ASSERT_TRUE(send_request(5, raw_req, &out));
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
// Header parser trims surrounding whitespace of the header value
EXPECT_EQ(observed_xff, "198.51.100.23 , 203.0.113.66 , 192.0.2.45");
EXPECT_EQ(observed_remote_addr, "203.0.113.66");
}
// RFC 9110 Section 5.2 and 5.3: repeated field lines carry the same meaning as
// one comma-joined value, in the order received. Proxies such as HAProxy append
// their own X-Forwarded-For as a separate field line rather than extending the
// one the client sent, so every occurrence has to be taken into account.
// Reading only the first one hands back the address the client chose.
TEST(ForwardedHeadersTest, DuplicateFieldLines_JoinsAllOccurrences) {
Server svr;
svr.set_trusted_proxies({"192.0.2.45", "::1", "127.0.0.1"});
std::string observed_remote_addr;
size_t observed_xff_count = 0;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
observed_xff_count = req.get_header_value_count("X-Forwarded-For");
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// The client supplies the first field line; the trusted proxy appends the
// address it observed as a second one.
std::string raw_req = "GET /ip HTTP/1.1\r\n"
"Host: localhost\r\n"
"X-Forwarded-For: 1.2.3.4\r\n"
"X-Forwarded-For: 198.51.100.23, 192.0.2.45\r\n"
"Connection: close\r\n"
"\r\n";
std::string out;
ASSERT_TRUE(send_request(5, raw_req, &out, port));
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
EXPECT_EQ(observed_xff_count, 2U);
EXPECT_EQ(observed_remote_addr, "198.51.100.23");
// The same chain spread over one field line per hop derives the same client
// address, i.e. the split and the comma-joined representations agree.
std::string per_hop_req = "GET /ip HTTP/1.1\r\n"
"Host: localhost\r\n"
"X-Forwarded-For: 1.2.3.4\r\n"
"X-Forwarded-For: 198.51.100.23\r\n"
"X-Forwarded-For: 192.0.2.45\r\n"
"Connection: close\r\n"
"\r\n";
out.clear();
ASSERT_TRUE(send_request(5, per_hop_req, &out, port));
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
EXPECT_EQ(observed_xff_count, 3U);
EXPECT_EQ(observed_remote_addr, "198.51.100.23");
}
// An X-Forwarded-For header whose value parses to zero IP segments must not
// crash the server (it used to call front() on an empty vector inside
// get_client_ip). The connection-level remote address must be retained instead.
static void run_malformed_xff_test(const std::string &xff_value) {
Server svr;
svr.set_trusted_proxies({"192.0.2.45"});
std::string observed_remote_addr;
svr.Get("/ip", [&](const Request &req, Response &res) {
observed_remote_addr = req.remote_addr;
res.set_content("ok", "text/plain");
});
int port = 0;
thread t = thread([&]() {
port = svr.bind_to_any_port(HOST);
svr.listen_after_bind();
});
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Get("/ip", Headers{{"X-Forwarded-For", xff_value}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_TRUE(observed_remote_addr == "::1" ||
observed_remote_addr == "127.0.0.1");
}
TEST(ForwardedHeadersTest, EmptyXForwardedFor_DoesNotCrash) {
run_malformed_xff_test("");
}
TEST(ForwardedHeadersTest, CommaOnlyXForwardedFor_DoesNotCrash) {
run_malformed_xff_test(",");
}
TEST(ForwardedHeadersTest, MultipleCommasXForwardedFor_DoesNotCrash) {
run_malformed_xff_test(", , ,");
}
// The same rule applies to Accept: a request whose acceptable types are spread
// over several field lines must be negotiated against all of them.
// RFC 9110 Section 7.6.1: Connection carries a comma-separated list of
// case-insensitive connection options. Comparing the whole field value against
// one option misses a client that sends several of them, and misses every
// casing but the one written in the comparison.
//
// Sends req, reads the response, then reuses the same socket for a second
// request to find out whether the server kept the connection.
static void probe_connection_reuse(int port, const std::string &req,
bool *announced_close, bool *reusable) {
auto error = Error::Success;
auto sock = detail::create_client_socket(
HOST, "", port, AF_UNSPEC, false, false, nullptr,
/*connection_timeout_sec=*/5, 0,
/*read_timeout_sec=*/1, 0,
/*write_timeout_sec=*/5, 0, std::string(), error);
ASSERT_NE(sock, INVALID_SOCKET);
auto se = detail::scope_exit([&] { detail::close_socket(sock); });
const std::string second = "GET /keepalive HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n";
std::string first_response;
std::string second_response;
detail::process_client_socket(
sock, 1, 0, 5, 0, 0, std::chrono::steady_clock::time_point::min(),
[&](Stream &strm) {
if (strm.write(req.data(), req.size()) !=
static_cast<ssize_t>(req.size())) {
return false;
}
char buf[512];
detail::stream_line_reader reader(strm, buf, sizeof(buf));
// Headers plus the two-byte body of the handler below
while (reader.getline()) {
first_response += reader.ptr();
if (first_response.find("ok") != std::string::npos) { break; }
}
if (strm.write(second.data(), second.size()) !=
static_cast<ssize_t>(second.size())) {
return true;
}
detail::stream_line_reader reader2(strm, buf, sizeof(buf));
while (reader2.getline()) {
second_response += reader2.ptr();
if (second_response.find("ok") != std::string::npos) { break; }
}
return true;
});
*announced_close =
first_response.find("Connection: close") != std::string::npos;
*reusable = second_response.find("HTTP/1.1 200") != std::string::npos;
}
class ConnectionTokenTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.Get("/keepalive", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
port_ = svr_.bind_to_any_port(HOST);
thread_ = thread([&]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) { thread_.join(); }
}
Server svr_;
int port_ = 0;
thread thread_;
};
// "close" alongside another option still closes the connection, and the
// response says so rather than leaving the peer to discover it.
TEST_F(ConnectionTokenTest, CloseAmongSeveralOptionsIsHonored) {
bool announced_close = false;
bool reusable = true;
probe_connection_reuse(port_,
"GET /keepalive HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: keep-alive, close\r\n"
"\r\n",
&announced_close, &reusable);
EXPECT_TRUE(announced_close);
EXPECT_FALSE(reusable);
}
// "close" split over two field lines is the same list, so it is honored too.
TEST_F(ConnectionTokenTest, CloseOnALaterFieldLineIsHonored) {
bool announced_close = false;
bool reusable = true;
probe_connection_reuse(port_,
"GET /keepalive HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: keep-alive\r\n"
"Connection: close\r\n"
"\r\n",
&announced_close, &reusable);
EXPECT_TRUE(announced_close);
EXPECT_FALSE(reusable);
}
// Connection options are case-insensitive, so an HTTP/1.0 client asking for
// keep-alive in the spelling everyone actually sends keeps its connection.
TEST_F(ConnectionTokenTest, Http10KeepAliveIsCaseInsensitive) {
bool announced_close = false;
bool reusable = false;
probe_connection_reuse(port_,
"GET /keepalive HTTP/1.0\r\n"
"Host: localhost\r\n"
"Connection: keep-alive\r\n"
"\r\n",
&announced_close, &reusable);
EXPECT_FALSE(announced_close);
EXPECT_TRUE(reusable);
}
// A token the value merely contains is not the token itself.
TEST_F(ConnectionTokenTest, SubstringOfAnOptionIsNotTheOption) {
bool announced_close = false;
bool reusable = false;
probe_connection_reuse(port_,
"GET /keepalive HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: notclose\r\n"
"\r\n",
&announced_close, &reusable);
EXPECT_FALSE(announced_close);
EXPECT_TRUE(reusable);
}
TEST(RepeatedFieldLinesTest, AcceptCombinesEveryFieldLine) {
Server svr;
std::vector<std::string> observed_types;
svr.Get("/", [&](const Request &req, Response &res) {
observed_types = req.accept_content_types;
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
Headers headers;
headers.emplace("Accept", "text/plain;q=0.5");
headers.emplace("Accept", "application/json");
auto res = cli.Get("/", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// Sorted by q-value, so the second field line's type comes first
ASSERT_EQ(2U, observed_types.size());
EXPECT_EQ("application/json", observed_types[0]);
EXPECT_EQ("text/plain", observed_types[1]);
}
// RFC 9110 Section 5.6.1.2: empty list elements are parsed and ignored, so an
// empty field line must not contribute a bare comma to the combined value.
TEST(RepeatedFieldLinesTest, EmptyFieldLineDoesNotInjectComma) {
Server svr;
std::vector<std::string> observed_types;
svr.Get("/", [&](const Request &req, Response &res) {
observed_types = req.accept_content_types;
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Empty first field line, then a real one
std::string raw_req = "GET / HTTP/1.1\r\n"
"Host: localhost\r\n"
"Accept:\r\n"
"Accept: application/json\r\n"
"Connection: close\r\n"
"\r\n";
std::string out;
ASSERT_TRUE(send_request(5, raw_req, &out, port));
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
ASSERT_EQ(1U, observed_types.size());
EXPECT_EQ("application/json", observed_types[0]);
}
// The skip in get_combined_header_value() is what keeps an empty field line
// from contributing a bare comma. Only a trailing empty field line exercises
// it: a leading one is already covered by the "combined is still empty" check,
// and parse_accept_header() now ignores the empty element either way, so the
// request-level test above can no longer tell the two apart.
TEST(RepeatedFieldLinesTest, EmptyFieldLineIsNotCombined) {
Headers headers;
headers.emplace("Accept", "text/html");
headers.emplace("Accept", "");
EXPECT_EQ("text/html", detail::get_combined_header_value(headers, "Accept"));
headers.clear();
headers.emplace("Accept", "");
headers.emplace("Accept", "application/json");
EXPECT_EQ("application/json",
detail::get_combined_header_value(headers, "Accept"));
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
// An encoding offered on a later Accept-Encoding field line is still offered.
TEST(RepeatedFieldLinesTest, AcceptEncodingCombinesEveryFieldLine) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_content(std::string(1024, 'x'), "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
cli.set_decompress(false);
Headers headers;
headers.emplace("Accept-Encoding", "identity");
headers.emplace("Accept-Encoding", "gzip");
auto res = cli.Get("/", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
}
#endif
#ifndef _WIN32
TEST(ServerRequestParsingTest, RequestWithoutContentLengthOrTransferEncoding) {
Server svr;
svr.Post("/post", [&](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
svr.Put("/put", [&](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
svr.Patch("/patch", [&](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
svr.Delete("/delete", [&](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
thread t = thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
std::string resp;
// POST without Content-Length
ASSERT_TRUE(send_request(5,
"POST /post HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n",
&resp));
EXPECT_TRUE(resp.find("HTTP/1.1 200 OK") == 0);
// PUT without Content-Length
resp.clear();
ASSERT_TRUE(send_request(5,
"PUT /put HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n",
&resp));
EXPECT_TRUE(resp.find("HTTP/1.1 200 OK") == 0);
// PATCH without Content-Length
resp.clear();
ASSERT_TRUE(send_request(5,
"PATCH /patch HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n",
&resp));
EXPECT_TRUE(resp.find("HTTP/1.1 200 OK") == 0);
// DELETE without Content-Length
resp.clear();
ASSERT_TRUE(send_request(5,
"DELETE /delete HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n",
&resp));
EXPECT_TRUE(resp.find("HTTP/1.1 200 OK") == 0);
}
#endif
//==============================================================================
// open_stream() Tests
//==============================================================================
inline std::string read_all(ClientImpl::StreamHandle &handle) {
std::string result;
char buf[8192];
ssize_t n;
while ((n = handle.read(buf, sizeof(buf))) > 0) {
result.append(buf, static_cast<size_t>(n));
}
return result;
}
// Mock stream for unit tests
class MockStream : public Stream {
public:
std::string data;
size_t pos = 0;
ssize_t error_after = -1; // -1 = no error
explicit MockStream(const std::string &d, ssize_t err = -1)
: data(d), error_after(err) {}
bool is_readable() const override { return true; }
bool wait_readable() const override { return true; }
bool wait_writable() const override { return true; }
ssize_t read(char *ptr, size_t size) override {
if (error_after >= 0 && pos >= static_cast<size_t>(error_after)) return -1;
if (pos >= data.size()) return 0;
size_t limit =
error_after >= 0 ? static_cast<size_t>(error_after) : data.size();
size_t to_read = std::min(size, std::min(data.size() - pos, limit - pos));
std::memcpy(ptr, data.data() + pos, to_read);
pos += to_read;
return static_cast<ssize_t>(to_read);
}
ssize_t write(const char *, size_t) override { return -1; }
void get_remote_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 0;
}
void get_local_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 0;
}
socket_t socket() const override { return INVALID_SOCKET; }
time_t duration() const override { return 0; }
};
TEST(StreamHandleTest, Basic) {
ClientImpl::StreamHandle handle;
EXPECT_FALSE(handle.is_valid());
handle.response = detail::make_unique<Response>();
handle.error = Error::Connection;
EXPECT_FALSE(handle.is_valid());
handle.error = Error::Success;
EXPECT_TRUE(handle.is_valid());
}
TEST(BodyReaderTest, Basic) {
MockStream stream("Hello, World!");
detail::BodyReader reader;
reader.stream = &stream;
reader.content_length = 13;
char buf[32];
EXPECT_EQ(13, reader.read(buf, sizeof(buf)));
EXPECT_EQ(0, reader.read(buf, sizeof(buf)));
EXPECT_TRUE(reader.eof);
}
TEST(BodyReaderTest, NoStream) {
detail::BodyReader reader;
char buf[32];
EXPECT_EQ(-1, reader.read(buf, sizeof(buf)));
EXPECT_EQ(Error::Connection, reader.last_error);
}
TEST(BodyReaderTest, Error) {
MockStream stream("Hello, World!", 5);
detail::BodyReader reader;
reader.stream = &stream;
reader.content_length = 13;
char buf[32];
EXPECT_EQ(5, reader.read(buf, sizeof(buf)));
EXPECT_EQ(-1, reader.read(buf, sizeof(buf)));
EXPECT_EQ(Error::Read, reader.last_error);
}
// Memory buffer mode removed: StreamHandle reads only from socket streams.
// Mock-based StreamHandle tests relying on private internals are removed.
class OpenStreamTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.Get("/hello", [](const Request &, Response &res) {
res.set_content("Hello World!", "text/plain");
});
svr_.Get("/large", [](const Request &, Response &res) {
res.set_content(std::string(10000, 'X'), "text/plain");
});
svr_.Get("/unknown-encoding", [](const Request &, Response &res) {
res.set_content("Hello World!", "image/jpeg");
res.set_header("Content-Encoding", "UTF-8");
});
svr_.Get("/chunked", [](const Request &, Response &res) {
res.set_chunked_content_provider("text/plain",
[](size_t offset, DataSink &sink) {
if (offset < 15) {
sink.write("chunk", 5);
return true;
}
sink.done();
return true;
});
});
svr_.Get("/compressible", [](const Request &, Response &res) {
res.set_chunked_content_provider("text/plain", [](size_t offset,
DataSink &sink) {
if (offset < 100 * 1024) {
std::string chunk(std::min(size_t(8192), 100 * 1024 - offset), 'A');
sink.write(chunk.data(), chunk.size());
return true;
}
sink.done();
return true;
});
});
svr_.Get("/streamed-chunked-with-prohibited-trailer",
[](const Request & /*req*/, Response &res) {
auto i = new int(0);
res.set_header("Trailer", "Content-Length, X-Allowed");
res.set_chunked_content_provider(
"text/plain",
[i](size_t /*offset*/, DataSink &sink) {
switch (*i) {
case 0: sink.os << "123"; break;
case 1: sink.os << "456"; break;
case 2: sink.os << "789"; break;
case 3: {
sink.done_with_trailer(
{{"Content-Length", "5"}, {"X-Allowed", "yes"}});
} break;
}
(*i)++;
return true;
},
[i](bool success) {
EXPECT_TRUE(success);
delete i;
});
});
// Echo headers endpoint for header-related tests
svr_.Get("/echo-headers", [](const Request &req, Response &res) {
std::string body;
for (const auto &h : req.headers) {
body.append(h.first);
body.push_back(':');
body.append(h.second);
body.push_back('\n');
}
res.set_content(body, "text/plain");
});
svr_.Post("/echo-headers", [](const Request &req, Response &res) {
std::string body;
for (const auto &h : req.headers) {
body.append(h.first);
body.push_back(':');
body.append(h.second);
body.push_back('\n');
}
res.set_content(body, "text/plain");
});
port_ = svr_.bind_to_any_port("127.0.0.1");
thread_ = std::thread([this]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
Server svr_;
std::thread thread_;
int port_ = 0;
};
TEST_F(OpenStreamTest, Basic) {
Client cli("127.0.0.1", port_);
auto handle = cli.open_stream("GET", "/hello");
EXPECT_TRUE(handle.is_valid());
EXPECT_EQ("Hello World!", read_all(handle));
}
TEST_F(OpenStreamTest, UnknownContentEncodingIsPassedThrough) {
Client cli("127.0.0.1", port_);
auto handle = cli.open_stream("GET", "/unknown-encoding");
ASSERT_TRUE(handle.is_valid());
EXPECT_EQ("Hello World!", read_all(handle));
}
TEST_F(OpenStreamTest, SmallBuffer) {
Client cli("127.0.0.1", port_);
auto handle = cli.open_stream("GET", "/hello");
std::string result;
char buf[4];
ssize_t n;
while ((n = handle.read(buf, sizeof(buf))) > 0)
result.append(buf, static_cast<size_t>(n));
EXPECT_EQ("Hello World!", result);
}
TEST_F(OpenStreamTest, DefaultHeaders) {
Client cli("127.0.0.1", port_);
// open_stream GET should include Host, User-Agent and Accept-Encoding
{
auto handle = cli.open_stream("GET", "/echo-headers");
ASSERT_TRUE(handle.is_valid());
auto body = read_all(handle);
EXPECT_NE(body.find("Host:127.0.0.1:" + std::to_string(port_)),
std::string::npos);
EXPECT_NE(body.find("User-Agent:cpp-httplib/" CPPHTTPLIB_VERSION),
std::string::npos);
EXPECT_NE(body.find("Accept-Encoding:"), std::string::npos);
}
// open_stream POST with body and no explicit content_type should NOT add
// text/plain Content-Type (behavior differs from non-streaming path), but
// should include Content-Length
{
auto handle = cli.open_stream("POST", "/echo-headers", {}, {}, "hello", "");
ASSERT_TRUE(handle.is_valid());
auto body = read_all(handle);
EXPECT_EQ(body.find("Content-Type: text/plain"), std::string::npos);
EXPECT_NE(body.find("Content-Length:5"), std::string::npos);
}
// open_stream POST with explicit Content-Type should preserve it
{
auto handle = cli.open_stream("POST", "/echo-headers", {},
{{"Content-Type", "application/custom"}},
"{}", "application/custom");
ASSERT_TRUE(handle.is_valid());
auto body = read_all(handle);
EXPECT_NE(body.find("Content-Type:application/custom"), std::string::npos);
}
// User-specified User-Agent must not be overwritten for stream API
{
auto handle = cli.open_stream("GET", "/echo-headers", {},
{{"User-Agent", "MyAgent/1.2"}});
ASSERT_TRUE(handle.is_valid());
auto body = read_all(handle);
EXPECT_NE(body.find("User-Agent:MyAgent/1.2"), std::string::npos);
}
}
TEST_F(OpenStreamTest, Large) {
Client cli("127.0.0.1", port_);
auto handle = cli.open_stream("GET", "/large");
EXPECT_EQ(10000u, read_all(handle).size());
}
TEST_F(OpenStreamTest, ConnectionError) {
Client cli("127.0.0.1", 9999);
auto handle = cli.open_stream("GET", "/hello");
EXPECT_FALSE(handle.is_valid());
}
TEST_F(OpenStreamTest, Chunked) {
Client cli("127.0.0.1", port_);
auto handle = cli.open_stream("GET", "/chunked");
EXPECT_TRUE(handle.response && handle.response->get_header_value(
"Transfer-Encoding") == "chunked");
EXPECT_EQ("chunkchunkchunk", read_all(handle));
}
TEST_F(OpenStreamTest, ProhibitedTrailersAreIgnored_Stream) {
Client cli("127.0.0.1", port_);
auto handle =
cli.open_stream("GET", "/streamed-chunked-with-prohibited-trailer");
ASSERT_TRUE(handle.is_valid());
// Consume body to allow trailers to be received/parsed
auto body = read_all(handle);
// Explicitly parse trailers (ensure trailers are available for assertion)
handle.parse_trailers_if_needed();
EXPECT_EQ(std::string("123456789"), body);
// The response should include a Trailer header declaring both names
ASSERT_TRUE(handle.response);
EXPECT_TRUE(handle.response->has_header("Trailer"));
EXPECT_EQ(std::string("Content-Length, X-Allowed"),
handle.response->get_header_value("Trailer"));
// Prohibited trailer must not be present
EXPECT_FALSE(handle.response->has_trailer("Content-Length"));
// Allowed trailer should be present
EXPECT_TRUE(handle.response->has_trailer("X-Allowed"));
EXPECT_EQ(std::string("yes"),
handle.response->get_trailer_value("X-Allowed"));
// Verify trailers are NOT present as regular headers
EXPECT_EQ(std::string(""),
handle.response->get_header_value("Content-Length"));
EXPECT_EQ(std::string(""), handle.response->get_header_value("X-Allowed"));
}
static std::thread serve_single_response(std::promise<int> &port_promise,
const std::string &response) {
return std::thread([&port_promise, response] {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(srv, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(0); // Let OS assign a free port
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
int opt = 1;
::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR,
#ifdef _WIN32
reinterpret_cast<const char *>(&opt),
#else
&opt,
#endif
sizeof(opt));
if (::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) != 0 ||
::listen(srv, 1) != 0) {
port_promise.set_value(-1);
detail::close_socket(srv);
return;
}
socklen_t addr_len = sizeof(addr);
::getsockname(srv, reinterpret_cast<sockaddr *>(&addr), &addr_len);
port_promise.set_value(static_cast<int>(ntohs(addr.sin_port)));
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli != INVALID_SOCKET) {
// Read the complete HTTP request (until the blank line that terminates
// headers) before sending the response. If the server closes the socket
// while the client's request data is still unread in the kernel receive
// buffer, the TCP stack sends RST instead of FIN. That RST resets the
// connection on both sides: it discards the client's receive buffer
// (which already holds our response) and causes any in-progress write on
// the client to fail with ECONNRESET. Reading all request data first
// ensures close() emits a graceful FIN.
{
char rbuf[256];
std::string req;
while (req.find("\r\n\r\n") == std::string::npos) {
auto n = ::recv(cli, rbuf, sizeof(rbuf), 0);
if (n <= 0) { break; }
req.append(rbuf, static_cast<size_t>(n));
}
}
::send(cli,
#ifdef _WIN32
static_cast<const char *>(response.c_str()),
static_cast<int>(response.size()),
#else
response.c_str(), response.size(),
#endif
0);
detail::close_socket(cli);
}
detail::close_socket(srv);
});
}
TEST(OpenStreamMalformedContentLength, InvalidArgument) {
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
std::promise<int> port_promise;
auto port_future = port_promise.get_future();
auto server_thread =
serve_single_response(port_promise, "HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: not-a-number\r\n"
"Connection: close\r\n"
"\r\n"
"hello");
auto port = port_future.get();
ASSERT_GT(port, 0);
Client cli("127.0.0.1", port);
auto handle = cli.open_stream("GET", "/");
EXPECT_FALSE(handle.is_valid());
server_thread.join();
}
TEST(OpenStreamMalformedContentLength, OutOfRange) {
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
std::promise<int> port_promise;
auto port_future = port_promise.get_future();
auto server_thread = serve_single_response(
port_promise, "HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: 99999999999999999999999999\r\n"
"Connection: close\r\n"
"\r\n"
"hello");
auto port = port_future.get();
ASSERT_GT(port, 0);
// Historically std::stoull would throw std::out_of_range here and crash
// the process, then parsing silently clamped to a bogus framing length and
// the stream opened anyway. Now the out-of-range value is flagged invalid,
// so the stream fails to open, matching the not-a-number case above. The
// process still must NOT terminate.
Client cli("127.0.0.1", port);
auto handle = cli.open_stream("GET", "/");
EXPECT_FALSE(handle.is_valid());
server_thread.join();
}
// Serves `response` to the single request `fn` makes with a fresh client.
template <typename Fn>
static void with_single_response(const std::string &response, Fn fn) {
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
std::promise<int> port_promise;
auto port_future = port_promise.get_future();
auto server_thread = serve_single_response(port_promise, response);
auto se = detail::scope_exit([&] { server_thread.join(); });
auto port = port_future.get();
ASSERT_GT(port, 0);
Client cli("127.0.0.1", port);
fn(cli);
}
// RFC 9112 §6.3: a response that pairs a non-zero Content-Length with
// Transfer-Encoding is framed ambiguously. Both read paths delimit its body by
// the chunked coding and drop Content-Length, so a front-end that trusts
// Content-Length would disagree about where the body ends (response
// smuggling). The client must reject such a response.
TEST(ClientResponseSmugglingTest, ContentLengthAndTransferEncodingRejected) {
for (const char *te : {"chunked", "gzip, chunked"}) {
auto response = std::string("HTTP/1.1 200 OK\r\n") +
"Content-Length: 5\r\n" + "Transfer-Encoding: " + te +
"\r\n" + "Connection: close\r\n" + "\r\n" +
"5\r\nhello\r\n0\r\n\r\n";
with_single_response(response, [&](Client &cli) {
auto res = cli.Get("/");
EXPECT_FALSE(static_cast<bool>(res)) << te;
EXPECT_EQ(Error::Read, res.error()) << te;
});
with_single_response(response, [&](Client &cli) {
auto handle = cli.open_stream("GET", "/");
EXPECT_FALSE(handle.is_valid()) << te;
EXPECT_EQ(Error::Read, handle.error) << te;
});
}
}
// Unambiguous framing stays readable on both paths: chunked alone, and a final
// coding other than chunked, whose body RFC 9112 §6.3 delimits by the server
// closing the connection (unlike a request, which must be rejected).
TEST(ClientResponseSmugglingTest, UnambiguousFramingAccepted) {
for (const char *response : {"HTTP/1.1 200 OK\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"\r\n"
"5\r\nhello\r\n0\r\n\r\n",
"HTTP/1.1 200 OK\r\n"
"Transfer-Encoding: gzip\r\n"
"Connection: close\r\n"
"\r\n"
"hello"}) {
with_single_response(response, [&](Client &cli) {
auto res = cli.Get("/");
ASSERT_TRUE(static_cast<bool>(res)) << response;
EXPECT_EQ("hello", res->body);
});
with_single_response(response, [&](Client &cli) {
auto handle = cli.open_stream("GET", "/");
ASSERT_TRUE(handle.is_valid()) << response;
EXPECT_EQ("hello", read_all(handle));
});
}
}
// A response to HEAD, and a 204 or 304 response, carries no body, so its
// framing headers describe nothing to read and must not be rejected.
TEST(ClientResponseSmugglingTest, BodylessResponseNotRejected) {
const std::string framing = "Content-Length: 5\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"\r\n";
with_single_response("HTTP/1.1 200 OK\r\n" + framing, [](Client &cli) {
EXPECT_TRUE(static_cast<bool>(cli.Head("/")));
});
with_single_response("HTTP/1.1 200 OK\r\n" + framing, [](Client &cli) {
EXPECT_TRUE(cli.open_stream("HEAD", "/").is_valid());
});
for (const char *status : {"204 No Content", "304 Not Modified"}) {
auto response = std::string("HTTP/1.1 ") + status + "\r\n" + framing;
with_single_response(response, [&](Client &cli) {
EXPECT_TRUE(static_cast<bool>(cli.Get("/"))) << status;
});
with_single_response(response, [&](Client &cli) {
EXPECT_TRUE(cli.open_stream("GET", "/").is_valid()) << status;
});
}
}
// A payload max length of 0 means no limit, however the body is framed.
TEST(PayloadMaxLengthZeroTest, ClientReadsChunkedAndUnframedResponse) {
for (const char *response : {"HTTP/1.1 200 OK\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"5\r\nhello\r\n0\r\n\r\n",
"HTTP/1.1 200 OK\r\n"
"Connection: close\r\n"
"\r\n"
"hello"}) {
with_single_response(response, [&](Client &cli) {
cli.set_payload_max_length(0);
auto res = cli.Get("/");
ASSERT_TRUE(res) << response;
EXPECT_EQ("hello", res->body) << response;
});
}
}
TEST(PayloadMaxLengthZeroTest, ServerReadsChunkedRequest) {
Server svr;
svr.set_payload_max_length(0);
svr.Post("/echo", [](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
auto res = cli.Post(
"/echo",
[](size_t /*offset*/, DataSink &sink) {
sink.write("hello", 5);
sink.done();
return true;
},
"text/plain");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("hello", res->body);
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST_F(OpenStreamTest, Gzip) {
Client cli("127.0.0.1", port_);
auto handle = cli.open_stream("GET", "/compressible", {},
{{"Accept-Encoding", "gzip"}});
EXPECT_EQ("gzip", handle.response->get_header_value("Content-Encoding"));
EXPECT_EQ(100u * 1024u, read_all(handle).size());
}
#endif
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
TEST_F(OpenStreamTest, Brotli) {
Client cli("127.0.0.1", port_);
auto handle =
cli.open_stream("GET", "/compressible", {}, {{"Accept-Encoding", "br"}});
EXPECT_EQ("br", handle.response->get_header_value("Content-Encoding"));
EXPECT_EQ(100u * 1024u, read_all(handle).size());
}
#endif
#ifdef CPPHTTPLIB_ZSTD_SUPPORT
TEST_F(OpenStreamTest, Zstd) {
Client cli("127.0.0.1", port_);
auto handle = cli.open_stream("GET", "/compressible", {},
{{"Accept-Encoding", "zstd"}});
EXPECT_EQ("zstd", handle.response->get_header_value("Content-Encoding"));
EXPECT_EQ(100u * 1024u, read_all(handle).size());
}
#endif
#ifdef CPPHTTPLIB_SSL_ENABLED
class SSLOpenStreamTest : public ::testing::Test {
protected:
SSLOpenStreamTest() : svr_("cert.pem", "key.pem") {}
void SetUp() override {
svr_.Get("/hello", [](const Request &, Response &res) {
res.set_content("Hello SSL World!", "text/plain");
});
svr_.Get("/chunked", [](const Request &, Response &res) {
res.set_chunked_content_provider("text/plain",
[](size_t offset, DataSink &sink) {
if (offset < 15) {
sink.write("chunk", 5);
return true;
}
sink.done();
return true;
});
});
svr_.Post("/echo", [](const Request &req, Response &res) {
res.set_content(req.body, req.get_header_value("Content-Type"));
});
svr_.Post("/chunked-response", [](const Request &req, Response &res) {
std::string body = req.body;
res.set_chunked_content_provider(
"text/plain", [body](size_t offset, DataSink &sink) {
if (offset < body.size()) {
sink.write(body.data() + offset, body.size() - offset);
}
sink.done();
return true;
});
});
port_ = svr_.bind_to_any_port("127.0.0.1");
thread_ = std::thread([this]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
SSLServer svr_;
std::thread thread_;
int port_ = 0;
};
TEST_F(SSLOpenStreamTest, Basic) {
SSLClient cli("127.0.0.1", port_);
cli.enable_server_certificate_verification(false);
auto handle = cli.open_stream("GET", "/hello");
ASSERT_TRUE(handle.is_valid());
EXPECT_EQ("Hello SSL World!", read_all(handle));
}
TEST_F(SSLOpenStreamTest, Chunked) {
SSLClient cli("127.0.0.1", port_);
cli.enable_server_certificate_verification(false);
auto handle = cli.open_stream("GET", "/chunked");
ASSERT_TRUE(handle.is_valid()) << "Error: " << static_cast<int>(handle.error);
EXPECT_TRUE(handle.response && handle.response->get_header_value(
"Transfer-Encoding") == "chunked");
auto body = read_all(handle);
EXPECT_EQ("chunkchunkchunk", body);
}
TEST_F(SSLOpenStreamTest, Post) {
SSLClient cli("127.0.0.1", port_);
cli.enable_server_certificate_verification(false);
auto handle =
cli.open_stream("POST", "/echo", {}, {}, "Hello SSL POST", "text/plain");
ASSERT_TRUE(handle.is_valid()) << "Error: " << static_cast<int>(handle.error);
EXPECT_EQ(200, handle.response->status);
auto body = read_all(handle);
EXPECT_EQ("Hello SSL POST", body);
}
TEST_F(SSLOpenStreamTest, PostChunked) {
SSLClient cli("127.0.0.1", port_);
cli.enable_server_certificate_verification(false);
auto handle = cli.open_stream("POST", "/chunked-response", {}, {},
"Chunked SSL Data", "text/plain");
ASSERT_TRUE(handle.is_valid());
EXPECT_EQ(200, handle.response->status);
auto body = read_all(handle);
EXPECT_EQ("Chunked SSL Data", body);
}
// RFC 7230 §3.3: a response body with neither chunked Transfer-Encoding nor
// Content-Length is terminated by the server closing the connection. When the
// SSL peer sends a close_notify after the body, the client must treat it as a
// clean EOF and return a successful response rather than an error.
TEST(SSLTest, ResponseBodyTerminatedByConnectionClose) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.set_keep_alive_max_count(1);
const std::string expected_body = "Hello from connection-close response!";
svr.Get("/no-content-length", [&](const Request & /*req*/, Response &res) {
res.set_content_provider("text/plain",
[&](size_t offset, DataSink &sink) -> bool {
if (offset < expected_body.size()) {
sink.write(expected_body.data() + offset,
expected_body.size() - offset);
}
sink.done();
return true;
});
});
auto listen_thread = std::thread([&svr] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
auto res = cli.Get("/no-content-length");
ASSERT_TRUE(res) << "Request failed: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(expected_body, res->body);
}
#endif // CPPHTTPLIB_SSL_ENABLED
//==============================================================================
// Parity Tests: ensure streaming and non-streaming APIs produce identical
// results for various scenarios.
//==============================================================================
TEST(ParityTest, GetVsOpenStream) {
Server svr;
const std::string path = "/parity";
const std::string content = "Parity test content: hello world";
svr.Get(path, [&](const Request & /*req*/, Response &res) {
res.set_content(content, "text/plain");
});
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
// Non-stream path
auto r1 = cli.Get(path);
ASSERT_TRUE(r1);
EXPECT_EQ(StatusCode::OK_200, r1->status);
// Stream path
auto h = cli.open_stream("GET", path);
ASSERT_TRUE(h.is_valid());
EXPECT_EQ(r1->body, read_all(h));
}
// Helper to compress data with provided compressor type T
template <typename Compressor>
static std::string compress_payload_for_parity(const std::string &in) {
std::string out;
Compressor compressor;
bool ok = compressor.compress(in.data(), in.size(), /*last=*/true,
[&](const char *data, size_t n) {
out.append(data, n);
return true;
});
EXPECT_TRUE(ok);
return out;
}
// Helper function for compression parity tests
template <typename Compressor>
static void test_compression_parity(const std::string &original,
const std::string &path,
const std::string &encoding) {
const std::string compressed =
compress_payload_for_parity<Compressor>(original);
Server svr;
svr.Get(path, [&](const Request & /*req*/, Response &res) {
res.set_content(compressed, "application/octet-stream");
res.set_header("Content-Encoding", encoding);
});
auto t = std::thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
// Non-streaming
{
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(original, res->body);
}
// Streaming
{
auto h = cli.open_stream("GET", path);
ASSERT_TRUE(h.is_valid());
EXPECT_EQ(original, read_all(h));
}
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST(ParityTest, Gzip) {
test_compression_parity<detail::gzip_compressor>(
"The quick brown fox jumps over the lazy dog", "/parity-gzip", "gzip");
}
#endif
#ifdef CPPHTTPLIB_BROTLI_SUPPORT
TEST(ParityTest, Brotli) {
test_compression_parity<detail::brotli_compressor>(
"Hello, brotli parity test payload", "/parity-br", "br");
}
#endif
#ifdef CPPHTTPLIB_ZSTD_SUPPORT
TEST(ParityTest, Zstd) {
test_compression_parity<detail::zstd_compressor>(
"Zstandard parity test payload", "/parity-zstd", "zstd");
}
#endif
//==============================================================================
// New Stream API Tests
//==============================================================================
inline std::string read_body(httplib::stream::Result &result) {
std::string body;
while (result.next()) {
body.append(result.data(), result.size());
}
return body;
}
TEST(ClientConnectionTest, Basic) {
httplib::ClientConnection conn;
EXPECT_FALSE(conn.is_open());
conn.sock = 1;
EXPECT_TRUE(conn.is_open());
httplib::ClientConnection conn2(std::move(conn));
EXPECT_EQ(INVALID_SOCKET, conn.sock);
conn2.sock = INVALID_SOCKET;
}
// Unified test server for all stream::* tests
class StreamApiTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.Get("/hello", [](const httplib::Request &, httplib::Response &res) {
res.set_content("Hello World!", "text/plain");
});
svr_.Get("/echo-params",
[](const httplib::Request &req, httplib::Response &res) {
std::string r;
for (const auto &p : req.params) {
if (!r.empty()) r += "&";
r += p.first + "=" + p.second;
}
res.set_content(r, "text/plain");
});
svr_.Post("/echo", [](const httplib::Request &req, httplib::Response &res) {
res.set_content(req.body, req.get_header_value("Content-Type"));
});
svr_.Post("/echo-headers",
[](const httplib::Request &req, httplib::Response &res) {
std::string r;
for (const auto &h : req.headers)
r += h.first + ": " + h.second + "\n";
res.set_content(r, "text/plain");
});
svr_.Post("/echo-params",
[](const httplib::Request &req, httplib::Response &res) {
std::string r = "params:";
for (const auto &p : req.params)
r += p.first + "=" + p.second + ";";
res.set_content(r + " body:" + req.body, "text/plain");
});
svr_.Post("/large", [](const httplib::Request &, httplib::Response &res) {
res.set_content(std::string(100 * 1024, 'X'), "application/octet-stream");
});
svr_.Put("/echo", [](const httplib::Request &req, httplib::Response &res) {
res.set_content("PUT:" + req.body, "text/plain");
});
svr_.Patch("/echo",
[](const httplib::Request &req, httplib::Response &res) {
res.set_content("PATCH:" + req.body, "text/plain");
});
svr_.Delete(
"/resource", [](const httplib::Request &req, httplib::Response &res) {
res.set_content(req.body.empty() ? "Deleted" : "Deleted:" + req.body,
"text/plain");
});
svr_.Get("/head-test",
[](const httplib::Request &, httplib::Response &res) {
res.set_content("body for HEAD", "text/plain");
});
svr_.Options("/options",
[](const httplib::Request &, httplib::Response &res) {
res.set_header("Allow", "GET, POST, PUT, DELETE, OPTIONS");
});
thread_ = std::thread([this]() { svr_.listen(HOST, PORT); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
httplib::Server svr_;
std::thread thread_;
};
// stream::Get tests
TEST_F(StreamApiTest, GetBasic) {
httplib::Client cli(HOST, PORT);
auto result = httplib::stream::Get(cli, "/hello");
ASSERT_TRUE(result.is_valid());
EXPECT_EQ(200, result.status());
EXPECT_EQ("Hello World!", read_body(result));
}
TEST_F(StreamApiTest, GetWithParams) {
httplib::Client cli(HOST, PORT);
httplib::Params params{{"foo", "bar"}};
auto result = httplib::stream::Get(cli, "/echo-params", params);
ASSERT_TRUE(result.is_valid());
EXPECT_TRUE(read_body(result).find("foo=bar") != std::string::npos);
}
TEST_F(StreamApiTest, GetConnectionError) {
httplib::Client cli(HOST, 9999);
EXPECT_FALSE(httplib::stream::Get(cli, "/hello").is_valid());
}
TEST_F(StreamApiTest, Get404) {
httplib::Client cli(HOST, PORT);
auto result = httplib::stream::Get(cli, "/nonexistent");
EXPECT_TRUE(result.is_valid());
EXPECT_EQ(404, result.status());
}
// stream::Post tests
TEST_F(StreamApiTest, PostBasic) {
httplib::Client cli(HOST, PORT);
auto result = httplib::stream::Post(cli, "/echo", R"({"key":"value"})",
"application/json");
ASSERT_TRUE(result.is_valid());
EXPECT_EQ("application/json", result.get_header_value("Content-Type"));
EXPECT_EQ(R"({"key":"value"})", read_body(result));
}
TEST_F(StreamApiTest, PostWithHeaders) {
httplib::Client cli(HOST, PORT);
httplib::Headers headers{{"X-Custom", "value"}};
auto result = httplib::stream::Post(cli, "/echo-headers", headers, "body",
"text/plain");
EXPECT_TRUE(read_body(result).find("X-Custom: value") != std::string::npos);
}
TEST_F(StreamApiTest, PostWithParams) {
httplib::Client cli(HOST, PORT);
httplib::Params params{{"k", "v"}};
auto result =
httplib::stream::Post(cli, "/echo-params", params, "data", "text/plain");
auto body = read_body(result);
EXPECT_TRUE(body.find("k=v") != std::string::npos);
EXPECT_TRUE(body.find("body:data") != std::string::npos);
}
TEST_F(StreamApiTest, PostLarge) {
httplib::Client cli(HOST, PORT);
auto result = httplib::stream::Post(cli, "/large", "", "text/plain");
size_t total = 0;
while (result.next()) {
total += result.size();
}
EXPECT_EQ(100u * 1024u, total);
}
// stream::Put/Patch tests
TEST_F(StreamApiTest, PutAndPatch) {
httplib::Client cli(HOST, PORT);
auto put = httplib::stream::Put(cli, "/echo", "test", "text/plain");
EXPECT_EQ("PUT:test", read_body(put));
auto patch = httplib::stream::Patch(cli, "/echo", "test", "text/plain");
EXPECT_EQ("PATCH:test", read_body(patch));
}
// stream::Delete tests
TEST_F(StreamApiTest, Delete) {
httplib::Client cli(HOST, PORT);
auto del1 = httplib::stream::Delete(cli, "/resource");
EXPECT_EQ("Deleted", read_body(del1));
auto del2 = httplib::stream::Delete(cli, "/resource", "data", "text/plain");
EXPECT_EQ("Deleted:data", read_body(del2));
}
// stream::Head/Options tests
TEST_F(StreamApiTest, HeadAndOptions) {
httplib::Client cli(HOST, PORT);
auto head = httplib::stream::Head(cli, "/head-test");
EXPECT_TRUE(head.is_valid());
EXPECT_FALSE(head.get_header_value("Content-Length").empty());
auto opts = httplib::stream::Options(cli, "/options");
EXPECT_EQ("GET, POST, PUT, DELETE, OPTIONS", opts.get_header_value("Allow"));
}
// SSL stream::* tests
#ifdef CPPHTTPLIB_SSL_ENABLED
class SSLStreamApiTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.Get("/hello", [](const httplib::Request &, httplib::Response &res) {
res.set_content("Hello SSL!", "text/plain");
});
svr_.Post("/echo", [](const httplib::Request &req, httplib::Response &res) {
res.set_content(req.body, "text/plain");
});
port_ = svr_.bind_to_any_port("127.0.0.1");
thread_ = std::thread([this]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
httplib::SSLServer svr_{"cert.pem", "key.pem"};
std::thread thread_;
int port_ = 0;
};
TEST_F(SSLStreamApiTest, GetAndPost) {
httplib::SSLClient cli("127.0.0.1", port_);
cli.enable_server_certificate_verification(false);
auto get = httplib::stream::Get(cli, "/hello");
EXPECT_EQ("Hello SSL!", read_body(get));
auto post = httplib::stream::Post(cli, "/echo", "test", "text/plain");
EXPECT_EQ("test", read_body(post));
}
#endif
// Tests for Error::Timeout and Error::ConnectionClosed error types
// These errors are set in SocketStream/SSLSocketStream and propagated through
// BodyReader
TEST(ErrorHandlingTest, StreamReadTimeout) {
// Test that read timeout during streaming is detected
// Use a large content-length response where server delays mid-stream
Server svr;
svr.Get("/slow-stream", [](const Request &, Response &res) {
// Send a large response with delay in the middle
res.set_content_provider(
1000, // content_length
"text/plain", [](size_t offset, size_t /*length*/, DataSink &sink) {
if (offset < 100) {
// Send first 100 bytes immediately
std::string data(100, 'A');
sink.write(data.c_str(), data.size());
return true;
}
// Then delay longer than client timeout
std::this_thread::sleep_for(std::chrono::seconds(3));
std::string data(900, 'B');
sink.write(data.c_str(), data.size());
return true;
});
});
auto port = 8091;
std::thread t([&]() { svr.listen("localhost", port); });
svr.wait_until_ready();
Client cli("localhost", port);
cli.set_read_timeout(1, 0); // 1 second timeout
auto handle = cli.open_stream("GET", "/slow-stream");
ASSERT_TRUE(handle.is_valid());
char buf[256];
ssize_t total = 0;
ssize_t n;
bool got_error = false;
while ((n = handle.read(buf, sizeof(buf))) > 0) {
total += n;
}
if (n < 0) {
got_error = true;
// Should be timeout or read error
EXPECT_TRUE(handle.get_read_error() == Error::Timeout ||
handle.get_read_error() == Error::Read)
<< "Actual error: " << to_string(handle.get_read_error());
}
// Either we got an error, or we got less data than expected
EXPECT_TRUE(got_error || total < 1000)
<< "Expected timeout but got all " << total << " bytes";
svr.stop();
t.join();
}
TEST(ErrorHandlingTest, StreamConnectionClosed) {
// Test connection closed detection via BodyReader
Server svr;
std::atomic<bool> close_now{false};
svr.Get("/will-close", [&](const Request &, Response &res) {
res.set_content_provider(
10000, // Large content_length that we won't fully send
"text/plain", [&](size_t offset, size_t /*length*/, DataSink &sink) {
if (offset < 100) {
std::string data(100, 'X');
sink.write(data.c_str(), data.size());
return true;
}
// Wait for signal then abort
while (!close_now) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return false; // Abort - server will close connection
});
});
auto port = 8092;
std::thread t([&]() { svr.listen("localhost", port); });
svr.wait_until_ready();
Client cli("localhost", port);
auto handle = cli.open_stream("GET", "/will-close");
ASSERT_TRUE(handle.is_valid());
char buf[256];
ssize_t n = handle.read(buf, sizeof(buf)); // First read
EXPECT_GT(n, 0) << "First read should succeed";
// Signal server to close
close_now = true;
// Keep reading until error or EOF
while ((n = handle.read(buf, sizeof(buf))) > 0) {
// Keep reading
}
// Should get an error since content_length wasn't satisfied
if (n < 0) {
EXPECT_TRUE(handle.get_read_error() == Error::ConnectionClosed ||
handle.get_read_error() == Error::Read)
<< "Actual error: " << to_string(handle.get_read_error());
}
svr.stop();
t.join();
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(ErrorHandlingTest, SSLStreamReadTimeout) {
// Test that read timeout during SSL streaming is detected
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
svr.Get("/slow-stream", [](const Request &, Response &res) {
res.set_content_provider(
1000, "text/plain",
[](size_t offset, size_t /*length*/, DataSink &sink) {
if (offset < 100) {
std::string data(100, 'A');
sink.write(data.c_str(), data.size());
return true;
}
std::this_thread::sleep_for(std::chrono::seconds(3));
std::string data(900, 'B');
sink.write(data.c_str(), data.size());
return true;
});
});
auto port = 8093;
std::thread t([&]() { svr.listen("localhost", port); });
svr.wait_until_ready();
SSLClient cli("localhost", port);
cli.enable_server_certificate_verification(false);
cli.set_read_timeout(1, 0); // 1 second timeout
auto handle = cli.open_stream("GET", "/slow-stream");
ASSERT_TRUE(handle.is_valid());
char buf[256];
ssize_t total = 0;
ssize_t n;
bool got_error = false;
while ((n = handle.read(buf, sizeof(buf))) > 0) {
total += n;
}
if (n < 0) {
got_error = true;
EXPECT_TRUE(handle.get_read_error() == Error::Timeout ||
handle.get_read_error() == Error::Read)
<< "Actual error: " << to_string(handle.get_read_error());
}
EXPECT_TRUE(got_error || total < 1000)
<< "Expected timeout but got all " << total << " bytes";
svr.stop();
t.join();
}
TEST(ErrorHandlingTest, SSLStreamConnectionClosed) {
// Test SSL connection closed detection
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
std::atomic<bool> close_now{false};
svr.Get("/will-close", [&](const Request &, Response &res) {
res.set_content_provider(
10000, "text/plain",
[&](size_t offset, size_t /*length*/, DataSink &sink) {
if (offset < 100) {
std::string data(100, 'X');
sink.write(data.c_str(), data.size());
return true;
}
while (!close_now) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return false;
});
});
auto port = 8094;
std::thread t([&]() { svr.listen("localhost", port); });
svr.wait_until_ready();
SSLClient cli("localhost", port);
cli.enable_server_certificate_verification(false);
auto handle = cli.open_stream("GET", "/will-close");
ASSERT_TRUE(handle.is_valid());
char buf[256];
ssize_t n = handle.read(buf, sizeof(buf)); // First read
EXPECT_GT(n, 0);
// Signal server to close
close_now = true;
while ((n = handle.read(buf, sizeof(buf))) > 0) {
// Keep reading
}
if (n < 0) {
EXPECT_TRUE(handle.get_read_error() == Error::ConnectionClosed ||
handle.get_read_error() == Error::Read)
<< "Actual error: " << to_string(handle.get_read_error());
}
svr.stop();
t.join();
}
#endif
TEST(ETagTest, StaticFileETagAndIfNoneMatch) {
using namespace httplib;
// Create a test file
const char *fname = "etag_testfile.txt";
const char *content = "etag-content";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen("localhost", PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// First request: should get 200 with ETag header
auto res1 = cli.Get("/static/etag_testfile.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("ETag"));
std::string etag = res1->get_header_value("ETag");
EXPECT_FALSE(etag.empty());
// Verify ETag format: W/"hex-hex"
ASSERT_GE(etag.length(), 5u); // Minimum: W/""
EXPECT_EQ('W', etag[0]);
EXPECT_EQ('/', etag[1]);
EXPECT_EQ('"', etag[2]);
EXPECT_EQ('"', etag.back());
// Exact match: expect 304 Not Modified
Headers h2 = {{"If-None-Match", etag}};
auto res2 = cli.Get("/static/etag_testfile.txt", h2);
ASSERT_TRUE(res2);
EXPECT_EQ(304, res2->status);
// Wildcard match: expect 304 Not Modified
Headers h3 = {{"If-None-Match", "*"}};
auto res3 = cli.Get("/static/etag_testfile.txt", h3);
ASSERT_TRUE(res3);
EXPECT_EQ(304, res3->status);
// Non-matching ETag: expect 200
Headers h4 = {{"If-None-Match", "W/\"deadbeef\""}};
auto res4 = cli.Get("/static/etag_testfile.txt", h4);
ASSERT_TRUE(res4);
EXPECT_EQ(200, res4->status);
// Multiple ETags with one matching: expect 304
Headers h5 = {{"If-None-Match", "W/\"other\", " + etag + ", W/\"another\""}};
auto res5 = cli.Get("/static/etag_testfile.txt", h5);
ASSERT_TRUE(res5);
EXPECT_EQ(304, res5->status);
// The ETag list split over several field lines: RFC 9110 Section 5.3 makes
// that equivalent to the single comma-separated list above, so the match on
// the second line must still be found.
Headers h6;
h6.emplace("If-None-Match", "W/\"other\"");
h6.emplace("If-None-Match", etag);
auto res6 = cli.Get("/static/etag_testfile.txt", h6);
ASSERT_TRUE(res6);
EXPECT_EQ(304, res6->status);
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, StaticFileETagIfNoneMatchStarNotFound) {
using namespace httplib;
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// Send If-None-Match: * to a non-existent file
Headers h = {{"If-None-Match", "*"}};
auto res = cli.Get("/static/etag_testfile_notfound.txt", h);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(404, res->status);
svr.stop();
t.join();
}
TEST(ETagTest, IfNoneMatchBoundaryCheck) {
using namespace httplib;
// Create a test file
const char *fname = "etag_boundary_testfile.txt";
const char *content = "boundary-test";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen("localhost", PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// Get the actual ETag
auto res1 = cli.Get("/static/etag_boundary_testfile.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("ETag"));
std::string etag = res1->get_header_value("ETag");
// Test 1: Very long ETag value (longer than actual ETag)
// Should NOT match and return 200 (not trigger out-of-bounds read)
Headers h1 = {{"If-None-Match", "W/"
"\"very-long-etag-value-that-is-much-longer-"
"than-the-actual-etag-value\""}};
auto res2 = cli.Get("/static/etag_boundary_testfile.txt", h1);
ASSERT_TRUE(res2);
EXPECT_EQ(200, res2->status); // Should not match
// Test 2: Long string followed by wildcard
// Should match on "*" and return 304 (without out-of-bounds read on the long
// string)
Headers h2 = {{"If-None-Match", "W/\"another-very-long-value\", *"}};
auto res3 = cli.Get("/static/etag_boundary_testfile.txt", h2);
ASSERT_TRUE(res3);
EXPECT_EQ(304, res3->status); // Should match on "*"
// Test 3: Wildcard followed by long string
// Should match on "*" immediately and return 304
Headers h3 = {{"If-None-Match", "*, W/\"long-value-after-wildcard\""}};
auto res4 = cli.Get("/static/etag_boundary_testfile.txt", h3);
ASSERT_TRUE(res4);
EXPECT_EQ(304, res4->status); // Should match on "*"
// Test 4: Multiple long non-matching values
// Should NOT match and return 200 (test that all comparisons are safe)
Headers h4 = {{"If-None-Match", "W/\"first-long-non-matching-value\", "
"W/\"second-long-non-matching-value\", "
"W/\"third-long-non-matching-value\""}};
auto res5 = cli.Get("/static/etag_boundary_testfile.txt", h4);
ASSERT_TRUE(res5);
EXPECT_EQ(200, res5->status); // Should not match
// Test 5: Single character that is not "*" (edge case)
Headers h5 = {{"If-None-Match", "X"}};
auto res6 = cli.Get("/static/etag_boundary_testfile.txt", h5);
ASSERT_TRUE(res6);
EXPECT_EQ(200, res6->status); // Should not match
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, LastModifiedAndIfModifiedSince) {
using namespace httplib;
// Create a test file
const char *fname = "ims_testfile.txt";
const char *content = "if-modified-since-test";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// First request: should get 200 with Last-Modified header
auto res1 = cli.Get("/static/ims_testfile.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("Last-Modified"));
std::string last_modified = res1->get_header_value("Last-Modified");
EXPECT_FALSE(last_modified.empty());
// If-Modified-Since with same time: expect 304
Headers h2 = {{"If-Modified-Since", last_modified}};
auto res2 = cli.Get("/static/ims_testfile.txt", h2);
ASSERT_TRUE(res2);
EXPECT_EQ(304, res2->status);
// If-Modified-Since with future time: expect 304
Headers h3 = {{"If-Modified-Since", "Sun, 01 Jan 2099 00:00:00 GMT"}};
auto res3 = cli.Get("/static/ims_testfile.txt", h3);
ASSERT_TRUE(res3);
EXPECT_EQ(304, res3->status);
// If-Modified-Since with past time: expect 200
Headers h4 = {{"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}};
auto res4 = cli.Get("/static/ims_testfile.txt", h4);
ASSERT_TRUE(res4);
EXPECT_EQ(200, res4->status);
// If-None-Match takes precedence over If-Modified-Since
// (send matching ETag with old If-Modified-Since -> should still be 304)
ASSERT_TRUE(res1->has_header("ETag"));
std::string etag = res1->get_header_value("ETag");
Headers h5 = {{"If-None-Match", etag},
{"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}};
auto res5 = cli.Get("/static/ims_testfile.txt", h5);
ASSERT_TRUE(res5);
EXPECT_EQ(304, res5->status);
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, VaryAcceptEncodingWithCompression) {
using namespace httplib;
Server svr;
// Endpoint that returns compressible content
svr.Get("/compressible", [](const Request &, Response &res) {
// Return a large enough body to trigger compression
std::string body(1000, 'a');
res.set_content(body, "text/plain");
});
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// Request with gzip support: should get Vary header when compressed
cli.set_compress(true);
auto res1 = cli.Get("/compressible");
ASSERT_TRUE(res1);
EXPECT_EQ(200, res1->status);
// If Content-Encoding is set, Vary should also be set
if (res1->has_header("Content-Encoding")) {
EXPECT_TRUE(res1->has_header("Vary"));
EXPECT_EQ("Accept-Encoding", res1->get_header_value("Vary"));
}
// Request without Accept-Encoding header: should not have compression
Headers h_no_compress;
auto res2 = cli.Get("/compressible", h_no_compress);
ASSERT_TRUE(res2);
EXPECT_EQ(200, res2->status);
// Verify Vary header is present when compression is applied
// (the exact behavior depends on server configuration)
svr.stop();
t.join();
}
TEST(ETagTest, IfRangeWithETag) {
using namespace httplib;
// Create a test file with known content
const char *fname = "if_range_testfile.txt";
const std::string content = "0123456789ABCDEFGHIJ"; // 20 bytes
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// First request: get ETag
auto res1 = cli.Get("/static/if_range_testfile.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("ETag"));
std::string etag = res1->get_header_value("ETag");
// RFC 9110 Section 13.1.5: If-Range requires strong ETag comparison.
// Since our server generates weak ETags (W/"..."), If-Range with our
// ETag should NOT result in partial content - it should return full content.
Headers h2 = {{"Range", "bytes=0-4"}, {"If-Range", etag}};
auto res2 = cli.Get("/static/if_range_testfile.txt", h2);
ASSERT_TRUE(res2);
// Weak ETag in If-Range -> full content (200), not partial (206)
EXPECT_EQ(200, res2->status);
EXPECT_EQ(content, res2->body);
EXPECT_FALSE(res2->has_header("Content-Range"));
// Range request with non-matching If-Range (ETag): should get 200 (full
// content)
Headers h3 = {{"Range", "bytes=0-4"}, {"If-Range", "W/\"wrong-etag\""}};
auto res3 = cli.Get("/static/if_range_testfile.txt", h3);
ASSERT_TRUE(res3);
EXPECT_EQ(200, res3->status);
EXPECT_EQ(content, res3->body);
EXPECT_FALSE(res3->has_header("Content-Range"));
// Range request with strong ETag (hypothetical - our server doesn't generate
// strong ETags, but if client sends a strong ETag that doesn't match, it
// should return full content)
Headers h4 = {{"Range", "bytes=0-4"}, {"If-Range", "\"strong-etag\""}};
auto res4 = cli.Get("/static/if_range_testfile.txt", h4);
ASSERT_TRUE(res4);
EXPECT_EQ(200, res4->status);
EXPECT_EQ(content, res4->body);
EXPECT_FALSE(res4->has_header("Content-Range"));
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, IfRangeWithDate) {
using namespace httplib;
// Create a test file
const char *fname = "if_range_date_testfile.txt";
const std::string content = "ABCDEFGHIJ0123456789"; // 20 bytes
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// First request: get Last-Modified
auto res1 = cli.Get("/static/if_range_date_testfile.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("Last-Modified"));
std::string last_modified = res1->get_header_value("Last-Modified");
// Range request with matching If-Range (date): should get 206
Headers h2 = {{"Range", "bytes=5-9"}, {"If-Range", last_modified}};
auto res2 = cli.Get("/static/if_range_date_testfile.txt", h2);
ASSERT_TRUE(res2);
EXPECT_EQ(206, res2->status);
EXPECT_EQ("FGHIJ", res2->body);
// Range request with old If-Range date: should get 200 (full content)
Headers h3 = {{"Range", "bytes=5-9"},
{"If-Range", "Sun, 01 Jan 2000 00:00:00 GMT"}};
auto res3 = cli.Get("/static/if_range_date_testfile.txt", h3);
ASSERT_TRUE(res3);
EXPECT_EQ(200, res3->status);
EXPECT_EQ(content, res3->body);
// Range request with future If-Range date: should get 206
Headers h4 = {{"Range", "bytes=0-4"},
{"If-Range", "Sun, 01 Jan 2099 00:00:00 GMT"}};
auto res4 = cli.Get("/static/if_range_date_testfile.txt", h4);
ASSERT_TRUE(res4);
EXPECT_EQ(206, res4->status);
EXPECT_EQ("ABCDE", res4->body);
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, MalformedIfNoneMatchAndWhitespace) {
using namespace httplib;
const char *fname = "etag_malformed.txt";
const char *content = "malformed-etag";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// baseline: should get 200 and an ETag
auto res1 = cli.Get("/static/etag_malformed.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("ETag"));
// Malformed ETag value (missing quotes) should be treated as non-matching
Headers h_bad = {{"If-None-Match", "W/noquotes"}};
auto res_bad = cli.Get("/static/etag_malformed.txt", h_bad);
ASSERT_TRUE(res_bad);
EXPECT_EQ(200, res_bad->status);
// Whitespace-only header value should be considered invalid / non-matching
std::string raw_req = "GET /static/etag_malformed.txt HTTP/1.1\r\n"
"Host: localhost\r\n"
"If-None-Match: \r\n"
"Connection: close\r\n"
"\r\n";
std::string out;
ASSERT_TRUE(send_request(5, raw_req, &out));
EXPECT_EQ("HTTP/1.1 200 OK", out.substr(0, 15));
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, InvalidIfModifiedSinceAndIfRangeDate) {
using namespace httplib;
const char *fname = "ims_invalid_format.txt";
const char *content = "ims-bad-format";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res1 = cli.Get("/static/ims_invalid_format.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("Last-Modified"));
// If-Modified-Since with invalid format should not result in 304
Headers h_bad_date = {{"If-Modified-Since", "not-a-valid-date"}};
auto res_bad = cli.Get("/static/ims_invalid_format.txt", h_bad_date);
ASSERT_TRUE(res_bad);
EXPECT_EQ(200, res_bad->status);
// If-Range with invalid date format should be treated as mismatch -> full
// content (200)
Headers h_ifrange_bad = {{"Range", "bytes=0-3"},
{"If-Range", "invalid-date"}};
auto res_ifrange = cli.Get("/static/ims_invalid_format.txt", h_ifrange_bad);
ASSERT_TRUE(res_ifrange);
EXPECT_EQ(200, res_ifrange->status);
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, IfRangeWithMalformedETag) {
using namespace httplib;
const char *fname = "ifrange_malformed.txt";
const std::string content = "0123456789";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
// First request: get ETag
auto res1 = cli.Get("/static/ifrange_malformed.txt");
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("ETag"));
// If-Range with malformed ETag (no quotes) should be treated as mismatch ->
// full content (200)
Headers h_malformed = {{"Range", "bytes=0-4"}, {"If-Range", "W/noquotes"}};
auto res2 = cli.Get("/static/ifrange_malformed.txt", h_malformed);
ASSERT_TRUE(res2);
EXPECT_EQ(200, res2->status);
EXPECT_EQ(content, res2->body);
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, ExtremeLargeDateValues) {
using namespace httplib;
const char *fname = "ims_extreme_date.txt";
const char *content = "ims-extreme-date";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res1 = cli.Get(std::string("/static/") + fname);
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
ASSERT_TRUE(res1->has_header("Last-Modified"));
// Extremely large year that may overflow date parsing routines.
Headers h_large_date = {
{"If-Modified-Since", "Sun, 01 Jan 99999 00:00:00 GMT"}};
auto res_bad = cli.Get(std::string("/static/") + fname, h_large_date);
ASSERT_TRUE(res_bad);
// Expect server to treat this as invalid/mismatch and return full content
EXPECT_EQ(200, res_bad->status);
// If-Range with extremely large date should be treated as mismatch -> full
// content (200)
Headers h_ifrange_large = {{"Range", "bytes=0-3"},
{"If-Range", "Sun, 01 Jan 99999 00:00:00 GMT"}};
auto res_ifrange = cli.Get(std::string("/static/") + fname, h_ifrange_large);
ASSERT_TRUE(res_ifrange);
EXPECT_EQ(200, res_ifrange->status);
svr.stop();
t.join();
std::remove(fname);
}
TEST(ETagTest, NegativeFileModificationTime) {
using namespace httplib;
const char *fname = "ims_negative_mtime.txt";
const std::string content = "negative-mtime";
{
std::ofstream ofs(fname);
ofs << content;
ASSERT_TRUE(ofs.good());
}
// Try to set file mtime to a negative value. This may fail on some
// platforms/filesystems; if it fails, the test will still verify server
// behaves safely by performing a regular conditional request.
#if defined(__APPLE__) || defined(__linux__)
bool set_negative = false;
do {
struct timeval times[2];
// access time: now
times[0].tv_sec = time(nullptr);
times[0].tv_usec = 0;
// modification time: negative (e.g., -1)
times[1].tv_sec = -1;
times[1].tv_usec = 0;
if (utimes(fname, times) == 0) { set_negative = true; }
} while (0);
#else
bool set_negative = false;
#endif
Server svr;
svr.set_mount_point("/static", ".");
auto t = std::thread([&]() { svr.listen(HOST, PORT); });
svr.wait_until_ready();
Client cli(HOST, PORT);
auto res1 = cli.Get(std::string("/static/") + fname);
ASSERT_TRUE(res1);
ASSERT_EQ(200, res1->status);
bool has_last_modified = res1->has_header("Last-Modified");
std::string last_modified;
if (has_last_modified) {
last_modified = res1->get_header_value("Last-Modified");
}
if (set_negative) {
// If we successfully set a negative mtime, ensure server returns a
// Last-Modified string (may be empty or normalized). Send If-Modified-Since
// with an old date and ensure server handles it without crash.
Headers h_old = {{"If-Modified-Since", "Sun, 01 Jan 1970 00:00:00 GMT"}};
auto res2 = cli.Get(std::string("/static/") + fname, h_old);
ASSERT_TRUE(res2);
// Behavior may vary; at minimum ensure server responds (200 or 304).
EXPECT_TRUE(res2->status == 200 || res2->status == 304);
} else {
// Could not set negative mtime on this platform; fall back to verifying
// that normal invalid/malformed dates are treated safely (non-304).
Headers h_bad_date = {
{"If-Modified-Since", "Sun, 01 Jan 99999 00:00:00 GMT"}};
auto res_bad = cli.Get(std::string("/static/") + fname, h_bad_date);
ASSERT_TRUE(res_bad);
EXPECT_EQ(200, res_bad->status);
}
svr.stop();
t.join();
std::remove(fname);
}
//==============================================================================
// Integration Tests with Server
//==============================================================================
class SSEIntegrationTest : public ::testing::Test {
protected:
void SetUp() override {
stop_server_.store(false);
events_.clear();
server_ = httplib::detail::make_unique<Server>();
setup_server();
start_server();
}
void TearDown() override {
stop_server_.store(true);
event_cv_.notify_all();
server_->stop();
if (server_thread_.joinable()) { server_thread_.join(); }
}
void setup_server() {
// Simple SSE endpoint
server_->Get("/events", [this](const Request &req, Response &res) {
auto last_id = req.get_header_value("Last-Event-ID");
if (!last_id.empty()) { last_received_event_id_ = last_id; }
res.set_chunked_content_provider(
"text/event-stream", [this](size_t /*offset*/, DataSink &sink) {
std::unique_lock<std::mutex> lock(event_mutex_);
if (event_cv_.wait_for(
lock, std::chrono::milliseconds(200), [this] {
return !events_.empty() || stop_server_.load();
})) {
if (stop_server_.load()) { return false; }
if (!events_.empty()) {
std::string event = events_.front();
events_.erase(events_.begin());
sink.write(event.data(), event.size());
return true;
}
}
return !stop_server_.load();
});
});
// Endpoint that returns error
server_->Get("/error-endpoint", [](const Request &, Response &res) {
res.status = 500;
res.set_content("Internal Server Error", "text/plain");
});
// Endpoint for custom event types
server_->Get("/custom-events", [](const Request &, Response &res) {
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "event: update\ndata: updated\n\n"
"event: delete\ndata: deleted\n\n";
sink.write(event.data(), event.size());
}
return false; // End stream after sending
});
});
}
void start_server() {
port_ = server_->bind_to_any_port(HOST);
server_thread_ = std::thread([this]() { server_->listen_after_bind(); });
// Wait for server to start
while (!server_->is_running()) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
int get_port() const { return port_; }
void send_event(const std::string &event) {
std::lock_guard<std::mutex> lock(event_mutex_);
events_.push_back(event);
event_cv_.notify_all();
}
std::unique_ptr<Server> server_;
std::thread server_thread_;
std::mutex event_mutex_;
std::condition_variable event_cv_;
std::vector<std::string> events_;
std::atomic<bool> stop_server_{false};
std::string last_received_event_id_;
int port_ = 0;
};
//==============================================================================
// SSE Parsing Tests
//==============================================================================
class SSEParsingTest : public SSEIntegrationTest {
protected:
// Feeds a raw event stream to SSEClient and returns the dispatched messages.
// A trailing sentinel event tells when the whole stream has been parsed.
std::vector<sse::SSEMessage> parse(const std::string &stream) {
auto body = stream + "event: end\ndata: end\n\n";
server_->Get("/parse", [body](const Request &, Response &res) {
res.set_content(body, "text/event-stream");
});
return collect("/parse");
}
// Runs SSEClient against path until an "end" event arrives and returns the
// messages dispatched before it
std::vector<sse::SSEMessage> collect(const std::string &path) {
Client client(HOST, get_port());
sse::SSEClient sse(client, path);
std::mutex mutex;
std::condition_variable cv;
std::vector<sse::SSEMessage> messages;
auto done = false;
sse.on_message([&](const sse::SSEMessage &msg) {
std::lock_guard<std::mutex> lock(mutex);
// Ignore a replay from a reconnect that races with stop()
if (!done) { messages.push_back(msg); }
});
sse.on_event("end", [&](const sse::SSEMessage &) {
std::lock_guard<std::mutex> lock(mutex);
done = true;
cv.notify_all();
});
sse.set_reconnect_interval(100);
sse.start_async();
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait_for(lock, std::chrono::seconds(5), [&] { return done; });
}
sse.stop();
EXPECT_TRUE(done);
return messages;
}
};
TEST_F(SSEParsingTest, SingleLineData) {
auto msgs = parse("data: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[0].event, "message");
}
TEST_F(SSEParsingTest, MultiLineData) {
auto msgs = parse("data: line1\ndata: line2\ndata: line3\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "line1\nline2\nline3");
}
TEST_F(SSEParsingTest, CustomEventType) {
auto msgs = parse("event: update\ndata: payload\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "payload");
}
TEST_F(SSEParsingTest, EventIdHandling) {
auto msgs = parse("id: 12345\ndata: test\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].id, "12345");
}
TEST_F(SSEParsingTest, EmptyEventId) {
auto msgs = parse("id:\ndata: test\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].id, "");
}
TEST_F(SSEParsingTest, CommentsIgnored) {
auto msgs = parse(": this is a comment\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[0].event, "message");
}
TEST_F(SSEParsingTest, ColonInValue) {
auto msgs = parse("data: hello:world:test\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello:world:test");
}
TEST_F(SSEParsingTest, FieldNameOnly) {
// A line without a colon is a field name with an empty value
auto msgs = parse("data\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "");
}
TEST_F(SSEParsingTest, TrailingCarriageReturn) {
auto msgs = parse("event: update\r\ndata: hello\r\n\r\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, FieldNameOnlyWithCarriageReturn) {
auto msgs = parse("data\r\n\r\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "");
}
TEST_F(SSEParsingTest, CarriageReturnLineEnding) {
auto msgs = parse("event: update\rdata: hello\r\r");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, LineEndingSplitAcrossChunks) {
// Chunk boundaries fall inside every \r\n and right before the first one
std::vector<std::string> chunks = {"event: update", "\r", "\ndata: hello\r",
"\n\r", "\nevent: end\ndata: end\n\n"};
server_->Get("/split", [chunks](const Request &, Response &res) {
auto i = std::make_shared<size_t>(0);
res.set_chunked_content_provider(
"text/event-stream", [chunks, i](size_t /*offset*/, DataSink &sink) {
if (*i == chunks.size()) {
sink.done();
} else {
sink.write(chunks[*i].data(), chunks[*i].size());
(*i)++;
}
return true;
});
});
auto msgs = collect("/split");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, LeadingByteOrderMarkIgnored) {
// Only a BOM at the start of the stream is ignored
auto msgs = parse("\xEF\xBB\xBF"
"data: first\n\n"
"\xEF\xBB\xBF"
"data: second\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "first");
}
TEST_F(SSEParsingTest, UnknownFieldsIgnored) {
auto msgs = parse("unknown: value\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[0].event, "message");
}
TEST_F(SSEParsingTest, SpaceAfterColonOptional) {
auto msgs = parse("data: hello\n\ndata:hello\n\n");
ASSERT_EQ(msgs.size(), 2u);
EXPECT_EQ(msgs[0].data, "hello");
EXPECT_EQ(msgs[1].data, "hello");
}
TEST_F(SSEParsingTest, EventWithoutDataResetsEventType) {
// An event without data is not dispatched, and its type must not leak
// into the next event
auto msgs = parse("event: update\n\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "message");
EXPECT_EQ(msgs[0].data, "hello");
}
TEST_F(SSEParsingTest, EventWithoutDataUpdatesLastEventId) {
// The first connection sends only an id; the second echoes back the
// Last-Event-ID it was reconnected with
std::atomic<int> connection_count{0};
server_->Get("/id-only", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content("id: 42\n\n", "text/event-stream");
} else {
res.set_content("data: " + req.get_header_value("Last-Event-ID") +
"\n\nevent: end\ndata: end\n\n",
"text/event-stream");
}
});
auto msgs = collect("/id-only");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "42");
}
TEST_F(SSEParsingTest, EmptyEventIdClearsLastEventId) {
// The first connection sets an id and then clears it with an empty one; the
// second reports whether it was reconnected with a Last-Event-ID
std::atomic<int> connection_count{0};
server_->Get("/id-clear", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content("id: 1\ndata: first\n\nid:\ndata: second\n\n",
"text/event-stream");
} else {
res.set_content(
std::string("data: ") +
(req.has_header("Last-Event-ID") ? "sent" : "not sent") +
"\n\nevent: end\ndata: end\n\n",
"text/event-stream");
}
});
auto msgs = collect("/id-clear");
ASSERT_EQ(msgs.size(), 3u);
EXPECT_EQ(msgs[0].id, "1");
EXPECT_EQ(msgs[1].id, "");
EXPECT_EQ(msgs[2].data, "not sent");
}
TEST_F(SSEParsingTest, UnsendableEventIdKeepsLastEventId) {
// The first connection sets an id and then sends ids that cannot be carried
// in a header; the second echoes back the Last-Event-ID it was reconnected
// with
std::atomic<int> connection_count{0};
server_->Get("/id-unsendable", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content(std::string("id: 42\n\nid: a") + '\0' +
"b\n\nid: \x01\n\nid: 7\n\nid: 7 \n\n",
"text/event-stream");
} else {
res.set_content("data: " + req.get_header_value("Last-Event-ID") +
"\n\nevent: end\ndata: end\n\n",
"text/event-stream");
}
});
auto msgs = collect("/id-unsendable");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "42");
}
TEST_F(SSEParsingTest, CompleteEventParsing) {
auto msgs = parse("event: notification\nid: evt-42\n"
"data: {\"type\":\"alert\"}\nretry: 1000\n\n");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "notification");
EXPECT_EQ(msgs[0].id, "evt-42");
EXPECT_EQ(msgs[0].data, "{\"type\":\"alert\"}");
}
TEST(SSEMessageTest, Clear) {
sse::SSEMessage msg;
msg.event = "custom";
msg.data = "some data";
msg.id = "123";
msg.clear();
EXPECT_EQ(msg.event, "message");
EXPECT_EQ(msg.data, "");
EXPECT_EQ(msg.id, "");
}
//==============================================================================
// SSE Integration Tests
//==============================================================================
// Test: Successful connection and on_open callback
TEST_F(SSEIntegrationTest, SuccessfulConnection) {
// Add a simple endpoint that sends one event and closes
server_->Get("/simple-event", [](const Request &, Response &res) {
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "data: hello\n\n";
sink.write(event.data(), event.size());
}
return false; // Close stream after sending
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/simple-event");
std::atomic<bool> open_called{false};
std::atomic<bool> message_received{false};
sse.on_open([&open_called]() { open_called.store(true); });
sse.on_message([&message_received](const sse::SSEMessage &msg) {
if (msg.data == "hello") { message_received.store(true); }
});
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(1);
// Start async
sse.start_async();
// Wait for message to be processed
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
EXPECT_TRUE(open_called.load());
EXPECT_TRUE(message_received.load());
}
// Test: on_message callback
TEST_F(SSEIntegrationTest, OnMessageCallback) {
// Endpoint that sends multiple events then closes
server_->Get("/multi-event", [](const Request &, Response &res) {
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string events = "data: message1\n\ndata: message2\n\n";
sink.write(events.data(), events.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/multi-event");
std::vector<std::string> received_messages;
std::mutex messages_mutex;
sse.on_message([&](const sse::SSEMessage &msg) {
std::lock_guard<std::mutex> lock(messages_mutex);
received_messages.push_back(msg.data);
});
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(1);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
std::lock_guard<std::mutex> lock(messages_mutex);
EXPECT_GE(received_messages.size(), 2u);
if (received_messages.size() >= 2) {
EXPECT_EQ(received_messages[0], "message1");
EXPECT_EQ(received_messages[1], "message2");
}
}
// Test: on_event for specific types
TEST_F(SSEIntegrationTest, OnEventForSpecificTypes) {
Client client("localhost", get_port());
sse::SSEClient sse(client, "/custom-events");
std::atomic<bool> update_received{false};
std::atomic<bool> delete_received{false};
sse.on_event("update", [&update_received](const sse::SSEMessage &msg) {
if (msg.data == "updated") { update_received.store(true); }
});
sse.on_event("delete", [&delete_received](const sse::SSEMessage &msg) {
if (msg.data == "deleted") { delete_received.store(true); }
});
sse.set_max_reconnect_attempts(1);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(300));
sse.stop();
EXPECT_TRUE(update_received.load());
EXPECT_TRUE(delete_received.load());
}
// Test: on_error callback on connection failure
TEST_F(SSEIntegrationTest, OnErrorCallback) {
// Connect to a non-existent port
Client client("localhost", 59999);
sse::SSEClient sse(client, "/events");
std::atomic<bool> error_called{false};
Error received_error = Error::Success;
sse.on_error([&](Error err) {
error_called.store(true);
received_error = err;
});
sse.set_reconnect_interval(50);
sse.set_max_reconnect_attempts(1);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
sse.stop();
EXPECT_TRUE(error_called.load());
EXPECT_NE(received_error, Error::Success);
}
// Test: Last-Event-ID header sent on reconnect
TEST_F(SSEIntegrationTest, LastEventIdHeader) {
// Endpoint that sends event with ID
server_->Get("/event-with-id", [](const Request &, Response &res) {
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "id: evt-123\ndata: test\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/event-with-id");
std::atomic<bool> id_received{false};
sse.on_message([&](const sse::SSEMessage &msg) {
if (!msg.id.empty()) { id_received.store(true); }
});
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(1);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
EXPECT_TRUE(id_received.load());
EXPECT_EQ(sse.last_event_id(), "evt-123");
}
// Test: Manual stop
TEST_F(SSEIntegrationTest, ManualStop) {
// Endpoint that sends one event and stays open briefly
std::atomic<bool> handler_running{true};
server_->Get("/stay-open", [&handler_running](const Request &,
Response &res) {
res.set_chunked_content_provider(
"text/event-stream", [&handler_running](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "data: connected\n\n";
sink.write(event.data(), event.size());
}
// Keep connection open while handler_running is true
for (int i = 0; i < 10 && handler_running.load(); ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/stay-open");
std::atomic<bool> connected{false};
sse.on_open([&connected]() { connected.store(true); });
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(1);
sse.start_async();
// Wait for connection to establish
for (int i = 0; i < 20 && !connected.load(); ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
EXPECT_TRUE(connected.load());
EXPECT_TRUE(sse.is_connected());
// Signal handler to stop
handler_running.store(false);
// Stop SSE client
sse.stop();
EXPECT_FALSE(sse.is_connected());
}
// Test: SSEClient with custom headers
TEST_F(SSEIntegrationTest, CustomHeaders) {
// Setup a server endpoint that checks for custom header
std::atomic<bool> header_received{false};
server_->Get("/header-check", [&](const Request &req, Response &res) {
if (req.get_header_value("X-Custom-Header") == "custom-value") {
header_received.store(true);
}
res.set_chunked_content_provider("text/event-stream",
[](size_t, DataSink &) { return false; });
});
Client client("localhost", get_port());
Headers headers = {{"X-Custom-Header", "custom-value"}};
sse::SSEClient sse(client, "/header-check", headers);
sse.set_max_reconnect_attempts(1);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
sse.stop();
EXPECT_TRUE(header_received.load());
}
// Test: Reconnect interval configuration
TEST_F(SSEIntegrationTest, ReconnectIntervalConfiguration) {
Client client("localhost", get_port());
sse::SSEClient sse(client, "/events");
auto &result = sse.set_reconnect_interval(500);
// Builder pattern should return reference to self
EXPECT_EQ(&result, &sse);
}
// Test: Max reconnect attempts
TEST_F(SSEIntegrationTest, MaxReconnectAttempts) {
// Connect to non-existent port to force reconnects
Client client("localhost", 59998);
sse::SSEClient sse(client, "/events");
std::atomic<int> error_count{0};
sse.on_error([&](Error) { error_count.fetch_add(1); });
sse.set_reconnect_interval(50);
sse.set_max_reconnect_attempts(2);
auto start = std::chrono::steady_clock::now();
sse.start(); // Blocking call
auto end = std::chrono::steady_clock::now();
// Should have stopped after 2 failed attempts
EXPECT_GE(error_count.load(), 2);
// Should not have taken too long (max 2 attempts * 50ms + overhead)
auto duration =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
#ifdef _WIN32
// Windows is much slower for socket connection failures
EXPECT_LT(duration.count(), 7000);
#else
EXPECT_LT(duration.count(), 1000);
#endif
}
// Test: Multi-line data in integration
TEST_F(SSEIntegrationTest, MultiLineDataIntegration) {
// Endpoint with multi-line data
server_->Get("/multiline-data", [](const Request &, Response &res) {
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "data: line1\ndata: line2\ndata: line3\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/multiline-data");
std::string received_data;
std::mutex data_mutex;
sse.on_message([&](const sse::SSEMessage &msg) {
std::lock_guard<std::mutex> lock(data_mutex);
received_data = msg.data;
});
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(1);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
std::lock_guard<std::mutex> lock(data_mutex);
EXPECT_EQ(received_data, "line1\nline2\nline3");
}
TEST_F(SSEIntegrationTest, EmptyDataLines) {
server_->Get("/empty-data-lines", [](const Request &, Response &res) {
res.set_chunked_content_provider("text/event-stream", [](size_t offset,
DataSink &sink) {
if (offset == 0) {
const std::string events = "data:\n\ndata:\ndata: hello\n\ndata\n\n";
sink.write(events.data(), events.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/empty-data-lines");
std::mutex mutex;
std::condition_variable cv;
std::vector<std::string> received;
sse.on_message([&](const sse::SSEMessage &msg) {
std::lock_guard<std::mutex> lock(mutex);
received.push_back(msg.data);
cv.notify_all();
});
sse.set_max_reconnect_attempts(1);
sse.start_async();
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait_for(lock, std::chrono::seconds(2),
[&] { return received.size() >= 3; });
}
sse.stop();
ASSERT_GE(received.size(), 3u);
EXPECT_EQ(received[0], "");
EXPECT_EQ(received[1], "\nhello");
EXPECT_EQ(received[2], "");
}
// Test: Auto-reconnect after server disconnection
TEST_F(SSEIntegrationTest, AutoReconnectAfterDisconnect) {
std::atomic<int> connection_count{0};
std::atomic<int> message_count{0};
// Endpoint that sends one event and closes, forcing reconnect
server_->Get("/reconnect-test",
[&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 = "data: hello\n\n";
sink.write(event.data(), event.size());
}
return false; // Close connection after sending
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/reconnect-test");
sse.on_message([&message_count](const sse::SSEMessage &) {
message_count.fetch_add(1);
});
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(3);
sse.start_async();
// Wait long enough for multiple reconnects
std::this_thread::sleep_for(std::chrono::milliseconds(800));
sse.stop();
// Should have connected multiple times (initial + reconnects)
EXPECT_GE(connection_count.load(), 2);
// Should have received messages from multiple connections
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};
std::vector<std::string> received_last_event_ids;
std::mutex id_mutex;
// Endpoint that checks Last-Event-ID header and sends event with ID
server_->Get("/reconnect-with-id", [&](const Request &req, Response &res) {
int conn = connection_count.fetch_add(1);
// Capture the Last-Event-ID header from each connection
{
std::lock_guard<std::mutex> lock(id_mutex);
received_last_event_ids.push_back(req.get_header_value("Last-Event-ID"));
}
res.set_chunked_content_provider(
"text/event-stream", [conn](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event =
"id: event-" + std::to_string(conn) + "\ndata: msg\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/reconnect-with-id");
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(3);
sse.start_async();
// Wait for at least 2 connections
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
// Verify behavior
std::lock_guard<std::mutex> lock(id_mutex);
EXPECT_GE(received_last_event_ids.size(), 2u);
// First connection should have no Last-Event-ID
if (!received_last_event_ids.empty()) {
EXPECT_EQ(received_last_event_ids[0], "");
}
// Second connection should have Last-Event-ID from first connection
if (received_last_event_ids.size() >= 2) {
EXPECT_EQ(received_last_event_ids[1], "event-0");
}
}
// Test: set_headers updates headers used on reconnect
TEST_F(SSEIntegrationTest, SetHeadersUpdatesOnReconnect) {
std::vector<std::string> received_tokens;
std::mutex token_mutex;
// Endpoint that captures Authorization header
server_->Get("/auth-check", [&](const Request &req, Response &res) {
{
std::lock_guard<std::mutex> lock(token_mutex);
received_tokens.push_back(req.get_header_value("Authorization"));
}
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "data: hello\n\n";
sink.write(event.data(), event.size());
}
return false; // Close connection to trigger reconnect
});
});
Client client("localhost", get_port());
Headers headers = {{"Authorization", "Bearer old-token"}};
sse::SSEClient sse(client, "/auth-check", headers);
// Update headers on each successful connection
sse.on_open(
[&sse]() { sse.set_headers({{"Authorization", "Bearer new-token"}}); });
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(3);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(800));
sse.stop();
std::lock_guard<std::mutex> lock(token_mutex);
ASSERT_GE(received_tokens.size(), 2u);
// First connection uses original header
EXPECT_EQ(received_tokens[0], "Bearer old-token");
// Second connection uses updated header from set_headers
EXPECT_EQ(received_tokens[1], "Bearer new-token");
}
// Test: 401 allows reconnection (so on_error can refresh headers)
TEST_F(SSEIntegrationTest, ReconnectOn401WithHeaderRefresh) {
std::atomic<int> connection_count{0};
// Endpoint: returns 401 on first attempt, 200 on second
server_->Get("/auth-retry", [&](const Request &req, Response &res) {
int conn = connection_count.fetch_add(1);
if (conn == 0 || req.get_header_value("Authorization") != "Bearer valid") {
res.status = StatusCode::Unauthorized_401;
res.set_content("Unauthorized", "text/plain");
return;
}
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "data: authenticated\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
Headers headers = {{"Authorization", "Bearer expired"}};
sse::SSEClient sse(client, "/auth-retry", headers);
std::atomic<bool> message_received{false};
// Refresh token on error
sse.on_error(
[&sse](Error) { sse.set_headers({{"Authorization", "Bearer valid"}}); });
sse.on_message([&](const sse::SSEMessage &msg) {
if (msg.data == "authenticated") { message_received.store(true); }
});
sse.set_reconnect_interval(100);
sse.set_max_reconnect_attempts(3);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(800));
sse.stop();
// Should have reconnected after 401 and succeeded with new token
EXPECT_GE(connection_count.load(), 2);
EXPECT_TRUE(message_received.load());
}
TEST(Issue2318Test, EmptyHostString) {
{
httplib::Client cli_empty("", PORT);
auto res = cli_empty.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(httplib::Error::Connection, res.error());
}
{
httplib::Client cli(" ", PORT);
auto res = cli.Get("/");
ASSERT_FALSE(res);
EXPECT_EQ(httplib::Error::Connection, res.error());
}
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST(ZipBombProtectionTest, DecompressedSizeExceedsLimit) {
Server svr;
// Set a small payload limit (1KB)
svr.set_payload_max_length(1024);
svr.Post("/test", [&](const Request &req, Response &res) {
res.set_content("Body size: " + std::to_string(req.body.size()),
"text/plain");
});
auto listen_thread = std::thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
});
svr.wait_until_ready();
// Create data that compresses well but exceeds limit when decompressed
// 8KB of repeated null bytes compresses to a very small size
std::string original_data(8 * 1024, '\0');
// Compress the data using gzip
std::string compressed_data;
detail::gzip_compressor compressor;
compressor.compress(original_data.data(), original_data.size(), true,
[&](const char *data, size_t size) {
compressed_data.append(data, size);
return true;
});
// Verify compression worked (compressed should be much smaller)
ASSERT_LT(compressed_data.size(), original_data.size());
ASSERT_LT(compressed_data.size(), 1024u); // Compressed fits in limit
// Send compressed data with Content-Encoding: gzip
Client cli(HOST, PORT);
Headers headers = {{"Content-Encoding", "gzip"}};
auto res =
cli.Post("/test", headers, compressed_data, "application/octet-stream");
// Server should reject because decompressed size (8KB) exceeds limit (1KB)
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
}
#endif
// ============================================================================
// OpenSSL-Specific Tests
// ============================================================================
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
X509 *readCertificate(const std::string &strFileName) {
std::ifstream inStream(strFileName);
std::string strCertPEM((std::istreambuf_iterator<char>(inStream)),
std::istreambuf_iterator<char>());
if (strCertPEM.empty()) return (nullptr);
BIO *pbCert = BIO_new(BIO_s_mem());
BIO_write(pbCert, strCertPEM.c_str(), (int)strCertPEM.size());
X509 *pCert = PEM_read_bio_X509(pbCert, NULL, 0, NULL);
BIO_free(pbCert);
return (pCert);
}
EVP_PKEY *readPrivateKey(const std::string &strFileName) {
std::ifstream inStream(strFileName);
std::string strPrivateKeyPEM((std::istreambuf_iterator<char>(inStream)),
std::istreambuf_iterator<char>());
if (strPrivateKeyPEM.empty()) return (nullptr);
BIO *pbPrivKey = BIO_new(BIO_s_mem());
BIO_write(pbPrivKey, strPrivateKeyPEM.c_str(), (int)strPrivateKeyPEM.size());
EVP_PKEY *pPrivateKey = PEM_read_bio_PrivateKey(pbPrivKey, NULL, NULL, NULL);
BIO_free(pbPrivKey);
return (pPrivateKey);
}
TEST(BindServerTest, UpdateCerts) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
int port = svr.bind_to_any_port("0.0.0.0");
ASSERT_TRUE(svr.is_valid());
ASSERT_TRUE(port > 0);
X509 *cert = readCertificate(SERVER_CERT_FILE);
X509 *ca_cert = readCertificate(CLIENT_CA_CERT_FILE);
EVP_PKEY *key = readPrivateKey(SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(cert != nullptr);
ASSERT_TRUE(ca_cert != nullptr);
ASSERT_TRUE(key != nullptr);
X509_STORE *cert_store = X509_STORE_new();
X509_STORE_add_cert(cert_store, ca_cert);
// svr.update_certs(cert, key, cert_store); // deprecated
svr.update_certs_pem(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE,
CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
svr.stop();
X509_STORE_free(cert_store);
X509_free(cert);
X509_free(ca_cert);
EVP_PKEY_free(key);
}
// Test that update_certs() updates client CA list correctly
TEST(SSLClientServerTest, UpdateCertsSetsClientCAList) {
// Start with file-based constructor
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
// Initially no client CA list should be set
auto ssl_ctx = static_cast<SSL_CTX *>(svr.tls_context());
ASSERT_TRUE(ssl_ctx != nullptr);
STACK_OF(X509_NAME) *ca_list_before = SSL_CTX_get_client_CA_list(ssl_ctx);
int count_before = ca_list_before ? sk_X509_NAME_num(ca_list_before) : 0;
EXPECT_EQ(0, count_before);
// Now update with client CA (PEM string)
std::string cert_pem, key_pem, ca_pem;
read_file(SERVER_CERT_FILE, cert_pem);
read_file(SERVER_PRIVATE_KEY_FILE, key_pem);
read_file(CLIENT_CA_CERT_FILE, ca_pem);
svr.update_certs_pem(cert_pem.c_str(), key_pem.c_str(), ca_pem.c_str());
ASSERT_TRUE(svr.is_valid());
// Now client CA list should be set
STACK_OF(X509_NAME) *ca_list_after = SSL_CTX_get_client_CA_list(ssl_ctx);
ASSERT_TRUE(ca_list_after != nullptr);
EXPECT_GT(sk_X509_NAME_num(ca_list_after), 0);
}
TEST(SSLClientServerTest, FilePathConstructorSetsClientCAList) {
// Test that the file-path SSLServer constructor properly sets the client CA
// list that is sent to clients during the TLS handshake (CertificateRequest)
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
ASSERT_TRUE(svr.is_valid());
auto ssl_ctx = static_cast<SSL_CTX *>(svr.tls_context());
ASSERT_TRUE(ssl_ctx != nullptr);
STACK_OF(X509_NAME) *ca_list = SSL_CTX_get_client_CA_list(ssl_ctx);
ASSERT_TRUE(ca_list != nullptr);
EXPECT_GT(sk_X509_NAME_num(ca_list), 0);
}
#endif
// ============================================================================
// MbedTLS-Specific Tests
// ============================================================================
#ifdef CPPHTTPLIB_MBEDTLS_SUPPORT
TEST(SSLClientServerTest, CustomizeServerSSLCtxMbedTLS) {
using namespace httplib::tls;
// Track if callback was invoked
bool callback_invoked = false;
// The callback receives void* ctx which is actually MbedTlsContext*
// We can access the mbedtls_ssl_config via the context
auto setup_callback = [&callback_invoked](void *ctx) {
callback_invoked = true;
// Cast to MbedTlsContext* to access the ssl config
auto *mbedtls_ctx = static_cast<httplib::tls::impl::MbedTlsContext *>(ctx);
mbedtls_ssl_config *conf = &mbedtls_ctx->conf;
// Use static variables to hold certificate data (simplified for test)
static mbedtls_x509_crt own_cert;
static mbedtls_pk_context own_key;
static mbedtls_x509_crt ca_chain;
static bool initialized = false;
if (!initialized) {
mbedtls_x509_crt_init(&own_cert);
mbedtls_pk_init(&own_key);
mbedtls_x509_crt_init(&ca_chain);
// Load server certificate
if (mbedtls_x509_crt_parse_file(&own_cert, SERVER_CERT_FILE) != 0) {
return false;
}
// Load server private key.
// Mbed TLS 3.x takes an RNG callback here; 2.x and 4.x do not.
if (mbedtls_pk_parse_keyfile(&own_key, SERVER_PRIVATE_KEY_FILE, nullptr
#if MBEDTLS_VERSION_MAJOR == 3
,
mbedtls_ctr_drbg_random, nullptr
#endif
) != 0) {
return false;
}
// Load CA chain for client verification
if (mbedtls_x509_crt_parse_file(&ca_chain, CLIENT_CA_CERT_FILE) != 0) {
return false;
}
initialized = true;
}
// Configure the SSL config
mbedtls_ssl_conf_own_cert(conf, &own_cert, &own_key);
mbedtls_ssl_conf_ca_chain(conf, &ca_chain, nullptr);
mbedtls_ssl_conf_authmode(conf, MBEDTLS_SSL_VERIFY_REQUIRED);
// Set minimum TLS version using mbedTLS native API
#if MBEDTLS_VERSION_MAJOR >= 3
mbedtls_ssl_conf_min_tls_version(conf, MBEDTLS_SSL_VERSION_TLS1_2);
#else
mbedtls_ssl_conf_min_version(conf, MBEDTLS_SSL_MAJOR_VERSION_3,
MBEDTLS_SSL_MINOR_VERSION_3);
#endif
return true;
};
SSLServer svr(setup_callback);
ASSERT_TRUE(svr.is_valid());
ASSERT_TRUE(callback_invoked);
svr.Get("/test", [&](const Request &req, Response &res) {
res.set_content("test", "text/plain");
auto cert = req.peer_cert();
ASSERT_TRUE(static_cast<bool>(cert));
auto common_name = cert.subject_cn();
EXPECT_EQ("Common Name", common_name);
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
SSLClient cli(HOST, PORT, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE);
cli.enable_server_certificate_verification(false);
cli.set_connection_timeout(30);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
}
// Regression test for a use-after-free where ~SSLClient freed the mbedTLS
// context (owning mbedtls_ssl_config) before shutting down a still-open
// keep-alive SSL session, which reads through that config in
// mbedtls_ssl_close_notify.
TEST(SSLClientServerTest, DestructWithLiveKeepAliveSessionMbedTLS) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.Get("/test", [&](const Request & /*req*/, Response &res) {
res.set_content("test", "text/plain");
});
thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
SSLClient cli(HOST, PORT);
cli.enable_server_certificate_verification(false);
cli.set_keep_alive(true);
auto res = cli.Get("/test");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
ASSERT_EQ(StatusCode::OK_200, res->status);
// cli is destructed here with the keep-alive SSL session still open.
}
}
#endif
// WebSocket Tests
TEST(WebSocketTest, RSVBitsMustBeZero) {
// RFC 6455 Section 5.2: RSV1, RSV2, RSV3 MUST be 0 unless an extension
// defining the meaning of these bits has been negotiated.
auto make_frame = [](uint8_t first_byte) {
std::string frame;
frame += static_cast<char>(first_byte); // FIN + RSV + opcode
frame += static_cast<char>(0x05); // mask=0, payload_len=5
frame += "Hello";
return frame;
};
// RSV1 set (0x40)
{
detail::BufferStream strm;
strm.write(make_frame(0x81 | 0x40).data(), 8); // FIN + RSV1 + Text
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Fail);
}
// RSV2 set (0x20)
{
detail::BufferStream strm;
strm.write(make_frame(0x81 | 0x20).data(), 8); // FIN + RSV2 + Text
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Fail);
}
// RSV3 set (0x10)
{
detail::BufferStream strm;
strm.write(make_frame(0x81 | 0x10).data(), 8); // FIN + RSV3 + Text
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Fail);
}
// No RSV bits set - should succeed
{
detail::BufferStream strm;
strm.write(make_frame(0x81).data(), 8); // FIN + Text, no RSV
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Ok);
EXPECT_EQ(ws::Opcode::Text, opcode);
EXPECT_EQ("Hello", payload);
EXPECT_TRUE(fin);
}
}
TEST(WebSocketTest, ControlFrameValidation) {
// RFC 6455 Section 5.5: control frames MUST have FIN=1 and
// payload length <= 125.
// Ping with FIN=0 - must be rejected
{
detail::BufferStream strm;
std::string frame;
frame += static_cast<char>(0x09); // FIN=0, opcode=Ping
frame += static_cast<char>(0x00); // mask=0, payload_len=0
strm.write(frame.data(), frame.size());
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Fail);
}
// Close with FIN=0 - must be rejected
{
detail::BufferStream strm;
std::string frame;
frame += static_cast<char>(0x08); // FIN=0, opcode=Close
frame += static_cast<char>(0x00); // mask=0, payload_len=0
strm.write(frame.data(), frame.size());
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Fail);
}
// Ping with payload_len=126 (extended length) - must be rejected
{
detail::BufferStream strm;
std::string frame;
frame += static_cast<char>(0x89); // FIN=1, opcode=Ping
frame += static_cast<char>(126); // payload_len=126 (>125)
frame += static_cast<char>(0x00); // extended length high byte
frame += static_cast<char>(126); // extended length low byte
frame += std::string(126, 'x');
strm.write(frame.data(), frame.size());
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Fail);
}
// Ping with FIN=1 and payload_len=125 - should succeed
{
detail::BufferStream strm;
std::string frame;
frame += static_cast<char>(0x89); // FIN=1, opcode=Ping
frame += static_cast<char>(125); // payload_len=125
frame += std::string(125, 'x');
strm.write(frame.data(), frame.size());
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Ok);
EXPECT_EQ(ws::Opcode::Ping, opcode);
EXPECT_EQ(125u, payload.size());
EXPECT_TRUE(fin);
}
}
TEST(WebSocketTest, PayloadLength64BitMSBMustBeZero) {
// RFC 6455 Section 5.2: the most significant bit of a 64-bit payload
// length MUST be 0.
// MSB set - must be rejected
{
detail::BufferStream strm;
std::string frame;
frame += static_cast<char>(0x81); // FIN=1, opcode=Text
frame += static_cast<char>(127); // 64-bit extended length
frame += static_cast<char>(0x80); // MSB set (invalid)
frame += std::string(7, '\0'); // remaining 7 bytes of length
strm.write(frame.data(), frame.size());
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Fail);
}
// MSB clear - should pass length parsing (will be rejected by max_len,
// but that's a different check; use a small length to verify)
{
detail::BufferStream strm;
std::string frame;
frame += static_cast<char>(0x81); // FIN=1, opcode=Text
frame += static_cast<char>(127); // 64-bit extended length
frame += std::string(7, '\0'); // high bytes = 0
frame += static_cast<char>(0x03); // length = 3
frame += "abc";
strm.write(frame.data(), frame.size());
ws::Opcode opcode;
std::string payload;
bool fin;
EXPECT_EQ(
ws::impl::read_websocket_frame(strm, opcode, payload, fin, false, 1024),
ws::impl::FrameRead::Ok);
EXPECT_EQ(ws::Opcode::Text, opcode);
EXPECT_EQ("abc", payload);
}
}
TEST(WebSocketTest, InvalidUTF8TextFrame) {
// RFC 6455 Section 5.6: text frames must contain valid UTF-8.
// Valid UTF-8
EXPECT_TRUE(ws::impl::is_valid_utf8("Hello"));
EXPECT_TRUE(ws::impl::is_valid_utf8("\xC3\xA9")); // é (U+00E9)
EXPECT_TRUE(ws::impl::is_valid_utf8("\xE3\x81\x82")); // あ (U+3042)
EXPECT_TRUE(ws::impl::is_valid_utf8("\xF0\x9F\x98\x80")); // 😀 (U+1F600)
EXPECT_TRUE(ws::impl::is_valid_utf8(""));
// Invalid UTF-8
EXPECT_FALSE(ws::impl::is_valid_utf8("\x80")); // Invalid start byte
EXPECT_FALSE(ws::impl::is_valid_utf8("\xC3\x28")); // Bad continuation
EXPECT_FALSE(ws::impl::is_valid_utf8("\xC0\xAF")); // Overlong encoding
EXPECT_FALSE(
ws::impl::is_valid_utf8("\xED\xA0\x80")); // Surrogate half U+D800
EXPECT_FALSE(ws::impl::is_valid_utf8("\xF4\x90\x80\x80")); // Beyond U+10FFFF
}
TEST(WebSocketTest, ConnectAndDisconnect) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
auto res = client.connect();
ASSERT_TRUE(res);
EXPECT_EQ(Error::Success, res.error());
EXPECT_EQ(StatusCode::SwitchingProtocol_101, res.status());
EXPECT_TRUE(res.has_header("Sec-WebSocket-Accept"));
EXPECT_TRUE(client.is_open());
client.close();
EXPECT_FALSE(client.is_open());
svr.stop();
t.join();
}
TEST(WebSocketTest, ValidURL) {
ws::WebSocketClient ws1("ws://localhost:8080/path");
EXPECT_TRUE(ws1.is_valid());
ws::WebSocketClient ws2("ws://example.com/path");
EXPECT_TRUE(ws2.is_valid());
ws::WebSocketClient ws3("ws://example.com:9090/path/to/endpoint");
EXPECT_TRUE(ws3.is_valid());
#ifdef CPPHTTPLIB_SSL_ENABLED
ws::WebSocketClient wss1("wss://example.com/path");
EXPECT_TRUE(wss1.is_valid());
ws::WebSocketClient wss2("wss://example.com:443/path");
EXPECT_TRUE(wss2.is_valid());
#endif
}
TEST(WebSocketTest, InvalidURL) {
// No scheme
ws::WebSocketClient ws1("localhost:8080/path");
EXPECT_FALSE(ws1.is_valid());
// No path
ws::WebSocketClient ws2("ws://localhost:8080");
EXPECT_FALSE(ws2.is_valid());
// Empty string
ws::WebSocketClient ws3("");
EXPECT_FALSE(ws3.is_valid());
// Missing host
ws::WebSocketClient ws4("ws://:8080/path");
EXPECT_FALSE(ws4.is_valid());
// Port number overflow — should not crash
ws::WebSocketClient ws5("ws://localhost:99999999999999999999/path");
EXPECT_FALSE(ws5.is_valid());
// Port out of range
ws::WebSocketClient ws6("ws://localhost:99999/path");
EXPECT_FALSE(ws6.is_valid());
}
TEST(WebSocketTest, UnsupportedScheme) {
#ifdef CPPHTTPLIB_NO_EXCEPTIONS
ws::WebSocketClient ws1("http://localhost:8080/path");
EXPECT_FALSE(ws1.is_valid());
ws::WebSocketClient ws2("https://localhost:8080/path");
EXPECT_FALSE(ws2.is_valid());
ws::WebSocketClient ws3("ftp://localhost:8080/path");
EXPECT_FALSE(ws3.is_valid());
#else
EXPECT_THROW(ws::WebSocketClient("http://localhost:8080/path"),
std::invalid_argument);
EXPECT_THROW(ws::WebSocketClient("ftp://localhost:8080/path"),
std::invalid_argument);
#endif
}
TEST(WebSocketTest, ConnectWhenInvalid) {
ws::WebSocketClient ws("not a valid url");
EXPECT_FALSE(ws.is_valid());
auto res = ws.connect();
EXPECT_FALSE(res);
EXPECT_EQ(Error::Connection, res.error());
EXPECT_EQ(-1, res.status());
}
TEST(WebSocketTest, ConnectRefusedReportsError) {
// Grab a port that is free, then close it again so nothing listens there
Server svr;
auto port = svr.bind_to_any_port(HOST);
svr.stop();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
auto res = client.connect();
ASSERT_FALSE(res);
EXPECT_EQ(Error::Connection, res.error());
EXPECT_EQ(-1, res.status());
}
TEST(WebSocketTest, DefaultPort) {
ws::WebSocketClient ws1("ws://example.com/path");
EXPECT_TRUE(ws1.is_valid());
// ws:// defaults to port 80 (verified by successful parse)
#ifdef CPPHTTPLIB_SSL_ENABLED
ws::WebSocketClient ws2("wss://example.com/path");
EXPECT_TRUE(ws2.is_valid());
// wss:// defaults to port 443 (verified by successful parse)
#endif
}
TEST(WebSocketTest, IPv6LiteralAddress) {
ws::WebSocketClient ws1("ws://[::1]:8080/path");
EXPECT_TRUE(ws1.is_valid());
ws::WebSocketClient ws2("ws://[fe80::1]:3000/ws");
EXPECT_TRUE(ws2.is_valid());
}
TEST(WebSocketTest, ComplexPath) {
ws::WebSocketClient ws1("ws://localhost:8080/path/to/endpoint");
EXPECT_TRUE(ws1.is_valid());
ws::WebSocketClient ws2("ws://localhost:8080/");
EXPECT_TRUE(ws2.is_valid());
}
TEST(WebSocketTest, SpecifyServerIPAddress_AnotherHostname) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
auto another_host = "example.com";
auto wrong_ip = "192.0.2.1";
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
client.set_hostname_addr_map({{another_host, wrong_ip}});
ASSERT_TRUE(client.connect());
EXPECT_TRUE(client.is_open());
client.close();
svr.stop();
t.join();
}
TEST(WebSocketTest, SpecifyServerIPAddress_RealHostname) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
auto wrong_ip = "192.0.2.1";
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
client.set_hostname_addr_map({{"localhost", wrong_ip}});
client.set_connection_timeout(1);
client.set_read_timeout(1);
client.set_write_timeout(1);
EXPECT_FALSE(client.connect());
EXPECT_FALSE(client.is_open());
svr.stop();
t.join();
}
TEST(WebSocketTest, SpecifyServerIPAddress_HostnameAsAddrMapValue) {
// A mapped value that is not an IP literal must be resolved. HOST resolves
// from the hosts file, so this test needs no external DNS. "target.invalid"
// (RFC 6761) is only a map key and the Host header value.
auto host = "target.invalid";
Server svr;
std::string received_host;
svr.WebSocket("/ws", [&](const Request &req, ws::WebSocket &ws) {
received_host = req.get_header_value("Host");
std::string msg;
while (ws.read(msg)) {}
});
auto port = svr.bind_to_any_port(HOST);
std::thread t([&]() { svr.listen_after_bind(); });
// ASSERT_* below returns from the test body, which would leave t joinable
// and make ~thread call std::terminate.
auto se = detail::scope_exit([&] {
svr.stop();
if (t.joinable()) { t.join(); }
});
svr.wait_until_ready();
ws::WebSocketClient client("ws://" + std::string(host) + ":" +
std::to_string(port) + "/ws");
client.set_hostname_addr_map({{host, HOST}});
ASSERT_TRUE(client.connect());
EXPECT_TRUE(client.is_open());
client.close();
svr.stop();
t.join();
// The mapping only redirects the connection; the identity stays host_.
EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host);
}
class WebSocketIntegrationTest : public ::testing::Test {
protected:
void SetUp() override {
server_ = httplib::detail::make_unique<Server>();
setup_server();
start_server();
}
void TearDown() override {
server_->stop();
if (server_thread_.joinable()) { server_thread_.join(); }
}
void setup_server() {
server_->WebSocket("/ws-echo", [](const Request &, ws::WebSocket &ws) {
std::string msg;
ws::ReadResult ret;
while ((ret = ws.read(msg))) {
if (ret == ws::Binary) {
ws.send(msg.data(), msg.size());
} else {
ws.send(msg);
}
}
});
server_->WebSocket("/ws-echo-string",
[](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send("echo: " + msg);
}
});
server_->WebSocket(
"/ws-request-info", [](const Request &req, ws::WebSocket &ws) {
// Echo back request metadata
ws.send("path:" + req.path);
ws.send("header:" + req.get_header_value("X-Test-Header"));
std::string msg;
while (ws.read(msg)) {}
});
server_->WebSocket("/ws-close", [](const Request &, ws::WebSocket &ws) {
std::string msg;
ws.read(msg); // wait for a message
ws.close();
});
server_->WebSocket("/ws-close-status",
[](const Request &, ws::WebSocket &ws) {
std::string msg;
ws.read(msg); // wait for a message
ws.close(ws::CloseStatus::GoingAway, "shutting down");
});
server_->WebSocket(
"/ws-subprotocol",
[](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
},
[](const std::vector<std::string> &protocols) -> std::string {
for (const auto &p : protocols) {
if (p == "graphql-ws") { return p; }
}
return "";
});
server_->WebSocket(
"/ws-subprotocol-unoffered",
[](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
},
[](const std::vector<std::string> &) -> std::string {
return "admin";
});
}
void start_server() {
port_ = server_->bind_to_any_port(HOST);
server_thread_ = std::thread([this]() { server_->listen_after_bind(); });
server_->wait_until_ready();
}
std::unique_ptr<Server> server_;
std::thread server_thread_;
int port_ = 0;
};
TEST_F(WebSocketIntegrationTest, TextEcho) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.is_open());
ASSERT_TRUE(client.send("Hello WebSocket"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("Hello WebSocket", msg);
client.close();
}
TEST_F(WebSocketIntegrationTest, BinaryEcho) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
std::string binary_data = {'\x00', '\x01', '\x02', '\xFF', '\xFE'};
ASSERT_TRUE(client.send(binary_data.data(), binary_data.size()));
std::string msg;
EXPECT_EQ(ws::Binary, client.read(msg));
EXPECT_EQ(binary_data, msg);
client.close();
}
TEST_F(WebSocketIntegrationTest, MultipleMessages) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
for (int i = 0; i < 10; i++) {
auto text = "message " + std::to_string(i);
ASSERT_TRUE(client.send(text));
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ(text, msg);
}
client.close();
}
TEST_F(WebSocketIntegrationTest, CloseHandshake) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-close");
ASSERT_TRUE(client.connect());
// Send a message to trigger the server to close
ASSERT_TRUE(client.send("trigger close"));
// The server will close, so read should return false
std::string msg;
EXPECT_FALSE(client.read(msg));
EXPECT_FALSE(client.is_open());
}
TEST_F(WebSocketIntegrationTest, LargeMessage) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
// 128KB message
std::string large_data(128 * 1024, 'X');
ASSERT_TRUE(client.send(large_data));
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ(large_data, msg);
client.close();
}
TEST_F(WebSocketIntegrationTest, ConcurrentSend) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
const int num_threads = 4;
std::vector<std::thread> threads;
std::atomic<int> send_count{0};
for (int t = 0; t < num_threads; t++) {
threads.emplace_back([&client, &send_count, t]() {
for (int i = 0; i < 5; i++) {
auto text = "thread" + std::to_string(t) + "_msg" + std::to_string(i);
if (client.send(text)) { send_count++; }
}
});
}
for (auto &th : threads) {
th.join();
}
int received = 0;
std::string msg;
while (received < send_count.load()) {
if (!client.read(msg)) { break; }
received++;
}
EXPECT_EQ(send_count.load(), received);
client.close();
}
TEST_F(WebSocketIntegrationTest, ReadString) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo-string");
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("echo: hello", msg);
ASSERT_TRUE(client.send("world"));
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("echo: world", msg);
client.close();
}
TEST_F(WebSocketIntegrationTest, RequestAccess) {
Headers headers = {{"X-Test-Header", "test-value"}};
ws::WebSocketClient client(
"ws://localhost:" + std::to_string(port_) + "/ws-request-info", headers);
ASSERT_TRUE(client.connect());
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("path:/ws-request-info", msg);
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("header:test-value", msg);
client.close();
}
TEST_F(WebSocketIntegrationTest, ReadTimeout) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
client.set_read_timeout(1, 0); // 1 second
ASSERT_TRUE(client.connect());
// Don't send anything — server echo handler waits for a message, so read()
// should time out. The connection survives it: nothing was consumed.
std::string msg;
EXPECT_EQ(client.read(msg), ws::Timeout);
EXPECT_TRUE(client.is_open());
// And it is still usable afterwards.
EXPECT_TRUE(client.send("after timeout"));
EXPECT_EQ(client.read(msg), ws::Text);
EXPECT_EQ(msg, "after timeout");
}
TEST_F(WebSocketIntegrationTest, ReadTimeoutLeavesMessageUntouched) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
client.set_read_timeout(1, 0);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("first"));
std::string msg;
ASSERT_EQ(client.read(msg), ws::Text);
ASSERT_EQ(msg, "first");
// A timeout does not write to msg. This is why `while (ws.read(msg))` must
// not be used with a read timeout: it would reprocess the previous message.
EXPECT_EQ(client.read(msg), ws::Timeout);
EXPECT_EQ(msg, "first");
}
TEST_F(WebSocketIntegrationTest, ReadTimeoutSetAfterConnect) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
// Setting it once the connection is open reaches the live stream, not just
// the seed for the next connect().
client.set_read_timeout(1, 0);
std::string msg;
EXPECT_EQ(client.read(msg), ws::Timeout);
EXPECT_TRUE(client.is_open());
}
TEST_F(WebSocketIntegrationTest, MaxPayloadExceeded) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
client.set_read_timeout(5, 0);
ASSERT_TRUE(client.connect());
// Send a message exceeding CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH (16MB).
// The server should reject it and close the connection.
std::string oversized(CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH + 1, 'A');
client.send(oversized);
// The server's read() should have failed due to payload limit,
// so our read() should return false (connection closed).
std::string msg;
EXPECT_FALSE(client.read(msg));
}
TEST_F(WebSocketIntegrationTest, MaxPayloadAtLimit) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
client.set_read_timeout(10, 0);
ASSERT_TRUE(client.connect());
// Send a message exactly at CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH (16MB).
// This should succeed.
std::string at_limit(CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH, 'B');
ASSERT_TRUE(client.send(at_limit));
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ(at_limit.size(), msg.size());
client.close();
}
TEST_F(WebSocketIntegrationTest, ConnectToInvalidPath) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/nonexistent");
auto res = client.connect();
EXPECT_FALSE(res);
EXPECT_EQ(Error::WebSocketHandshake, res.error());
EXPECT_EQ(StatusCode::NotFound_404, res.status());
EXPECT_FALSE(client.is_open());
}
TEST_F(WebSocketIntegrationTest, EmptyMessage) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send(""));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("", msg);
client.close();
}
TEST_F(WebSocketIntegrationTest, Reconnect) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
// First connection
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("first"));
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("first", msg);
client.close();
EXPECT_FALSE(client.is_open());
// Reconnect using the same client object
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.is_open());
ASSERT_TRUE(client.send("second"));
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("second", msg);
client.close();
}
TEST_F(WebSocketIntegrationTest, CloseWithStatus) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-close-status");
ASSERT_TRUE(client.connect());
// Trigger the server to close with GoingAway status
ASSERT_TRUE(client.send("trigger"));
// read() should return false after receiving the close frame
std::string msg;
EXPECT_FALSE(client.read(msg));
EXPECT_FALSE(client.is_open());
}
TEST_F(WebSocketIntegrationTest, ClientCloseWithStatus) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
ASSERT_TRUE(client.connect());
client.close(ws::CloseStatus::GoingAway, "client leaving");
EXPECT_FALSE(client.is_open());
}
TEST_F(WebSocketIntegrationTest, SubProtocolNegotiation) {
Headers headers = {{"Sec-WebSocket-Protocol", "mqtt, graphql-ws"}};
ws::WebSocketClient client(
"ws://localhost:" + std::to_string(port_) + "/ws-subprotocol", headers);
ASSERT_TRUE(client.connect());
// Server should have selected graphql-ws
EXPECT_EQ("graphql-ws", client.subprotocol());
client.close();
}
TEST_F(WebSocketIntegrationTest, SubProtocolSplitAcrossFieldLines) {
Headers headers;
headers.emplace("Sec-WebSocket-Protocol", "mqtt");
headers.emplace("Sec-WebSocket-Protocol", "graphql-ws");
ws::WebSocketClient client(
"ws://localhost:" + std::to_string(port_) + "/ws-subprotocol", headers);
ASSERT_TRUE(client.connect());
// The offer on the second field line counts too, so graphql-ws is selected
EXPECT_EQ("graphql-ws", client.subprotocol());
client.close();
}
TEST_F(WebSocketIntegrationTest, SubProtocolNoMatch) {
Headers headers = {{"Sec-WebSocket-Protocol", "mqtt, wamp"}};
ws::WebSocketClient client(
"ws://localhost:" + std::to_string(port_) + "/ws-subprotocol", headers);
ASSERT_TRUE(client.connect());
// Server should not have selected any subprotocol
EXPECT_TRUE(client.subprotocol().empty());
client.close();
}
TEST_F(WebSocketIntegrationTest, SubProtocolSelectorReturnsUnoffered) {
Headers headers = {{"Sec-WebSocket-Protocol", "chat"}};
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-subprotocol-unoffered",
headers);
ASSERT_TRUE(client.connect());
EXPECT_TRUE(client.subprotocol().empty());
client.close();
}
TEST_F(WebSocketIntegrationTest, SubProtocolNotRequested) {
// Connect without requesting any subprotocol
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-subprotocol");
ASSERT_TRUE(client.connect());
EXPECT_TRUE(client.subprotocol().empty());
client.close();
}
TEST_F(WebSocketIntegrationTest, SocketSettings) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
client.set_tcp_nodelay(true);
client.set_connection_timeout(3, 0);
bool socket_options_called = false;
client.set_socket_options([&](socket_t) { socket_options_called = true; });
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.is_open());
EXPECT_TRUE(socket_options_called);
ASSERT_TRUE(client.send("hello"));
std::string msg;
auto result = client.read(msg);
EXPECT_EQ(result, ws::ReadResult::Text);
EXPECT_EQ(msg, "hello");
client.close();
}
TEST_F(WebSocketIntegrationTest, ChronoTimeoutSetters) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) +
"/ws-echo");
client.set_connection_timeout(std::chrono::seconds(3));
client.set_write_timeout(std::chrono::seconds(3));
// A sub-second remainder exercises the seconds/microseconds split.
client.set_read_timeout(std::chrono::milliseconds(1500));
ASSERT_TRUE(client.connect());
auto start = std::chrono::steady_clock::now();
std::string msg;
EXPECT_EQ(client.read(msg), ws::ReadResult::Timeout);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
// Above 1s so that dropping the microseconds half of the split fails here.
// Ignoring the setter altogether would not reach this line at all: a client
// waits forever by default.
EXPECT_GE(elapsed, 1400);
EXPECT_LT(elapsed, 30000);
}
TEST(WebSocketPreRoutingTest, RejectWithoutAuth) {
Server svr;
svr.set_pre_routing_handler([](const Request &req, Response &res) {
if (!req.has_header("Authorization")) {
res.status = StatusCode::Unauthorized_401;
res.set_content("Unauthorized", "text/plain");
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
// Without Authorization header - should be rejected before upgrade
ws::WebSocketClient client1("ws://localhost:" + std::to_string(port) + "/ws");
EXPECT_FALSE(client1.connect());
// The rejection is framed like any other HTTP response
{
Client cli("localhost", port);
Headers headers = {{"Upgrade", "websocket"},
{"Connection", "Upgrade"},
{"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="},
{"Sec-WebSocket-Version", "13"}};
auto res = cli.Get("/ws", headers);
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
EXPECT_EQ("12", res->get_header_value("Content-Length"));
EXPECT_EQ("Unauthorized", res->body);
}
// With Authorization header - should succeed
Headers headers = {{"Authorization", "Bearer token123"}};
ws::WebSocketClient client2("ws://localhost:" + std::to_string(port) + "/ws",
headers);
ASSERT_TRUE(client2.connect());
ASSERT_TRUE(client2.send("hello"));
std::string msg;
ASSERT_TRUE(client2.read(msg));
EXPECT_EQ("hello", msg);
client2.close();
svr.stop();
t.join();
}
TEST(WebSocketPreRequestTest, RejectWithoutAuth) {
Server svr;
std::atomic<int> pre_request_calls{0};
std::atomic<bool> route_matched{false};
svr.set_pre_request_handler([&](const Request &req, Response &res) {
pre_request_calls++;
if (req.matched_route == "/ws/:id") { route_matched = true; }
if (req.get_header_value("Authorization") != "Bearer token123") {
res.status = StatusCode::Unauthorized_401;
res.set_content("Unauthorized", "text/plain");
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
std::atomic<bool> handler_called{false};
svr.WebSocket("/ws/:id", [&](const Request &req, ws::WebSocket &ws) {
handler_called = true;
ws.send(req.matched_route + " " + req.path_params.at("id"));
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
// Without Authorization header - should be rejected before upgrade
ws::WebSocketClient client1("ws://localhost:" + std::to_string(port) +
"/ws/1");
EXPECT_FALSE(client1.connect());
EXPECT_FALSE(handler_called);
EXPECT_EQ(1, pre_request_calls);
EXPECT_TRUE(route_matched);
// The rejection is an ordinary HTTP response, not a protocol switch
{
Client cli("localhost", port);
Headers headers = {{"Upgrade", "websocket"},
{"Connection", "Upgrade"},
{"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="},
{"Sec-WebSocket-Version", "13"}};
auto res = cli.Get("/ws/1", headers);
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
EXPECT_EQ("12", res->get_header_value("Content-Length"));
EXPECT_EQ("Unauthorized", res->body);
}
EXPECT_FALSE(handler_called);
// With Authorization header - should succeed
Headers headers = {{"Authorization", "Bearer token123"}};
ws::WebSocketClient client2(
"ws://localhost:" + std::to_string(port) + "/ws/2", headers);
ASSERT_TRUE(client2.connect());
std::string msg;
ASSERT_TRUE(client2.read(msg));
EXPECT_EQ("/ws/:id 2", msg);
EXPECT_TRUE(handler_called);
client2.close();
svr.stop();
t.join();
}
TEST(WebSocketServerTimeoutTest, HandlerSendsWhileNothingArrives) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) {
// A read timeout is how a handler gets control back. Without it, a handler
// parked in read() can never write on the connection it is reading.
ws.set_read_timeout(std::chrono::milliseconds(100));
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) {
ws.send("tick");
continue;
}
if (r == httplib::ws::Fail) { break; }
ws.send(msg);
}
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
client.set_read_timeout(10, 0); // fail rather than hang if no tick arrives
ASSERT_TRUE(client.connect());
// The client sends nothing, so anything it receives came out of the
// handler's timeout path.
std::string msg;
ASSERT_EQ(client.read(msg), ws::Text);
EXPECT_EQ("tick", msg);
client.close();
svr.stop();
t.join();
}
// Stream::read may return fewer bytes than asked for. This is that contract at
// its worst -- one byte per call -- which is what a frame header straddling a
// read buffer's boundary looks like to the frame parser.
class WsByteAtATimeStream : public Stream {
public:
explicit WsByteAtATimeStream(std::string data) : data_(std::move(data)) {}
bool is_readable() const override { return true; }
bool wait_readable() const override { return true; }
bool wait_writable() const override { return true; }
ssize_t read(char *ptr, size_t size) override {
if (size == 0 || pos_ >= data_.size()) { return 0; }
*ptr = data_[pos_++];
return 1;
}
ssize_t write(const char *, size_t size) override {
return static_cast<ssize_t>(size);
}
void get_remote_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 0;
}
void get_local_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 0;
}
socket_t socket() const override { return INVALID_SOCKET; }
time_t duration() const override { return 0; }
private:
std::string data_;
size_t pos_ = 0;
};
TEST(WebSocketFrameTest, MultiByteFieldsSplitAcrossReads) {
// A 200-byte payload uses the 16-bit extended length, so the header, the
// length and the mask key are all multi-byte fields here. Each used to be
// read with a single read() call, which fails as soon as one is split.
std::string body(200, 'x');
const uint8_t mask[4] = {0x0a, 0x0b, 0x0c, 0x0d};
std::string frame;
frame += static_cast<char>(0x81); // FIN + Text
frame += static_cast<char>(0x80 | 126); // masked, 16-bit length follows
frame += static_cast<char>(body.size() >> 8);
frame += static_cast<char>(body.size() & 0xff);
for (size_t i = 0; i < 4; i++) {
frame += static_cast<char>(mask[i]);
}
for (size_t i = 0; i < body.size(); i++) {
frame += static_cast<char>(body[i] ^ mask[i % 4]);
}
WsByteAtATimeStream strm(frame);
ws::Opcode opcode;
std::string payload;
bool fin = false;
ASSERT_EQ(ws::impl::read_websocket_frame(strm, opcode, payload, fin,
/*expect_masked=*/true, 1024),
ws::impl::FrameRead::Ok);
EXPECT_TRUE(fin);
EXPECT_EQ(opcode, ws::Opcode::Text);
EXPECT_EQ(payload, body);
}
TEST(WebSocketTest, QueryStringInHandshake) {
Server svr;
std::mutex mtx;
std::string received_target;
std::string received_token;
svr.WebSocket("/ws", [&](const Request &req, ws::WebSocket &ws) {
{
std::lock_guard<std::mutex> lock(mtx);
received_target = req.target;
if (req.has_param("token")) {
received_token = req.get_param_value("token");
}
}
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) +
"/ws?token=ABC&session=123");
ASSERT_TRUE(client.connect());
// Round-trip ensures the handler has run and captured the request.
ASSERT_TRUE(client.send("hello"));
std::string msg;
ASSERT_TRUE(client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
{
std::lock_guard<std::mutex> lock(mtx);
EXPECT_EQ("/ws?token=ABC&session=123", received_target);
EXPECT_EQ("ABC", received_token);
}
svr.stop();
t.join();
}
// Run a handshake against a throwaway server and hand the request the server
// received back to the caller, so tests can assert on the headers the client
// actually put on the wire.
static void capture_websocket_handshake_request(
const Headers &client_headers,
std::function<void(const Request &, int port)> verify) {
Server svr;
std::mutex mtx;
Request received;
svr.WebSocket("/ws", [&](const Request &req, ws::WebSocket &ws) {
{
std::lock_guard<std::mutex> lock(mtx);
received = req;
}
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws",
client_headers);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
ASSERT_TRUE(client.read(msg));
client.close();
{
std::lock_guard<std::mutex> lock(mtx);
verify(received, port);
}
}
TEST(WebSocketTest, DefaultHeadersInHandshake) {
capture_websocket_handshake_request({}, [](const Request &req, int port) {
// Non-default port must be present in the Host header. Default ports
// (80/443) are omitted; that logic is covered by
// MakeHostAndPortStringTest.
EXPECT_EQ("localhost:" + std::to_string(port),
req.get_header_value("Host"));
EXPECT_EQ(std::string("cpp-httplib/") + CPPHTTPLIB_VERSION,
req.get_header_value("User-Agent"));
EXPECT_FALSE(req.has_header("Accept"));
EXPECT_FALSE(req.has_header("Accept-Encoding"));
EXPECT_FALSE(req.has_header("Content-Length"));
});
}
TEST(WebSocketTest, UserHeadersOverrideGeneratedOnesInHandshake) {
capture_websocket_handshake_request(
{{"Host", "example.com"},
{"User-Agent", "custom-agent"},
{"X-Custom", "value"}},
[](const Request &req, int) {
EXPECT_EQ("example.com", req.get_header_value("Host"));
EXPECT_EQ(1U, req.get_header_value_count("Host"));
EXPECT_EQ("custom-agent", req.get_header_value("User-Agent"));
EXPECT_EQ(1U, req.get_header_value_count("User-Agent"));
EXPECT_EQ("value", req.get_header_value("X-Custom"));
});
}
TEST(WebSocketTest, MandatoryHeadersInHandshakeAreEnforced) {
capture_websocket_handshake_request(
{{"Upgrade", "bogus"},
{"Connection", "close"},
{"Sec-WebSocket-Key", "AAAAAAAAAAAAAAAAAAAAAA=="},
{"Sec-WebSocket-Version", "8"}},
[](const Request &req, int) {
EXPECT_EQ("websocket", req.get_header_value("Upgrade"));
EXPECT_EQ(1U, req.get_header_value_count("Upgrade"));
EXPECT_EQ("Upgrade", req.get_header_value("Connection"));
EXPECT_EQ(1U, req.get_header_value_count("Connection"));
EXPECT_EQ("13", req.get_header_value("Sec-WebSocket-Version"));
EXPECT_EQ(1U, req.get_header_value_count("Sec-WebSocket-Version"));
EXPECT_EQ(1U, req.get_header_value_count("Sec-WebSocket-Key"));
EXPECT_NE("AAAAAAAAAAAAAAAAAAAAAA==",
req.get_header_value("Sec-WebSocket-Key"));
});
}
TEST(WebSocketTest, InvalidHeaderInHandshakeWritesNothing) {
// A header the client refuses to send must abort the handshake before any
// part of it reaches the wire; a lone request line would otherwise sit in
// the peer's buffer as a truncated request.
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_NE(INVALID_SOCKET, srv);
auto se_srv = detail::scope_exit([&] { detail::close_socket(srv); });
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = 0; // ephemeral, so parallel shards don't collide
::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));
sockaddr_in bound{};
socklen_t bound_len = sizeof(bound);
ASSERT_EQ(
0, ::getsockname(srv, reinterpret_cast<sockaddr *>(&bound), &bound_len));
auto port = ntohs(bound.sin_port);
ssize_t received = -1;
std::thread t([&] {
// Bound every blocking call so a regression fails the test with a bad
// value instead of hanging the suite.
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(srv, &rfds);
timeval tv{5, 0};
if (::select(static_cast<int>(srv + 1), &rfds, nullptr, nullptr, &tv) <=
0) {
return;
}
sockaddr_in cli_addr{};
socklen_t cli_len = sizeof(cli_addr);
auto cli = ::accept(srv, reinterpret_cast<sockaddr *>(&cli_addr), &cli_len);
if (cli == INVALID_SOCKET) { return; }
auto se_cli = detail::scope_exit([&] { detail::close_socket(cli); });
detail::set_socket_opt_time(cli, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
char buf[4096];
received = ::recv(cli, buf, sizeof(buf), 0);
});
// The CR/LF makes the value invalid, so check_and_write_headers rejects it.
// connect() has already shut the socket down by the time it returns false,
// so the peer sees EOF without waiting for the client to be destroyed.
ws::WebSocketClient client("ws://127.0.0.1:" + std::to_string(port) + "/ws",
{{"X-Bad", "a\r\nInjected: 1"}});
EXPECT_FALSE(client.connect());
t.join();
// 0 means the peer saw a clean EOF without a single byte of the handshake.
EXPECT_EQ(0, received);
}
TEST(WebSocketTest, ConnectionHeaderNeedsCompleteUpgradeToken) {
// RFC 6455 4.2.1: Connection is a token list, so a value that merely
// contains "upgrade" as a substring is a different token and must not
// start a WebSocket handshake.
auto make_request = [](const std::vector<std::string> &connection_values) {
Request req;
req.method = "GET";
req.headers.emplace("Upgrade", "websocket");
for (const auto &value : connection_values) {
req.headers.emplace("Connection", value);
}
req.headers.emplace("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
req.headers.emplace("Sec-WebSocket-Version", "13");
return req;
};
EXPECT_TRUE(detail::is_websocket_upgrade(make_request({"Upgrade"})));
EXPECT_TRUE(detail::is_websocket_upgrade(make_request({"upgrade"})));
EXPECT_TRUE(
detail::is_websocket_upgrade(make_request({"keep-alive, Upgrade"})));
EXPECT_TRUE(
detail::is_websocket_upgrade(make_request({"Upgrade , keep-alive"})));
EXPECT_TRUE(
detail::is_websocket_upgrade(make_request({"keep-alive", "Upgrade"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"notupgrade"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"upgrade-not"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"xupgrade"})));
EXPECT_FALSE(
detail::is_websocket_upgrade(make_request({"keep-alive, notupgrade"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"close"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({})));
}
TEST(WebSocketTest, UpgradeHeaderNeedsCompleteWebsocketToken) {
// RFC 9110 7.8 defines Upgrade as a comma-separated list of protocols and
// asks recipients to match each protocol-name case-insensitively, and RFC
// 6455 4.2.1 asks for a header field containing the value "websocket". A
// client naming websocket alongside another protocol, or on a second field
// line, is offering websocket; a value that merely contains "websocket" as a
// substring is a different protocol name and is not.
auto make_request = [](const std::vector<std::string> &upgrade_values) {
Request req;
req.method = "GET";
for (const auto &value : upgrade_values) {
req.headers.emplace("Upgrade", value);
}
req.headers.emplace("Connection", "Upgrade");
req.headers.emplace("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
req.headers.emplace("Sec-WebSocket-Version", "13");
return req;
};
EXPECT_TRUE(detail::is_websocket_upgrade(make_request({"websocket"})));
EXPECT_TRUE(detail::is_websocket_upgrade(make_request({"WebSocket"})));
EXPECT_TRUE(
detail::is_websocket_upgrade(make_request({"websocket, HTTP/3.0"})));
EXPECT_TRUE(
detail::is_websocket_upgrade(make_request({"HTTP/3.0 , websocket"})));
EXPECT_TRUE(
detail::is_websocket_upgrade(make_request({"HTTP/3.0", "websocket"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"notwebsocket"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"websocket-2"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"xwebsocket"})));
EXPECT_FALSE(
detail::is_websocket_upgrade(make_request({"HTTP/3.0, notwebsocket"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({"h2c"})));
EXPECT_FALSE(detail::is_websocket_upgrade(make_request({})));
}
TEST(WebSocketTest, ServerRejectsHandshakeWithoutUpgradeToken) {
Server svr;
svr.WebSocket("/ws", [](const Request &, ws::WebSocket &) {});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
Headers headers = {{"Upgrade", "websocket"},
{"Connection", "notupgrade"},
{"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="},
{"Sec-WebSocket-Version", "13"}};
Client cli("localhost", port);
auto res = cli.Get("/ws", headers);
// The route exists only as a WebSocket route, so a request that fails the
// handshake check falls through to ordinary routing.
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
TEST(WebSocketTest, ClientRejectsResponseWithoutUpgradeToken) {
// The peer answers 101 with a correct Sec-WebSocket-Accept, so the
// Connection value is the only thing left for the client to reject.
Server svr;
svr.Get("/ws", [](const Request &req, Response &res) {
res.status = StatusCode::SwitchingProtocol_101;
res.set_header("Upgrade", "websocket");
res.set_header("Connection", "notupgrade");
res.set_header("Sec-WebSocket-Accept",
detail::websocket_accept_key(
req.get_header_value("Sec-WebSocket-Key")));
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws");
auto res = client.connect();
EXPECT_FALSE(res);
EXPECT_EQ(Error::WebSocketHandshake, res.error());
EXPECT_FALSE(client.is_open());
}
TEST(WebSocketTest, ClientRejectsUnofferedSubprotocol) {
Server svr;
svr.Get("/ws", [](const Request &req, Response &res) {
res.status = StatusCode::SwitchingProtocol_101;
res.set_header("Upgrade", "websocket");
res.set_header("Connection", "Upgrade");
res.set_header("Sec-WebSocket-Accept",
detail::websocket_accept_key(
req.get_header_value("Sec-WebSocket-Key")));
res.set_header("Sec-WebSocket-Protocol", "admin");
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
const auto url = "ws://localhost:" + std::to_string(port) + "/ws";
// Server selects a subprotocol the client never offered
{
Headers headers = {{"Sec-WebSocket-Protocol", "chat"}};
ws::WebSocketClient client(url, headers);
auto res = client.connect();
EXPECT_FALSE(res);
EXPECT_EQ(Error::WebSocketHandshake, res.error());
EXPECT_FALSE(client.is_open());
EXPECT_TRUE(client.subprotocol().empty());
}
// Client offered none but the server named one anyway
{
ws::WebSocketClient client(url);
auto res = client.connect();
EXPECT_FALSE(res);
EXPECT_EQ(Error::WebSocketHandshake, res.error());
EXPECT_FALSE(client.is_open());
EXPECT_TRUE(client.subprotocol().empty());
}
}
TEST(WebSocketTest, HostHeaderOverUnixSocket) {
// The socket path doubles as the URL host, so it must not contain '/'.
const char *shard = getenv("GTEST_SHARD_INDEX");
const std::string sock_path =
shard ? std::string("httplib-ws-") + shard + ".sock"
: std::string("httplib-ws.sock");
std::remove(sock_path.c_str());
Server svr;
std::mutex mtx;
std::string received_host;
svr.WebSocket("/ws", [&](const Request &req, ws::WebSocket &ws) {
{
std::lock_guard<std::mutex> lock(mtx);
received_host = req.get_header_value("Host");
}
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
svr.set_address_family(AF_UNIX);
std::thread t([&]() { ASSERT_TRUE(svr.listen(sock_path, 80)); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
std::remove(sock_path.c_str());
});
svr.wait_until_ready();
ws::WebSocketClient client("ws://" + sock_path + "/ws");
client.set_address_family(AF_UNIX);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
ASSERT_TRUE(client.read(msg));
client.close();
{
std::lock_guard<std::mutex> lock(mtx);
// There is no host:port for a Unix socket, so the same "localhost"
// placeholder the HTTP client uses is expected.
EXPECT_EQ("localhost", received_host);
}
}
// Two threads must never parse WebSocket frames from the same stream.
// close() used to read the peer's Close reply with its own frame read, so it
// raced a reader thread that was in the middle of a payload: the payload loop
// in read_websocket_frame() keeps reading until payload_len bytes are in hand,
// so bytes taken by close() were replaced with bytes from further along the
// stream. The message kept its length and silently changed content.
//
// The raw peer below sends a frame header plus part of the payload, waits for
// the handler to call close(), and only then sends the rest. Whichever thread
// would win the race for those bytes, the message must arrive intact, because
// close() must not touch the stream while read() owns it.
TEST(WebSocketTest, CloseDoesNotStealBytesFromConcurrentRead) {
#ifndef _WIN32
signal(SIGPIPE, SIG_IGN);
#endif
const size_t payload_len = 120; // fits the 7-bit length field
const size_t prefix_len = 8;
const int attempts = 8;
std::string expected(payload_len, '\0');
for (size_t i = 0; i < payload_len; i++) {
expected[i] = static_cast<char>('a' + i % 26);
}
std::atomic<bool> peer_stalled{false};
std::atomic<bool> handler_done{false};
std::mutex received_mutex;
std::vector<std::string> received;
// Bound every wait, so a regression fails the test instead of hanging the
// suite.
auto wait_for = [](const std::atomic<bool> &flag) {
for (int i = 0; i < 500 && !flag; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
};
Server svr;
svr.set_websocket_ping_interval(0);
svr.WebSocket("/ws", [&](const Request &, ws::WebSocket &ws) {
std::thread reader([&]() {
std::string msg;
while (ws.read(msg)) {
std::lock_guard<std::mutex> guard(received_mutex);
received.push_back(msg);
}
});
// Wait until the peer stalls mid-payload, so the reader thread is parked
// inside read_websocket_frame() when close() runs.
wait_for(peer_stalled);
ws.close();
reader.join();
handler_done = true;
});
auto port = svr.bind_to_any_port("127.0.0.1");
std::thread t([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
auto send_bytes = [](socket_t s, const std::string &data) {
#ifdef _WIN32
auto n = ::send(s, data.data(), static_cast<int>(data.size()), 0);
#else
auto n = ::send(s, data.data(), data.size(), 0);
#endif
return n == static_cast<decltype(n)>(data.size());
};
// Frame header with an all-zero mask key, so the payload goes out verbatim.
// Every length used here fits the 7-bit length field.
auto masked_header = [](uint8_t first_byte, size_t len) {
std::string h;
h += static_cast<char>(first_byte);
h += static_cast<char>(0x80 | len); // masked, 7-bit length
h.append(4, '\0'); // mask key
return h;
};
for (int attempt = 0; attempt < attempts; attempt++) {
peer_stalled = false;
handler_done = false;
auto sock = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_NE(INVALID_SOCKET, sock) << "attempt " << attempt;
auto se_sock = detail::scope_exit([&] {
if (sock != INVALID_SOCKET) { detail::close_socket(sock); }
});
detail::set_socket_opt_time(sock, SOL_SOCKET, SO_RCVTIMEO, 5, 0);
detail::set_socket_opt_time(sock, SOL_SOCKET, SO_SNDTIMEO, 5, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
ASSERT_EQ(
0, ::connect(sock, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)))
<< "attempt " << attempt;
ASSERT_TRUE(send_bytes(sock,
"GET /ws HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAA==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"))
<< "attempt " << attempt;
std::string response;
while (response.find("\r\n\r\n") == std::string::npos) {
char buf[512];
auto n = ::recv(sock, buf, static_cast<int>(sizeof(buf)), 0);
if (n <= 0) { break; }
response.append(buf, static_cast<size_t>(n));
}
ASSERT_NE(std::string::npos, response.find(" 101 "))
<< "attempt " << attempt;
// Send the header of a Binary message but only the first prefix_len bytes
// of its payload, leaving the reader thread stalled inside the payload.
ASSERT_TRUE(send_bytes(sock, masked_header(0x82, payload_len) +
expected.substr(0, prefix_len)))
<< "attempt " << attempt;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
peer_stalled = true;
// Let close() send its Close frame and park in its own read before the
// rest of the payload arrives, so both threads are waiting for it.
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::string close_frame = masked_header(0x88, 2); // FIN + Close
close_frame += static_cast<char>(0x03); // status 1000
close_frame += static_cast<char>(0xE8);
ASSERT_TRUE(send_bytes(sock, expected.substr(prefix_len) + close_frame))
<< "attempt " << attempt;
// Closing the peer releases the reader thread even on the buggy path,
// where it waits for bytes another thread already consumed.
std::this_thread::sleep_for(std::chrono::milliseconds(50));
detail::close_socket(sock);
sock = INVALID_SOCKET;
wait_for(handler_done);
ASSERT_TRUE(handler_done) << "attempt " << attempt;
}
std::lock_guard<std::mutex> guard(received_mutex);
EXPECT_EQ(static_cast<size_t>(attempts), received.size())
<< "a message in flight when close() ran was dropped";
for (size_t i = 0; i < received.size(); i++) {
EXPECT_EQ(expected, received[i]) << "message " << i;
}
}
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
class WebSocketSSLIntegrationTest : public ::testing::Test {
protected:
void SetUp() override {
server_ = httplib::detail::make_unique<SSLServer>(SERVER_CERT_FILE,
SERVER_PRIVATE_KEY_FILE);
server_->WebSocket("/ws-echo", [](const Request &, ws::WebSocket &ws) {
std::string msg;
ws::ReadResult ret;
while ((ret = ws.read(msg))) {
if (ret == ws::Binary) {
ws.send(msg.data(), msg.size());
} else {
ws.send(msg);
}
}
});
port_ = server_->bind_to_any_port(HOST);
server_thread_ = std::thread([this]() { server_->listen_after_bind(); });
server_->wait_until_ready();
}
void TearDown() override {
server_->stop();
if (server_thread_.joinable()) { server_thread_.join(); }
}
std::unique_ptr<SSLServer> server_;
std::thread server_thread_;
int port_ = 0;
};
TEST_F(WebSocketSSLIntegrationTest, TextEcho) {
ws::WebSocketClient client("wss://localhost:" + std::to_string(port_) +
"/ws-echo");
client.enable_server_certificate_verification(false);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.is_open());
ASSERT_TRUE(client.send("Hello WSS"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("Hello WSS", msg);
client.close();
}
// Regression test (GHSA-w7p7-f35j-mw7q): shutdown_and_close() used to free the
// TLS session before ws_->close() sent the close frame. Because the WebSocket's
// SSLSocketStream keeps a raw pointer to that session, sending the close frame
// then read/wrote a freed SSL object (use-after-free). Destroying an open wss
// client (without calling close() first) is the only path that runs
// shutdown_and_close() while the WebSocket is still open, so it reproduces the
// bug exactly.
//
// NOTE: the offending read/write happens inside OpenSSL, so ASan only flags it
// when linked against an instrumented libssl; run under Valgrind to catch it
// with a stock OpenSSL. The short timeouts keep a regression from wedging the
// suite (the freed session otherwise stalls on the close handshake) — this test
// is a memory-tool tripwire, not a pass/fail assertion on stock builds.
TEST_F(WebSocketSSLIntegrationTest, DestroyWhileOpenSendsCloseOverLiveSession) {
{
ws::WebSocketClient client("wss://localhost:" + std::to_string(port_) +
"/ws-echo");
client.enable_server_certificate_verification(false);
client.set_read_timeout(2, 0);
client.set_write_timeout(2, 0);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.is_open());
ASSERT_TRUE(client.send("still open"));
// Intentionally do NOT call client.close(): leaving scope runs
// shutdown_and_close() with the WebSocket still open, which must send the
// close frame while the TLS session is still alive.
}
}
// Regression test (GHSA-w7p7-f35j-mw7q): reconnecting an open wss client runs
// shutdown_and_close() at the start of connect(), reproducing the same
// use-after-free within the test body rather than at scope exit. The second
// connect must succeed and echo, proving the close handshake ran over a live
// session. See the note above about memory-tool requirements.
TEST_F(WebSocketSSLIntegrationTest,
ReconnectWhileOpenSendsCloseOverLiveSession) {
ws::WebSocketClient client("wss://localhost:" + std::to_string(port_) +
"/ws-echo");
client.enable_server_certificate_verification(false);
client.set_read_timeout(2, 0);
client.set_write_timeout(2, 0);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.is_open());
ASSERT_TRUE(client.send("still open"));
// Reconnect without closing first: connect() calls shutdown_and_close() on
// the still-open connection, which must not touch a freed TLS session.
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.is_open());
ASSERT_TRUE(client.send("after reconnect"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("after reconnect", msg);
client.close();
}
#endif
#ifdef CPPHTTPLIB_SSL_ENABLED
class WebSocketSSLCATest : public ::testing::Test {
protected:
void SetUp() override {
server_ = httplib::detail::make_unique<SSLServer>(SERVER_CERT2_FILE,
SERVER_PRIVATE_KEY_FILE);
server_->WebSocket("/echo", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
port_ = server_->bind_to_any_port("127.0.0.1");
server_thread_ = std::thread([this]() { server_->listen_after_bind(); });
server_->wait_until_ready();
}
void TearDown() override {
server_->stop();
if (server_thread_.joinable()) { server_thread_.join(); }
}
std::string url() const {
return "wss://127.0.0.1:" + std::to_string(port_) + "/echo";
}
std::unique_ptr<SSLServer> server_;
std::thread server_thread_;
int port_ = 0;
};
// A custom CA loaded as PEM verifies the server with full certificate
// verification enabled
TEST_F(WebSocketSSLCATest, LoadCaCertStoreVerifiesServer) {
ws::WebSocketClient client(url());
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
client.load_ca_cert_store(cert.c_str(), cert.size());
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
}
// A custom CA that does not cover the server must fail verification (the
// custom CA is exclusive; system certs are not loaded alongside it)
TEST_F(WebSocketSSLCATest, WrongCustomCaFailsVerification) {
ws::WebSocketClient client(url());
std::string cert;
read_file(CLIENT_CA_CERT_FILE, cert);
client.load_ca_cert_store(cert.c_str(), cert.size());
auto res = client.connect();
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLServerVerification, res.error());
EXPECT_EQ(-1, res.status());
EXPECT_NE(0u, res.ssl_backend_error());
}
// The same CA as a file path rather than PEM in memory
TEST_F(WebSocketSSLCATest, SetCaCertPathVerifiesServer) {
ws::WebSocketClient client(url());
client.set_ca_cert_path(SERVER_CERT2_FILE);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
}
// ...and a CA file that does not cover the server still fails, so it is the
// path above that decides the outcome
TEST_F(WebSocketSSLCATest, WrongCaCertPathFailsVerification) {
ws::WebSocketClient client(url());
client.set_ca_cert_path(CLIENT_CA_CERT_FILE);
ASSERT_FALSE(client.connect());
}
// Regression test: reconnecting with a native custom CA store used to reuse
// a store handle the previous TLS context had already freed (use-after-free
// under the OpenSSL backend). The context now lives as long as the client.
TEST_F(WebSocketSSLCATest, ReconnectWithCustomCaStore) {
ws::WebSocketClient client(url());
std::string cert;
read_file(SERVER_CERT2_FILE, cert);
client.set_ca_cert_store(tls::create_ca_store(cert.c_str(), cert.size()));
ASSERT_TRUE(client.connect());
client.close();
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("again"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("again", msg);
client.close();
}
// Regression test: a certificate with a trusted chain but no identity match
// for the server IP must be rejected. The Mbed TLS backend used to skip
// verification entirely for IP hosts, so this connection succeeded there.
// SERVER_CERT_FILE has no IP SAN (CN only), so trusting it as a CA satisfies
// chain verification while the identity check must still fail.
TEST(WebSocketSSLVerifyTest, TrustedChainWrongIdentityFails) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.WebSocket("/echo", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
auto port = svr.bind_to_any_port("127.0.0.1");
auto t = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
ws::WebSocketClient client("wss://127.0.0.1:" + std::to_string(port) +
"/echo");
std::string cert;
read_file(SERVER_CERT_FILE, cert);
client.load_ca_cert_store(cert.c_str(), cert.size());
ASSERT_FALSE(client.connect());
}
// The tests above all connect to an IP literal, for which RFC 6066 forbids
// SNI, so nothing binds the host name to the TLS session. These bind the
// server to "localhost" instead, the only shape that exercises the SNI and
// hostname-verification path with certificate verification left enabled.
class WebSocketSSLDnsHostTest : public ::testing::Test {
protected:
void Start(const char *cert_file) {
server_ = httplib::detail::make_unique<SSLServer>(cert_file,
SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(server_->is_valid());
server_->WebSocket("/echo", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
port_ = server_->bind_to_any_port("localhost");
server_thread_ = std::thread([this]() { server_->listen_after_bind(); });
server_->wait_until_ready();
}
void TearDown() override {
if (server_) { server_->stop(); }
if (server_thread_.joinable()) { server_thread_.join(); }
}
std::string url() const {
return "wss://localhost:" + std::to_string(port_) + "/echo";
}
std::unique_ptr<SSLServer> server_;
std::thread server_thread_;
int port_ = 0;
};
// cert2 carries a DNS:localhost SAN, so both the chain and the identity check
// out and the connection is usable
TEST_F(WebSocketSSLDnsHostTest, TrustedChainMatchingNameVerifies) {
Start(SERVER_CERT2_FILE);
ws::WebSocketClient client(url());
client.set_ca_cert_path(SERVER_CERT2_FILE);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
}
// cert.pem has a CN but no SAN, so trusting it as a CA satisfies the chain
// while the identity check must still reject "localhost"
TEST_F(WebSocketSSLDnsHostTest, TrustedChainWrongNameFails) {
Start(SERVER_CERT_FILE);
ws::WebSocketClient client(url());
client.set_ca_cert_path(SERVER_CERT_FILE);
auto res = client.connect();
ASSERT_FALSE(res);
EXPECT_EQ(Error::SSLServerHostnameVerification, res.error());
EXPECT_EQ(-1, res.status());
}
// Same setup as TrustedChainWrongNameFails, but with hostname verification
// disabled: the chain is still checked, only the identity check is skipped
TEST_F(WebSocketSSLDnsHostTest, HostnameVerificationDisabledAcceptsWrongName) {
Start(SERVER_CERT_FILE);
ws::WebSocketClient client(url());
client.set_ca_cert_path(SERVER_CERT_FILE);
client.enable_server_hostname_verification(false);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
}
// A CA that did not sign the server certificate fails the chain, even though
// the name would match
TEST_F(WebSocketSSLDnsHostTest, UntrustedChainFails) {
Start(SERVER_CERT2_FILE);
ws::WebSocketClient client(url());
client.set_ca_cert_path(CLIENT_CA_CERT_FILE);
ASSERT_FALSE(client.connect());
}
// With verification disabled, neither the chain nor the name is checked
TEST_F(WebSocketSSLDnsHostTest, VerificationDisabledAcceptsAnyName) {
Start(SERVER_CERT_FILE);
ws::WebSocketClient client(url());
client.enable_server_certificate_verification(false);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
}
class WebSocketSSLPemMemoryTest : public ::testing::Test {
protected:
void SetUp() override {
server_ = httplib::detail::make_unique<SSLServer>(
SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE);
ASSERT_TRUE(server_->is_valid());
server_->WebSocket("/ws-echo", [](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
ws.send(msg);
}
});
port_ = server_->bind_to_any_port("localhost");
server_thread_ = std::thread([this]() { server_->listen_after_bind(); });
server_->wait_until_ready();
}
void TearDown() override {
server_->stop();
if (server_thread_.joinable()) { server_thread_.join(); }
}
std::string url() const {
return "wss://localhost:" + std::to_string(port_) + "/ws-echo";
}
void ConnectWithClientCert(const std::string &client_cert_file,
const std::string &client_private_key_file,
const char *private_key_password) {
std::string cert_pem, key_pem;
read_file(client_cert_file, cert_pem);
read_file(client_private_key_file, key_pem);
ws::WebSocketClient::PemMemory pem = {cert_pem.c_str(), cert_pem.size(),
key_pem.c_str(), key_pem.size(),
private_key_password};
ws::WebSocketClient client(url(), pem);
ASSERT_TRUE(client.is_valid());
client.enable_server_certificate_verification(false);
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
EXPECT_EQ(ws::Text, client.read(msg));
EXPECT_EQ("hello", msg);
client.close();
}
std::unique_ptr<SSLServer> server_;
std::thread server_thread_;
int port_ = 0;
};
TEST_F(WebSocketSSLPemMemoryTest, ClientCertAccepted) {
ConnectWithClientCert(CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE, nullptr);
}
// Control for the tests above: the fixture's server really does require a
// client certificate, so it is the PEM the constructor installed that decides
// the outcome.
TEST_F(WebSocketSSLPemMemoryTest, NoClientCertRejected) {
ws::WebSocketClient client(url());
ASSERT_TRUE(client.is_valid());
client.enable_server_certificate_verification(false);
EXPECT_FALSE(client.connect());
}
TEST_F(WebSocketSSLPemMemoryTest, EncryptedClientCertAccepted) {
ConnectWithClientCert(CLIENT_ENCRYPTED_CERT_FILE,
CLIENT_ENCRYPTED_PRIVATE_KEY_FILE,
CLIENT_ENCRYPTED_PRIVATE_KEY_PASS);
}
#endif
#if !defined(_WIN32)
TEST(SymlinkTest, SymlinkEscapeFromBaseDirectory) {
auto secret_dir = std::string("./symlink_test_secret");
auto served_dir = std::string("./symlink_test_served");
auto secret_file = secret_dir + "/secret.txt";
auto symlink_path = served_dir + "/escape";
// Setup: create directories and files
mkdir(secret_dir.c_str(), 0755);
mkdir(served_dir.c_str(), 0755);
{
std::ofstream ofs(secret_file);
ofs << "SECRET_DATA";
}
// Create symlink using absolute path so it resolves correctly
#ifdef PATH_MAX
char abs_secret[PATH_MAX];
ASSERT_NE(nullptr, realpath(secret_dir.c_str(), abs_secret));
#else
auto abs_secret = realpath(secret_dir.c_str(), nullptr);
auto abs_secret_guard = detail::scope_exit([&]() { std::free(abs_secret); });
ASSERT_NE(nullptr, abs_secret);
#endif
ASSERT_EQ(0, symlink(abs_secret, symlink_path.c_str()));
auto se = detail::scope_exit([&] {
unlink(symlink_path.c_str());
unlink(secret_file.c_str());
rmdir(served_dir.c_str());
rmdir(secret_dir.c_str());
});
Server svr;
svr.set_mount_point("/", served_dir);
auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
auto se2 = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
});
svr.wait_until_ready();
Client cli("localhost", PORT);
// Symlink pointing outside base dir should be blocked
auto res = cli.Get("/escape/secret.txt");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::Forbidden_403, res->status);
}
#endif
// 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");
std::atomic<int> smuggled_count(0);
svr.Get("/smuggled", [&](const Request &, Response &res) {
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(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
auto error = Error::Success;
auto sock = detail::create_client_socket(
"localhost", "", port, AF_UNSPEC, false, false, nullptr,
/*connection_timeout_sec=*/2, 0,
/*read_timeout_sec=*/2, 0,
/*write_timeout_sec=*/2, 0, std::string(), error);
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 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)
auto sent = send(sock, head.data(), head.size(), 0);
EXPECT_EQ(static_cast<ssize_t>(head.size()), sent);
// Step 2: Read the first response
char buf[4096];
for (;;) {
auto n = recv(sock, buf, sizeof(buf), 0);
if (n <= 0) break;
first_response.append(buf, static_cast<size_t>(n));
// Stop once we have a complete response (headers + body)
auto hdr_end = first_response.find("\r\n\r\n");
if (hdr_end != std::string::npos) {
// Check for Content-Length to know when the body is complete
auto cl_pos = first_response.find("Content-Length:");
if (cl_pos != std::string::npos) {
auto cl_val_start = cl_pos + 15; // length of "Content-Length:"
auto cl_val_end = first_response.find("\r\n", cl_val_start);
auto cl = std::stoul(
first_response.substr(cl_val_start, cl_val_end - cl_val_start));
if (first_response.size() >= hdr_end + 4 + cl) { break; }
} else {
break; // No Content-Length, assume headers-only response
}
}
}
// 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);
// 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;
}
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) {
// RFC 9112 §6.3: A request with both Content-Length and Transfer-Encoding
// must be rejected with 400 Bad Request.
Server svr;
svr.Post("/test", [&](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Exact "chunked"
{
auto req = "POST /test HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: 5\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"\r\n"
"hello";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
EXPECT_EQ("HTTP/1.1 400 Bad Request",
response.substr(0, response.find("\r\n")));
}
// Multi-valued Transfer-Encoding (e.g., "gzip, chunked")
{
auto req = "POST /test HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: 5\r\n"
"Transfer-Encoding: gzip, chunked\r\n"
"Connection: close\r\n"
"\r\n"
"hello";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
EXPECT_EQ("HTTP/1.1 400 Bad Request",
response.substr(0, response.find("\r\n")));
}
}
TEST(RequestSmugglingTest, NonFinalChunkedTransferEncodingRejected) {
Server svr;
svr.Post("/test", [&](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// RFC 9112 6.3: when chunked is not the final transfer coding the body
// length cannot be determined, so the server must answer 400 and close
// rather than treat the request as bodyless and leave the body in the
// socket for the next request to pick up.
for (const auto &transfer_encoding : {"gzip", "chunked, gzip"}) {
auto req = std::string("POST /test HTTP/1.1\r\n") + "Host: localhost\r\n" +
"Transfer-Encoding: " + transfer_encoding + "\r\n" + "\r\n";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
EXPECT_EQ("HTTP/1.1 400 Bad Request",
response.substr(0, response.find("\r\n")))
<< transfer_encoding;
}
// RFC 9110 5.3: the codings may also be split across several
// Transfer-Encoding lines, which combine in the order they were received.
// "chunked" followed by "gzip" therefore ends in gzip and must be rejected
// just like the single-line "chunked, gzip" above.
{
auto req = "POST /test HTTP/1.1\r\n"
"Host: localhost\r\n"
"Transfer-Encoding: chunked\r\n"
"Transfer-Encoding: gzip\r\n"
"\r\n"
"0\r\n\r\n";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
EXPECT_EQ("HTTP/1.1 400 Bad Request",
response.substr(0, response.find("\r\n")));
}
// A sequence ending in chunked stays valid.
auto req = "POST /test HTTP/1.1\r\n"
"Host: localhost\r\n"
"Transfer-Encoding: gzip, chunked\r\n"
"Connection: close\r\n"
"\r\n"
"0\r\n\r\n";
std::string response;
ASSERT_TRUE(send_request(1, req, &response));
EXPECT_EQ("HTTP/1.1 200 OK", response.substr(0, response.find("\r\n")));
// ...including when it is spread over several lines.
auto split_req = "POST /test HTTP/1.1\r\n"
"Host: localhost\r\n"
"Transfer-Encoding: gzip\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"\r\n"
"0\r\n\r\n";
std::string split_response;
ASSERT_TRUE(send_request(1, split_req, &split_response));
EXPECT_EQ("HTTP/1.1 200 OK",
split_response.substr(0, split_response.find("\r\n")));
}
// Regression for issue #2450: a DELETE without Content-Length on a
// keep-alive connection must not let the post-response drain consume the
// next request's bytes.
TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) {
Server svr;
std::atomic<int> delete_count(0);
svr.Delete("/items/:id", [&](const Request &, Response &res) {
delete_count++;
res.status = StatusCode::NoContent_204;
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
auto error = Error::Success;
auto sock = detail::create_client_socket(
HOST, "", port, AF_UNSPEC, false, false, nullptr,
/*connection_timeout_sec=*/2, 0,
/*read_timeout_sec=*/2, 0,
/*write_timeout_sec=*/2, 0, std::string(), error);
ASSERT_NE(INVALID_SOCKET, sock);
auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
auto send_request_and_read_response = [&](const std::string &req,
std::string &out) -> bool {
auto sent = send(sock, req.data(), req.size(), 0);
if (sent != static_cast<ssize_t>(req.size())) { return false; }
char buf[4096];
for (;;) {
auto n = recv(sock, buf, sizeof(buf), 0);
if (n <= 0) { return !out.empty(); }
out.append(buf, static_cast<size_t>(n));
if (out.find("\r\n\r\n") != std::string::npos) { return true; }
}
};
std::string req1 = "DELETE /items/1 HTTP/1.1\r\n"
"Host: localhost\r\n"
"\r\n";
std::string resp1;
ASSERT_TRUE(send_request_and_read_response(req1, resp1));
EXPECT_NE(std::string::npos, resp1.find("HTTP/1.1 204"));
std::string req2 = "DELETE /items/2 HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n";
std::string resp2;
ASSERT_TRUE(send_request_and_read_response(req2, resp2));
EXPECT_NE(std::string::npos, resp2.find("HTTP/1.1 204"));
EXPECT_EQ(2, delete_count.load());
}
TEST(KeepAliveTest, UnconsumedChunkedBodyIsNotDrainedWhenResponseCloses) {
Server svr;
svr.Post("/ingest", [&](const Request &, Response &res,
const ContentReader &content_reader) {
auto consumed = content_reader([](const char *, size_t) { return false; });
EXPECT_FALSE(consumed);
res.status = StatusCode::Conflict_409;
res.set_header("Connection", "close");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
auto error = Error::Success;
auto sock = detail::create_client_socket(
HOST, "", port, AF_UNSPEC, false, false, nullptr,
/*connection_timeout_sec=*/2, 0,
/*read_timeout_sec=*/1, 0,
/*write_timeout_sec=*/2, 0, std::string(), error);
ASSERT_NE(INVALID_SOCKET, sock);
auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
std::string request = "POST /ingest HTTP/1.1\r\n"
"Host: localhost\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"4\r\n"
"data\r\n";
auto sent = send(sock, request.data(), request.size(), 0);
ASSERT_EQ(static_cast<ssize_t>(request.size()), sent);
std::string response;
ssize_t received = 0;
do {
char buf[4096];
received = recv(sock, buf, sizeof(buf), 0);
if (received > 0) { response.append(buf, static_cast<size_t>(received)); }
} while (received > 0);
EXPECT_NE(std::string::npos, response.find("HTTP/1.1 409 Conflict"));
EXPECT_NE(std::string::npos, response.find("Connection: close"));
EXPECT_EQ(0, received);
}
namespace no_proxy_test {
// Server bound to 127.0.0.1:<dynamic>, listen thread spawned by listen(),
// auto-stopped + joined on scope exit. Register handlers via svr() BEFORE
// calling listen().
class ScopedServer {
public:
ScopedServer() { port_ = svr_.bind_to_any_port("127.0.0.1"); }
~ScopedServer() {
svr_.stop();
if (th_.joinable()) { th_.join(); }
}
Server &svr() { return svr_; }
int port() const { return port_; }
void listen() {
th_ = std::thread([this] { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
private:
Server svr_;
std::thread th_;
int port_ = 0;
};
class ProxyAndTargetServers {
public:
ProxyAndTargetServers() {
proxy_mock_.Get(".*", [this](const Request &req, Response &res) {
proxy_hits_++;
last_had_proxy_authz_ = req.has_header("Proxy-Authorization");
res.set_content("via-proxy", "text/plain");
});
target_.Get(".*", [this](const Request &req, Response &res) {
target_hits_++;
last_had_proxy_authz_ = req.has_header("Proxy-Authorization");
res.set_content("direct", "text/plain");
});
proxy_port_ = proxy_mock_.bind_to_any_port("127.0.0.1");
target_port_ = target_.bind_to_any_port("127.0.0.1");
proxy_thread_ = std::thread([this] { proxy_mock_.listen_after_bind(); });
target_thread_ = std::thread([this] { target_.listen_after_bind(); });
proxy_mock_.wait_until_ready();
target_.wait_until_ready();
}
~ProxyAndTargetServers() {
proxy_mock_.stop();
target_.stop();
if (proxy_thread_.joinable()) { proxy_thread_.join(); }
if (target_thread_.joinable()) { target_thread_.join(); }
}
Server &proxy_mock() { return proxy_mock_; }
Server &target() { return target_; }
int proxy_port() const { return proxy_port_; }
int target_port() const { return target_port_; }
int proxy_hits() const { return proxy_hits_.load(); }
int target_hits() const { return target_hits_.load(); }
bool last_had_proxy_authz() const { return last_had_proxy_authz_.load(); }
void reset_counters() {
proxy_hits_ = 0;
target_hits_ = 0;
last_had_proxy_authz_ = false;
}
private:
Server proxy_mock_;
Server target_;
std::thread proxy_thread_;
std::thread target_thread_;
int proxy_port_ = 0;
int target_port_ = 0;
std::atomic<int> proxy_hits_{0};
std::atomic<int> target_hits_{0};
std::atomic<bool> last_had_proxy_authz_{false};
};
inline std::unique_ptr<Client> make_client(const std::string &host,
ProxyAndTargetServers &s) {
auto cli = detail::make_unique<Client>(host, s.target_port());
cli->set_hostname_addr_map({{host, "127.0.0.1"}});
cli->set_proxy("127.0.0.1", s.proxy_port());
return cli;
}
} // namespace no_proxy_test
using no_proxy_test::make_client;
using no_proxy_test::ProxyAndTargetServers;
TEST(NoProxyTest, ExactHostnameBypasses) {
ProxyAndTargetServers s;
auto cli = make_client("example.com", s);
cli->set_no_proxy({"example.com"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, SubdomainBypasses) {
ProxyAndTargetServers s;
auto cli = make_client("foo.example.com", s);
cli->set_no_proxy({"example.com"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, EvilExampleDoesNotMatchExample) {
// Regression guard: "evilexample.com" must not be considered a subdomain
// of "example.com". Without the dot-boundary rule, a naive endsWith
// check would let traffic bypass the proxy and leak credentials direct
// to the attacker host.
ProxyAndTargetServers s;
auto cli = make_client("evilexample.com", s);
cli->set_no_proxy({"example.com"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, ExampleDotEvilDoesNotMatchExample) {
ProxyAndTargetServers s;
auto cli = make_client("example.com.evil.com", s);
cli->set_no_proxy({"example.com"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, LeadingDotPatternMatchesBareDomain) {
ProxyAndTargetServers s;
auto cli = make_client("example.com", s);
cli->set_no_proxy({".example.com"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, CaseInsensitiveHostname) {
ProxyAndTargetServers s;
auto cli = make_client("Example.COM", s);
cli->set_no_proxy({"EXAMPLE.com"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, TrailingDotIsNormalized) {
ProxyAndTargetServers s;
auto cli = make_client("example.com.", s);
cli->set_no_proxy({"example.com"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, TrailingDotOnEntryIsNormalized) {
// Trailing dots must be normalized on BOTH sides — host and entry.
// An implementation that only strips the host-side trailing dot would
// fail to match host "example.com" against entry "example.com." because
// a literal substring search would compare 11 chars against 12.
ProxyAndTargetServers s;
auto cli = make_client("example.com", s);
cli->set_no_proxy({"example.com."});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, WildcardBypassesEverything) {
ProxyAndTargetServers s;
auto cli = make_client("anything.invalid.test", s);
cli->set_no_proxy({"*"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, IPv4LiteralExactMatch) {
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"127.0.0.1"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, IPv6LiteralExactMatchAcrossEquivalentForms) {
ProxyAndTargetServers s;
Client cli("0:0:0:0:0:0:0:1", s.target_port());
cli.set_hostname_addr_map({{"0:0:0:0:0:0:0:1", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"::1"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, BareIPv6LiteralMatchesIPv6Cidr) {
// Regression guard: a bare IPv6 host literal (no surrounding brackets)
// must still be recognized as IPv6 for CIDR matching. Implementations
// that detect IPv6 only when the host begins with '[' would parse the
// host as a hostname and miss the match.
ProxyAndTargetServers s;
Client cli("fe80::1", s.target_port());
cli.set_hostname_addr_map({{"fe80::1", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"fe80::/10"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, BracketedIPv6EntryAccepted) {
ProxyAndTargetServers s;
Client cli("::1", s.target_port());
cli.set_hostname_addr_map({{"::1", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"[::1]"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, BracketedIPv6CidrEntryAccepted) {
ProxyAndTargetServers s;
Client cli("fe80::1", s.target_port());
cli.set_hostname_addr_map({{"fe80::1", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"[fe80::]/10"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, IPv4MappedIPv6IsNotCrossMatchedAgainstIPv4Entry) {
// Policy: keep address families separate. "::ffff:1.2.3.4" must NOT
// satisfy a NO_PROXY entry of "1.2.3.4". This avoids subtle bypass
// tricks via address-family conversion.
ProxyAndTargetServers s;
Client cli("::ffff:127.0.0.1", s.target_port());
cli.set_hostname_addr_map({{"::ffff:127.0.0.1", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"127.0.0.1"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, IPv4CidrMatch) {
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"127.0.0.0/8"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, IPv4CidrNonMatch) {
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"10.0.0.0/8"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, IPv4CidrPrefixZeroMatchesAll) {
// Prefix 0 must not trigger the (1u << 32) shift UB. Result: every
// IPv4 target matches.
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"0.0.0.0/0"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, IPv4CidrSingleHostNoSlash) {
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"127.0.0.1"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, MalformedCidrPrefixIsDropped) {
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"127.0.0.0/33"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, TrailingSlashCidrIsRejected) {
// Empty prefix after the slash must be rejected, not silently treated as /32.
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({"127.0.0.1/"});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, ProxyAuthorizationSuppressedWhenBypassed) {
ProxyAndTargetServers s;
auto cli = make_client("internal.corp", s);
cli->set_proxy_basic_auth("u", "p");
cli->set_no_proxy({"internal.corp"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(1, s.target_hits());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_FALSE(s.last_had_proxy_authz())
<< "Proxy-Authorization must not be sent direct to the target";
}
TEST(NoProxyTest, ProxyAuthorizationSentWhenNotBypassed) {
ProxyAndTargetServers s;
auto cli = make_client("public.example", s);
cli->set_proxy_basic_auth("u", "p");
cli->set_no_proxy({"internal.corp"}); // does not match
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_TRUE(s.last_had_proxy_authz());
}
TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) {
ProxyAndTargetServers s;
auto cli = make_client("anything.test", s);
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, PortSpecificEntryRejected) {
ProxyAndTargetServers s;
auto cli = make_client("example.com", s);
cli->set_no_proxy({"example.com:8080"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, EmptyAndWhitespaceEntriesDropped) {
ProxyAndTargetServers s;
auto cli = make_client("anything.test", s);
cli->set_no_proxy({"", " ", "\t"});
auto res = cli->Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(s.proxy_hits(), 1);
EXPECT_EQ(0, s.target_hits());
}
TEST(NoProxyTest, ValidEntryWithSurroundingWhitespaceStillMatches) {
// An entry with leading/trailing whitespace must still match — env-style
// values are commonly pasted with stray spaces and an implementation
// that feeds the raw token directly to inet_pton would fail to match
// valid CIDRs because of the spaces.
ProxyAndTargetServers s;
Client cli("127.0.0.1", s.target_port());
cli.set_proxy("127.0.0.1", s.proxy_port());
cli.set_no_proxy({" 127.0.0.0/8 "});
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(0, s.proxy_hits());
EXPECT_EQ(1, s.target_hits());
}
TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) {
// Analog of GHSA-6hrp-7fq9-3qv2: redirect to a NO_PROXY-matched host must
// go direct and must NOT carry Proxy-Authorization.
std::atomic<int> proxy_hits{0};
std::atomic<int> target_hits{0};
std::atomic<bool> target_saw_authz{false};
no_proxy_test::ScopedServer proxy_mock, target;
proxy_mock.svr().Get(".*", [&](const Request &, Response &res) {
proxy_hits++;
res.status = 302;
res.set_header("Location", "http://127.0.0.1:" +
std::to_string(target.port()) + "/landed");
});
target.svr().Get(".*", [&](const Request &req, Response &res) {
target_hits++;
if (req.has_header("Proxy-Authorization")) { target_saw_authz = true; }
res.set_content("direct", "text/plain");
});
proxy_mock.listen();
target.listen();
Client cli("public.example", target.port());
cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", proxy_mock.port());
cli.set_proxy_basic_auth("u", "p");
cli.set_no_proxy({"127.0.0.1"});
cli.set_follow_location(true);
auto res = cli.Get("/redir");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_GE(proxy_hits.load(), 1);
EXPECT_GE(target_hits.load(), 1);
EXPECT_FALSE(target_saw_authz.load());
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) {
// Direct origin replying 407 must not trigger the digest retry; otherwise
// proxy creds would be sent to the (possibly hostile) origin.
std::atomic<int> target_hits{0};
std::atomic<bool> target_saw_proxy_authz{false};
no_proxy_test::ScopedServer target;
target.svr().Get(".*", [&](const Request &req, Response &res) {
target_hits++;
if (req.has_header("Proxy-Authorization")) {
target_saw_proxy_authz = true;
}
res.status = StatusCode::ProxyAuthenticationRequired_407;
res.set_header("Proxy-Authenticate", "Digest realm=\"evil\", qop=\"auth\", "
"nonce=\"abc\", algorithm=MD5");
});
target.listen();
// The proxy address is set to the target's port: the bypass MUST kick in;
// if it doesn't, the test still routes "via proxy" to the same server and
// the Proxy-Authorization assertion below catches the leak.
Client cli("evil.example", target.port());
cli.set_hostname_addr_map({{"evil.example", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", target.port());
cli.set_proxy_digest_auth("proxy-user", "proxy-pass");
cli.set_no_proxy({"evil.example"});
auto res = cli.Get("/x");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status);
EXPECT_EQ(1, target_hits.load());
EXPECT_FALSE(target_saw_proxy_authz.load());
}
#endif
TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) {
// A (via proxy) → B (NO_PROXY-matched, direct) → C must re-engage the proxy.
std::atomic<int> proxy_hits{0};
std::atomic<int> bypass_hits{0};
std::atomic<bool> proxy_saw_c_url{false};
no_proxy_test::ScopedServer proxy_mock, bypass_server;
proxy_mock.svr().Get(".*", [&](const Request &req, Response &res) {
proxy_hits++;
if (req.path.find("/start") != std::string::npos) {
res.status = 302;
res.set_header("Location", "http://127.0.0.1:" +
std::to_string(bypass_server.port()) +
"/middle");
return;
}
if (req.path.find("/end") != std::string::npos) {
proxy_saw_c_url = true;
res.set_content("c-via-proxy", "text/plain");
return;
}
res.set_content("unexpected", "text/plain");
});
// public.example's "advertised" port is arbitrary (the request never lands
// there — it goes through the proxy), but use a dynamic value to stay
// friendly with sharded parallel runs.
int public_port = bypass_server.port();
bypass_server.svr().Get(".*", [&](const Request &, Response &res) {
bypass_hits++;
res.status = 302;
res.set_header("Location", "http://public.example:" +
std::to_string(public_port) + "/end");
});
proxy_mock.listen();
bypass_server.listen();
Client cli("public.example", public_port);
cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", proxy_mock.port());
cli.set_no_proxy({"127.0.0.1"});
cli.set_follow_location(true);
auto res = cli.Get("/start");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("c-via-proxy", res->body);
EXPECT_GE(proxy_hits.load(), 2);
EXPECT_GE(bypass_hits.load(), 1);
EXPECT_TRUE(proxy_saw_c_url.load());
}
TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) {
// Mid-session set_no_proxy must drop any keep-alive socket so the next
// request reconnects to the correct endpoint.
std::atomic<int> proxy_hits{0};
std::atomic<int> target_hits{0};
no_proxy_test::ScopedServer proxy_mock, target;
proxy_mock.svr().Get(".*", [&](const Request &, Response &res) {
proxy_hits++;
res.set_content("via-proxy", "text/plain");
});
target.svr().Get(".*", [&](const Request &, Response &res) {
target_hits++;
res.set_content("direct", "text/plain");
});
proxy_mock.listen();
target.listen();
Client cli("public.example", target.port());
cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}});
cli.set_proxy("127.0.0.1", proxy_mock.port());
cli.set_keep_alive(true);
auto res1 = cli.Get("/a");
ASSERT_TRUE(res1);
EXPECT_EQ("via-proxy", res1->body);
EXPECT_EQ(1, proxy_hits.load());
EXPECT_EQ(0, target_hits.load());
cli.set_no_proxy({"public.example"});
auto res2 = cli.Get("/b");
ASSERT_TRUE(res2);
EXPECT_EQ("direct", res2->body);
EXPECT_EQ(1, proxy_hits.load());
EXPECT_EQ(1, target_hits.load());
}
#if defined(CPPHTTPLIB_SSL_ENABLED) && !defined(_WIN32)
namespace proxy_tunnel_test {
// SSLServer counterpart to no_proxy_test::ScopedServer.
class ScopedSSLServer {
public:
ScopedSSLServer() : svr_(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE) {
port_ = svr_.bind_to_any_port("127.0.0.1");
}
~ScopedSSLServer() {
svr_.stop();
if (th_.joinable()) { th_.join(); }
}
SSLServer &svr() { return svr_; }
int port() const { return port_; }
void listen() {
th_ = std::thread([this] { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
private:
SSLServer svr_;
std::thread th_;
int port_ = 0;
};
// Accepts CONNECT, replies 200, byte-forwards to forward_port.
class ScopedConnectProxy {
public:
explicit ScopedConnectProxy(int forward_port) : forward_port_(forward_port) {
listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd_ < 0) { return; }
int yes = 1;
::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
a.sin_port = 0;
if (::bind(listen_fd_, reinterpret_cast<sockaddr *>(&a), sizeof(a)) != 0) {
return;
}
socklen_t alen = sizeof(a);
if (::getsockname(listen_fd_, reinterpret_cast<sockaddr *>(&a), &alen) !=
0) {
return;
}
port_ = ntohs(a.sin_port);
if (::listen(listen_fd_, 1) != 0) { return; }
th_ = std::thread([this] { run(); });
}
~ScopedConnectProxy() {
stop_ = true;
if (listen_fd_ >= 0) {
::shutdown(listen_fd_, SHUT_RDWR);
::close(listen_fd_);
}
if (th_.joinable()) { th_.join(); }
}
int port() const { return port_; }
int connect_hits() const { return connect_hits_.load(); }
// Head of the last CONNECT request, as the proxy saw it on the wire.
std::string connect_request() const {
std::lock_guard<std::mutex> lock(connect_request_mutex_);
return connect_request_;
}
private:
void run() {
while (!stop_.load()) {
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(listen_fd_, &rfds);
timeval tv{0, 100 * 1000};
int sel = ::select(listen_fd_ + 1, &rfds, nullptr, nullptr, &tv);
if (sel <= 0) { continue; }
int client_fd = ::accept(listen_fd_, nullptr, nullptr);
if (client_fd < 0) { continue; }
std::string req;
char buf[2048];
while (req.find("\r\n\r\n") == std::string::npos) {
ssize_t n = ::recv(client_fd, buf, sizeof(buf), 0);
if (n <= 0) { break; }
req.append(buf, static_cast<size_t>(n));
}
if (req.compare(0, 7, "CONNECT") != 0) {
::close(client_fd);
continue;
}
connect_hits_++;
{
std::lock_guard<std::mutex> lock(connect_request_mutex_);
connect_request_ = req;
}
const char *ok = "HTTP/1.1 200 Connection established\r\n\r\n";
::send(client_fd, ok, std::strlen(ok), 0);
int origin_fd = ::socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in o{};
o.sin_family = AF_INET;
o.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
o.sin_port = htons(static_cast<unsigned short>(forward_port_));
if (::connect(origin_fd, reinterpret_cast<sockaddr *>(&o), sizeof(o)) !=
0) {
::close(client_fd);
::close(origin_fd);
continue;
}
auto forward = [](int from, int to) {
char b[8192];
for (;;) {
ssize_t n = ::recv(from, b, sizeof(b), 0);
if (n <= 0) { break; }
ssize_t off = 0;
while (off < n) {
ssize_t s = ::send(to, b + off, static_cast<size_t>(n - off), 0);
if (s <= 0) {
off = n;
break;
}
off += s;
}
}
::shutdown(to, SHUT_WR);
};
std::thread t1([&] { forward(client_fd, origin_fd); });
std::thread t2([&] { forward(origin_fd, client_fd); });
t1.join();
t2.join();
::close(client_fd);
::close(origin_fd);
}
}
int forward_port_ = -1;
int listen_fd_ = -1;
int port_ = 0;
std::thread th_;
std::atomic<bool> stop_{false};
std::atomic<int> connect_hits_{0};
mutable std::mutex connect_request_mutex_;
std::string connect_request_;
};
// Sends one request through the CONNECT proxy to the TLS origin with a
// credential configured for each hop, and checks that each credential reaches
// only the hop it was configured for: the proxy's on the CONNECT request, the
// origin's (every header in origin_headers) on the tunnelled request.
void CredentialsStayWithTheirHop(
const std::function<void(SSLClient &)> &set_credentials,
const std::string &scheme,
const std::vector<std::string> &origin_headers = {"Authorization"}) {
std::atomic<int> origin_hits{0};
std::atomic<bool> origin_saw_proxy_authz{false};
std::atomic<bool> origin_saw_headers{false};
ScopedSSLServer origin;
origin.svr().Get(".*", [&](const Request &req, Response &res) {
origin_hits++;
origin_saw_proxy_authz = req.has_header("Proxy-Authorization");
origin_saw_headers =
std::all_of(origin_headers.begin(), origin_headers.end(),
[&](const std::string &h) { return req.has_header(h); });
res.set_content("ok", "text/plain");
});
origin.listen();
ScopedConnectProxy proxy(origin.port());
ASSERT_NE(0, proxy.port());
// Pinned to 127.0.0.1 for the same reason as the test below.
SSLClient cli("127.0.0.1", origin.port());
cli.enable_server_certificate_verification(false);
cli.set_proxy("127.0.0.1", proxy.port());
set_credentials(cli);
auto res = cli.Get("/x");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1, origin_hits.load());
EXPECT_EQ(1, proxy.connect_hits());
EXPECT_TRUE(origin_saw_headers.load());
EXPECT_FALSE(origin_saw_proxy_authz.load())
<< "Proxy-Authorization must not be sent inside the tunnel";
auto connect_req = proxy.connect_request();
EXPECT_NE(std::string::npos,
connect_req.find("\r\nProxy-Authorization: " + scheme + " "));
for (const auto &h : origin_headers) {
EXPECT_EQ(std::string::npos, connect_req.find("\r\n" + h + ": "))
<< h << " must not be sent to the proxy on CONNECT";
}
}
} // namespace proxy_tunnel_test
TEST(ProxyTunnelTest, BasicCredentialsStayWithTheirHop) {
proxy_tunnel_test::CredentialsStayWithTheirHop(
[](SSLClient &cli) {
cli.set_proxy_basic_auth("proxy-user", "proxy-pass");
cli.set_basic_auth("origin-user", "origin-pass");
},
"Basic");
}
TEST(ProxyTunnelTest, BearerCredentialsStayWithTheirHop) {
proxy_tunnel_test::CredentialsStayWithTheirHop(
[](SSLClient &cli) {
cli.set_proxy_bearer_token_auth("proxy-token");
cli.set_bearer_token_auth("origin-token");
},
"Bearer");
}
TEST(ProxyTunnelTest, DefaultHeadersStayOffConnect) {
proxy_tunnel_test::CredentialsStayWithTheirHop(
[](SSLClient &cli) {
cli.set_proxy_basic_auth("proxy-user", "proxy-pass");
cli.set_default_headers({{"Authorization", "Bearer origin-token"},
{"Cookie", "sid=origin-session"},
{"X-Api-Key", "origin-key"}});
},
"Basic", {"Authorization", "Cookie", "X-Api-Key"});
}
TEST(ProxyTunnelTest, OriginReturning407InsideTunnelDoesNotLeakProxyDigest) {
// Origin inside a CONNECT tunnel replying 407 must not trigger the digest
// retry; otherwise proxy creds would be sent to the origin.
std::atomic<int> origin_hits{0};
std::atomic<bool> origin_saw_proxy_authz{false};
proxy_tunnel_test::ScopedSSLServer origin;
origin.svr().Get(".*", [&](const Request &req, Response &res) {
origin_hits++;
if (req.has_header("Proxy-Authorization")) {
origin_saw_proxy_authz = true;
}
res.status = StatusCode::ProxyAuthenticationRequired_407;
res.set_header("Proxy-Authenticate",
"Digest realm=\"evil\", qop=\"auth\", nonce=\"abc\", "
"algorithm=MD5");
});
origin.listen();
proxy_tunnel_test::ScopedConnectProxy proxy(origin.port());
ASSERT_NE(0, proxy.port());
// Pin to 127.0.0.1: the proxy listens on 127.0.0.1 only, while "localhost"
// may resolve to ::1 first. A concurrent test (e.g. another gtest shard)
// holding ::1 with the same ephemeral port number would hijack the CONNECT
// and answer with its own status, making this test flaky.
SSLClient cli("127.0.0.1", origin.port());
cli.enable_server_certificate_verification(false);
cli.set_proxy("127.0.0.1", proxy.port());
cli.set_proxy_digest_auth("proxy-user", "proxy-pass");
auto res = cli.Get("/x");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status);
EXPECT_EQ(1, origin_hits.load());
EXPECT_FALSE(origin_saw_proxy_authz.load());
EXPECT_EQ(1, proxy.connect_hits());
}
#endif