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>
This commit is contained in:
parent
9c22cbe09e
commit
39ff5806ed
5 changed files with 656 additions and 2 deletions
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