mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-10-13 10:24:47 +00:00
The SSE spec strips one BOM at the start of the stream, but SSEClient kept it as part of the first field name, so the first event was silently dropped.
27281 lines
883 KiB
C++
27281 lines
883 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¶m=test#fragment";
|
|
|
|
EXPECT_EQ(
|
|
httplib::encode_uri(uri),
|
|
"https://example.com/path/to/resource?query=value¶m=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¬e=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¶m2=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¶m2=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(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, 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, 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
|