https #6
5 changed files with 656 additions and 2 deletions
test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.
ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.
ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.
Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commit
39ff5806ed
|
|
@ -235,7 +235,9 @@ struct ListenerHTTP1::Impl {
|
|||
|
||||
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
||||
: ListenerHTTP1(port, std::move(routes), {})
|
||||
// Spelled out rather than `{}`: with a TLS overload also taking a third
|
||||
// argument, a braced empty initialiser no longer names one constructor.
|
||||
: ListenerHTTP1(port, std::move(routes), std::function<HTTPResponse(const HTTPRequest&)>{})
|
||||
{}
|
||||
|
||||
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||
|
|
@ -255,7 +257,8 @@ ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
|||
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
TLSServerCredentials credentials)
|
||||
: ListenerHTTP1(port, std::move(routes), {}, std::move(credentials))
|
||||
: ListenerHTTP1(port, std::move(routes),
|
||||
std::function<HTTPResponse(const HTTPRequest&)>{}, std::move(credentials))
|
||||
{}
|
||||
|
||||
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||
|
|
|
|||
|
|
@ -134,10 +134,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldRequireClientCertificateHTTPS1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldSend").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldSendRecieveHTTP1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldSendRecieveHTTPS1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg });
|
||||
|
|
|
|||
257
tests/ShouldInteropCurlHTTPS1/main.cpp
Normal file
257
tests/ShouldInteropCurlHTTPS1/main.cpp
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// TLS interop against implementations that are not this library. Speaking
|
||||
// HTTP/1.1 correctly to ourselves proves very little about the record layer —
|
||||
// an OpenSSL client and an OpenSSL server can agree on a mistake.
|
||||
//
|
||||
// * curl drives our TLS listener, verifying our certificate properly with
|
||||
// --cacert: keep-alive reuse, POST, HEAD, ALPN, and a status line.
|
||||
// * ClientHTTP1 drives python3's http.server behind ssl.wrap_socket, which
|
||||
// answers HTTP/1.0 with `Connection: close` — so the response is framed by
|
||||
// close_notify rather than by content-length.
|
||||
//
|
||||
// Both peers are optional: a missing curl or python3 skips its half rather
|
||||
// than failing, so the suite still runs on a bare machine.
|
||||
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <stdio.h>
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
bool HaveCommand(std::string_view name) {
|
||||
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
|
||||
return std::system(probe.c_str()) == 0;
|
||||
}
|
||||
|
||||
std::string Run(const std::string& command) {
|
||||
std::string output;
|
||||
FILE* pipe = popen((command + " 2>&1").c_str(), "r");
|
||||
if (pipe == nullptr) return output;
|
||||
char buffer[4096];
|
||||
while (std::size_t read = std::fread(buffer, 1, sizeof(buffer), pipe)) {
|
||||
output.append(buffer, read);
|
||||
}
|
||||
pclose(pipe);
|
||||
return output;
|
||||
}
|
||||
|
||||
// A child process, killed when this goes out of scope.
|
||||
class Child {
|
||||
public:
|
||||
explicit Child(std::vector<std::string> argv) {
|
||||
std::vector<char*> raw;
|
||||
for (auto& argument : argv) raw.push_back(argument.data());
|
||||
raw.push_back(nullptr);
|
||||
pid = fork();
|
||||
if (pid == 0) {
|
||||
freopen("/dev/null", "w", stdout);
|
||||
freopen("/dev/null", "w", stderr);
|
||||
execvp(raw[0], raw.data());
|
||||
_exit(127);
|
||||
}
|
||||
}
|
||||
~Child() {
|
||||
if (pid > 0) {
|
||||
kill(pid, SIGTERM);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
}
|
||||
}
|
||||
Child(const Child&) = delete;
|
||||
bool Started() const { return pid > 0; }
|
||||
|
||||
private:
|
||||
pid_t pid = -1;
|
||||
};
|
||||
|
||||
bool WaitForPort(std::uint16_t port, std::chrono::milliseconds budget) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + budget;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
try {
|
||||
ClientTCP probe("localhost", port);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The development certificate on disk, so peer processes (curl, python)
|
||||
// can be pointed at it.
|
||||
struct CertificateFiles {
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path certificate;
|
||||
std::filesystem::path privateKey;
|
||||
|
||||
CertificateFiles() {
|
||||
directory = std::filesystem::temp_directory_path() / "crafter-network-https1-interop";
|
||||
std::filesystem::create_directories(directory);
|
||||
certificate = directory / "cert.pem";
|
||||
privateKey = directory / "key.pem";
|
||||
const TLSCertificatePem& pem = GetSelfSignedCertificatePem();
|
||||
std::ofstream(certificate, std::ios::binary) << pem.certificate;
|
||||
std::ofstream(privateKey, std::ios::binary) << pem.privateKey;
|
||||
}
|
||||
~CertificateFiles() {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(directory, error);
|
||||
}
|
||||
CertificateFiles(const CertificateFiles&) = delete;
|
||||
};
|
||||
|
||||
void CurlAgainstListener(const CertificateFiles& files) {
|
||||
if (!HaveCommand("curl")) {
|
||||
std::println("skipping the curl half: curl is not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/hello"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, "Hello curl!");
|
||||
};
|
||||
routes["/echo"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.method + ":" + request.body);
|
||||
};
|
||||
routes["/scheme"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.scheme);
|
||||
};
|
||||
|
||||
ListenerAsyncHTTP1 listener(8097, std::move(routes),
|
||||
TLSServerCredentials{ .selfSigned = true });
|
||||
Check(WaitForPort(8097, std::chrono::seconds(2)), "the HTTPS listener came up");
|
||||
|
||||
// --cacert, not --insecure: curl does the full chain and hostname
|
||||
// check, so this is a real verification of what we present.
|
||||
const std::string base = "https://localhost:8097";
|
||||
const std::string curl = "curl -sS --http1.1 --cacert '" + files.certificate.string() + "' ";
|
||||
|
||||
Check(Run(curl + base + "/hello") == "Hello curl!", "curl GET over TLS");
|
||||
Check(Run(curl + base + "/scheme") == "https", "the handler sees scheme=https");
|
||||
|
||||
// Two URLs in one invocation: curl reuses the TLS session, which only
|
||||
// works if our framing let it know the first response ended.
|
||||
const std::uint64_t before = listener.listener.AcceptedCount();
|
||||
Check(Run(curl + base + "/hello " + base + "/hello") == "Hello curl!Hello curl!",
|
||||
"curl got both responses");
|
||||
Check(listener.listener.AcceptedCount() == before + 1,
|
||||
"curl reused one TLS connection for both");
|
||||
|
||||
Check(Run(curl + "-d 'body text' " + base + "/echo") == "POST:body text",
|
||||
"curl POST over TLS");
|
||||
|
||||
const std::string head = Run(curl + "-I " + base + "/hello");
|
||||
Check(head.find("HTTP/1.1 200 OK") != std::string::npos, "curl HEAD status line");
|
||||
Check(head.find("content-length: 11") != std::string::npos, "curl HEAD keeps content-length");
|
||||
Check(head.find("Hello curl!") == std::string::npos, "curl HEAD carries no body");
|
||||
|
||||
Check(Run(curl + "-o /dev/null -w '%{http_code}' " + base + "/missing") == "404",
|
||||
"curl reads the 404 status");
|
||||
|
||||
// curl offers h2 and http/1.1 by default; ours advertises only
|
||||
// http/1.1, so ALPN has to land there. `-w %{...}` reports what the
|
||||
// handshake actually agreed on rather than what we hoped for.
|
||||
Check(Run(curl + "-o /dev/null -w '%{http_version}' " + base + "/hello") == "1.1",
|
||||
"ALPN settled on HTTP/1.1");
|
||||
|
||||
// A client offering only h2 shares no protocol with us. RFC 7301 says
|
||||
// that is a fatal no_application_protocol alert, not a downgrade.
|
||||
if (Run("curl -sS --http2-prior-knowledge -o /dev/null -w '%{http_code}' --cacert '"
|
||||
+ files.certificate.string() + "' " + base + "/hello").find("200")
|
||||
== std::string::npos) {
|
||||
Check(true, "an h2-only client is refused rather than mis-served");
|
||||
} else {
|
||||
Check(false, "an h2-only client is refused rather than mis-served");
|
||||
}
|
||||
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
void ClientAgainstPythonServer(const CertificateFiles& files) {
|
||||
if (!HaveCommand("python3")) {
|
||||
std::println("skipping the python half: python3 is not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::filesystem::path root = files.directory / "www";
|
||||
std::filesystem::create_directories(root);
|
||||
const std::string content = "served by python over TLS\n";
|
||||
std::ofstream(root / "hello.txt", std::ios::binary) << content;
|
||||
|
||||
// http.server answers HTTP/1.0 with `Connection: close`, so over TLS
|
||||
// the response body is framed by close_notify — the path our reader
|
||||
// has to treat as an orderly end rather than a truncation.
|
||||
const std::string script =
|
||||
"import functools, http.server, ssl\n"
|
||||
"context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n"
|
||||
"context.load_cert_chain('" + files.certificate.string() + "', '"
|
||||
+ files.privateKey.string() + "')\n"
|
||||
"handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory='"
|
||||
+ root.string() + "')\n"
|
||||
"server = http.server.HTTPServer(('127.0.0.1', 8098), handler)\n"
|
||||
"server.socket = context.wrap_socket(server.socket, server_side=True)\n"
|
||||
"server.serve_forever()\n";
|
||||
|
||||
Child server({"python3", "-c", script});
|
||||
Check(server.Started(), "python3 TLS http.server was spawned");
|
||||
if (!WaitForPort(8098, std::chrono::seconds(10))) {
|
||||
std::println("skipping the python half: the TLS http.server never came up");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verified against the certificate on disk, exercising caPath (a file)
|
||||
// rather than the caPem blob the self-contained test uses.
|
||||
TLSClientCredentials credentials;
|
||||
credentials.caPath = files.certificate.string();
|
||||
ClientHTTP1 client("localhost", 8098, credentials);
|
||||
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/hello.txt", "localhost:8098"));
|
||||
Check(response.status == "200", "python TLS GET status");
|
||||
Check(response.body == content, "python TLS GET body");
|
||||
Check(!client.Connected(), "an HTTP/1.0 response closes the TLS connection");
|
||||
|
||||
HTTPResponse listing = client.Send(CreateRequestHTTP("GET", "/", "localhost:8098"));
|
||||
Check(listing.status == "200", "python TLS directory listing status");
|
||||
Check(listing.body.find("hello.txt") != std::string::npos,
|
||||
"python TLS directory listing body");
|
||||
|
||||
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nothing-here", "localhost:8098"));
|
||||
Check(missing.status == "404", "python TLS 404");
|
||||
|
||||
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/hello.txt", "localhost:8098"));
|
||||
Check(head.status == "200", "python TLS HEAD status");
|
||||
Check(head.body.empty(), "python TLS HEAD has no body");
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
CertificateFiles files;
|
||||
CurlAgainstListener(files);
|
||||
ClientAgainstPythonServer(files);
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
204
tests/ShouldRequireClientCertificateHTTPS1/main.cpp
Normal file
204
tests/ShouldRequireClientCertificateHTTPS1/main.cpp
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Mutual TLS on the HTTP/1.1 listener, plus the raw :TLS stream API on its
|
||||
// own. A server that asks for a client certificate is only useful if it also
|
||||
// refuses the peers that do not have one, so both directions are asserted.
|
||||
//
|
||||
// The certificate authority and client certificate are minted with the openssl
|
||||
// CLI: a client certificate needs the clientAuth extended key usage, which the
|
||||
// library's built-in development certificate (serverAuth, for listeners) does
|
||||
// not carry. Without openssl the mTLS half is skipped.
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
bool HaveCommand(std::string_view name) {
|
||||
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
|
||||
return std::system(probe.c_str()) == 0;
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> Routes() {
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", "authenticated");
|
||||
};
|
||||
return routes;
|
||||
}
|
||||
|
||||
// A throwaway CA and a client certificate signed by it.
|
||||
struct ClientIdentity {
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path authority;
|
||||
std::filesystem::path certificate;
|
||||
std::filesystem::path privateKey;
|
||||
bool ok = false;
|
||||
|
||||
ClientIdentity() {
|
||||
directory = std::filesystem::temp_directory_path() / "crafter-network-mtls";
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(directory, error);
|
||||
std::filesystem::create_directories(directory);
|
||||
authority = directory / "ca.pem";
|
||||
certificate = directory / "client.pem";
|
||||
privateKey = directory / "client-key.pem";
|
||||
|
||||
const std::string extensions = (directory / "client.ext").string();
|
||||
std::ofstream(extensions, std::ios::binary)
|
||||
<< "basicConstraints=critical,CA:FALSE\n"
|
||||
<< "keyUsage=critical,digitalSignature\n"
|
||||
<< "extendedKeyUsage=clientAuth\n";
|
||||
|
||||
const std::string command = std::format(
|
||||
"set -e\n"
|
||||
"openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes"
|
||||
" -keyout '{0}/ca-key.pem' -out '{1}' -days 5 -subj '/CN=Crafter Test CA'"
|
||||
" -addext 'basicConstraints=critical,CA:TRUE'"
|
||||
" -addext 'keyUsage=critical,keyCertSign'\n"
|
||||
"openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes"
|
||||
" -keyout '{2}' -out '{0}/client.csr' -subj '/CN=crafter-test-client'\n"
|
||||
"openssl x509 -req -in '{0}/client.csr' -CA '{1}' -CAkey '{0}/ca-key.pem'"
|
||||
" -set_serial 2 -days 5 -extfile '{0}/client.ext' -out '{3}'\n",
|
||||
directory.string(), authority.string(), privateKey.string(),
|
||||
certificate.string());
|
||||
ok = std::system((command + " >/dev/null 2>&1").c_str()) == 0;
|
||||
}
|
||||
~ClientIdentity() {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(directory, error);
|
||||
}
|
||||
ClientIdentity(const ClientIdentity&) = delete;
|
||||
};
|
||||
|
||||
// The :TLS layer without any HTTP on top — the case for anything else that
|
||||
// owns a connected socket and wants a record layer over it.
|
||||
void RawStreamAgainstListener() {
|
||||
ListenerAsyncHTTP1 listener(8099, Routes(), TLSServerCredentials{ .selfSigned = true });
|
||||
|
||||
TLSClientCredentials credentials;
|
||||
credentials.caPem = GetSelfSignedCertificatePem().certificate;
|
||||
auto context = TLSContext::Client(credentials);
|
||||
|
||||
ClientTCP socket("localhost", 8099);
|
||||
std::unique_ptr<TLSStream> stream =
|
||||
TLSStream::Connect(socket.socketid, context, "localhost",
|
||||
std::chrono::seconds(5));
|
||||
|
||||
Check(stream->Secure(), "a TLSStream reports itself as secure");
|
||||
Check(stream->Protocol() == "http/1.1", "the raw stream negotiated ALPN");
|
||||
Check(stream->Version().starts_with("TLS"), "a TLS version was negotiated");
|
||||
// The listener presents the development certificate, whose subject is
|
||||
// CN=localhost.
|
||||
Check(stream->PeerCertificateSubject().find("localhost") != std::string::npos,
|
||||
"the server certificate subject is readable");
|
||||
|
||||
// Hand-written HTTP/1.1 straight down the stream, to prove the record
|
||||
// layer is usable on its own.
|
||||
const std::string request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
stream->Write(request.data(), request.size(), std::chrono::seconds(5));
|
||||
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Response);
|
||||
std::vector<char> chunk(4096);
|
||||
while (!parser.Complete()) {
|
||||
std::size_t read = 0;
|
||||
const StreamStatus status =
|
||||
stream->ReadSome(chunk.data(), chunk.size(), std::chrono::seconds(5), read);
|
||||
if (status != StreamStatus::Data) break;
|
||||
parser.Feed(chunk.data(), read);
|
||||
}
|
||||
Check(parser.Complete(), "a response arrived over the raw TLS stream");
|
||||
if (parser.Complete()) {
|
||||
HTTPResponse response = parser.TakeResponse();
|
||||
Check(response.status == "200", "raw TLS stream response status");
|
||||
Check(response.body == "authenticated", "raw TLS stream response body");
|
||||
}
|
||||
|
||||
stream.reset();
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
void MutualTLS() {
|
||||
if (!HaveCommand("openssl")) {
|
||||
std::println("skipping the mTLS half: openssl is not installed");
|
||||
return;
|
||||
}
|
||||
ClientIdentity identity;
|
||||
if (!identity.ok) {
|
||||
std::println("skipping the mTLS half: could not mint a client certificate");
|
||||
return;
|
||||
}
|
||||
|
||||
TLSServerCredentials server;
|
||||
server.selfSigned = true;
|
||||
server.requireClientCertificate = true;
|
||||
server.clientCaPath = identity.authority.string();
|
||||
ListenerAsyncHTTP1 listener(8100, Routes(), server);
|
||||
|
||||
// A client with a certificate the listener's CA vouches for.
|
||||
{
|
||||
TLSClientCredentials credentials;
|
||||
credentials.caPem = GetSelfSignedCertificatePem().certificate;
|
||||
credentials.certPath = identity.certificate.string();
|
||||
credentials.keyPath = identity.privateKey.string();
|
||||
ClientHTTP1 client("localhost", 8100, credentials);
|
||||
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(response.status == "200", "a client with a trusted certificate is served");
|
||||
Check(response.body == "authenticated", "mTLS response body");
|
||||
}
|
||||
Check(listener.listener.HandshakeFailureCount() == 0,
|
||||
"a valid client certificate is not a handshake failure");
|
||||
|
||||
// The same client, with no certificate at all. Under TLS 1.3 the
|
||||
// server's rejection arrives after the client believes the handshake
|
||||
// finished, so the failure can surface at connect *or* on the first
|
||||
// exchange — either is a refusal, and neither may be a success.
|
||||
{
|
||||
TLSClientCredentials credentials;
|
||||
credentials.caPem = GetSelfSignedCertificatePem().certificate;
|
||||
ClientHTTP1 anonymous("localhost", 8100, credentials);
|
||||
bool refused = false;
|
||||
try {
|
||||
anonymous.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
} catch (const std::exception&) {
|
||||
refused = true;
|
||||
}
|
||||
Check(refused, "a client with no certificate is refused");
|
||||
}
|
||||
for (int wait = 0; wait < 100; ++wait) {
|
||||
if (listener.listener.HandshakeFailureCount() > 0) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
Check(listener.listener.HandshakeFailureCount() == 1,
|
||||
"the refused client is counted as a handshake failure");
|
||||
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
RawStreamAgainstListener();
|
||||
MutualTLS();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
187
tests/ShouldSendRecieveHTTPS1/main.cpp
Normal file
187
tests/ShouldSendRecieveHTTPS1/main.cpp
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The HTTP/1.1 round-trip of ShouldSendRecieveHTTP1, over TLS. The point is
|
||||
// that nothing above the transport changed: the same routes, the same
|
||||
// keep-alive reuse, the same 404/500 behaviour, now with libssl underneath.
|
||||
//
|
||||
// Also covers what only exists under TLS: ALPN, `scheme` reported as https,
|
||||
// certificate verification against a private trust anchor, and the two ways
|
||||
// verification is supposed to fail.
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> Routes() {
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", "Hello World!");
|
||||
};
|
||||
routes["/echo"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body);
|
||||
};
|
||||
routes["/scheme"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.scheme);
|
||||
};
|
||||
routes["/query"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.path);
|
||||
};
|
||||
routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse {
|
||||
throw std::runtime_error("handler exploded");
|
||||
};
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
// The listener mints an ephemeral certificate; the client is handed
|
||||
// that same certificate as a trust anchor, so this exercises real
|
||||
// chain *and* hostname verification rather than skipping both.
|
||||
ListenerAsyncHTTP1 listener(8095, Routes(), TLSServerCredentials{ .selfSigned = true });
|
||||
Check(listener.listener.Secure(), "the listener reports itself as https");
|
||||
|
||||
TLSClientCredentials credentials;
|
||||
credentials.caPem = GetSelfSignedCertificatePem().certificate;
|
||||
ClientHTTP1 client("localhost", 8095, credentials);
|
||||
Check(client.Secure(), "the client reports itself as https");
|
||||
|
||||
HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(hello.status == "200", "GET / status");
|
||||
Check(hello.body == "Hello World!", "GET / body");
|
||||
Check(hello.headers.contains("date"), "the server stamps a Date header");
|
||||
Check(hello.headers.at("content-length") == "12", "content-length matches the body");
|
||||
|
||||
// ALPN is the whole reason a TLS server can tell HTTP/1.1 from h2
|
||||
// before reading a byte, so assert it actually got negotiated.
|
||||
Check(client.Protocol() == "http/1.1", "ALPN negotiated http/1.1");
|
||||
|
||||
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost",
|
||||
std::string("ping pong")));
|
||||
Check(echoed.status == "200", "POST /echo status");
|
||||
Check(echoed.body == "ping pong", "POST /echo returns the request body");
|
||||
Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive");
|
||||
|
||||
// Origin-form targets carry no scheme; the transport has to supply it.
|
||||
HTTPResponse scheme = client.Send(CreateRequestHTTP("GET", "/scheme", "localhost"));
|
||||
Check(scheme.body == "https", "the handler sees scheme=https over TLS");
|
||||
|
||||
HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost"));
|
||||
Check(query.status == "200", "query-string request routes to the bare path");
|
||||
Check(query.body == "/query?a=1&b=2", "the handler sees the full target");
|
||||
|
||||
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost"));
|
||||
Check(missing.status == "404", "unknown route is a 404");
|
||||
|
||||
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost"));
|
||||
Check(head.status == "200", "HEAD status");
|
||||
Check(head.body.empty(), "HEAD has no body");
|
||||
Check(head.headers.at("content-length") == "12", "HEAD still advertises the length");
|
||||
|
||||
HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost"));
|
||||
Check(boom.status == "500", "a throwing handler yields 500");
|
||||
Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason");
|
||||
|
||||
// A body big enough to span many TLS records, to catch a Write() that
|
||||
// mishandles a partial SSL_write.
|
||||
const std::string large(512 * 1024, 'z');
|
||||
HTTPResponse bulk = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", large));
|
||||
Check(bulk.status == "200", "large POST status");
|
||||
Check(bulk.body == large, "a body spanning many TLS records survives intact");
|
||||
|
||||
// Every exchange above shared one TLS session — no rehandshaking per
|
||||
// request, which is what makes keep-alive worth having here.
|
||||
Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection");
|
||||
Check(client.Connected(), "the connection is still pooled");
|
||||
Check(listener.listener.HandshakeFailureCount() == 0, "no handshake failed");
|
||||
|
||||
// ── Verification has to actually fail when it should ──────────────
|
||||
// Default credentials: system trust store only, so a self-signed
|
||||
// certificate must be rejected rather than quietly accepted.
|
||||
{
|
||||
ClientHTTP1 strict("localhost", 8095, TLSClientCredentials{});
|
||||
bool rejected = false;
|
||||
try {
|
||||
strict.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
} catch (const TLSException&) {
|
||||
rejected = true;
|
||||
}
|
||||
Check(rejected, "an untrusted self-signed certificate is rejected");
|
||||
Check(!strict.Connected(), "a rejected connection is not left pooled");
|
||||
}
|
||||
|
||||
// Right certificate, wrong name: the chain checks out but the SANs say
|
||||
// localhost, so the name check has to catch it. Verifying the chain
|
||||
// without the name is the classic way TLS gets deployed insecurely.
|
||||
{
|
||||
TLSClientCredentials mismatched;
|
||||
mismatched.caPem = GetSelfSignedCertificatePem().certificate;
|
||||
mismatched.serverName = "not-localhost.invalid";
|
||||
ClientHTTP1 wrongName("localhost", 8095, mismatched);
|
||||
bool rejected = false;
|
||||
try {
|
||||
wrongName.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
} catch (const TLSException&) {
|
||||
rejected = true;
|
||||
}
|
||||
Check(rejected, "a certificate for the wrong name is rejected");
|
||||
}
|
||||
|
||||
// insecureNoServerValidation is the dev escape hatch; it has to work,
|
||||
// because the alternative is people shipping their own worse one.
|
||||
{
|
||||
ClientHTTP1 insecure("localhost", 8095,
|
||||
TLSClientCredentials{ .insecureNoServerValidation = true });
|
||||
HTTPResponse response = insecure.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(response.body == "Hello World!", "insecureNoServerValidation talks to the same server");
|
||||
}
|
||||
|
||||
// A plaintext client against a TLS listener: its request line is not a
|
||||
// TLS record, so the handshake fails and the server counts it. This is
|
||||
// what a port scanner or a misconfigured caller looks like, and it
|
||||
// must not disturb anything else.
|
||||
{
|
||||
const std::uint64_t before = listener.listener.HandshakeFailureCount();
|
||||
try {
|
||||
ClientHTTP1 plaintext("localhost", 8095);
|
||||
plaintext.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
} catch (const std::exception&) {
|
||||
// Expected: the listener drops it without answering.
|
||||
}
|
||||
// The handshake is rejected on the connection thread, so give it a
|
||||
// moment to record the failure before reading the counter.
|
||||
for (int wait = 0; wait < 100; ++wait) {
|
||||
if (listener.listener.HandshakeFailureCount() > before) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
Check(listener.listener.HandshakeFailureCount() == before + 1,
|
||||
"a plaintext peer is counted as a handshake failure");
|
||||
}
|
||||
|
||||
// And the TLS listener still serves after all that.
|
||||
HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(after.body == "Hello World!", "the listener still serves after a bad peer");
|
||||
|
||||
listener.Stop();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue