srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md40
-rw-r--r--include/packeteer/l7/dissector.hpp38
-rw-r--r--include/packeteer/l7/quic.hpp23
-rw-r--r--include/packeteer/l7/tls.hpp196
-rw-r--r--include/packeteer/summarize.hpp13
-rw-r--r--tests/test_dissector.cpp37
-rw-r--r--tests/test_quic.cpp19
-rw-r--r--tests/test_tls.cpp115
8 files changed, 437 insertions, 44 deletions
diff --git a/PLAN.md b/PLAN.md
index a4db288..9837ebb 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -587,3 +587,43 @@ None currently open.
correctly decoded a real RTP video stream (payload type 96,
incrementing sequence numbers, one consistent SSRC across the whole
stream) and a real RTCP Sender Report ffmpeg sent alongside it.
+- Deeper TLS: ServerHello (negotiated version, cipher suite) alongside
+ the existing ClientHello (SNI) support, plus ClientHello's ALPN
+ extension. Both hellos share almost all of their wire structure, so
+ the record/handshake header parsing was factored into one shared
+ detail::read_tls_handshake() rather than duplicated a second time.
+ ServerHello's negotiated_version prefers the supported_versions
+ extension over legacy_version when present: TLS 1.3 always sets
+ legacy_version to 0x0303 (TLS 1.2) for middlebox compatibility, so
+ reading only that field would misreport every real TLS 1.3
+ connection as 1.2. Cipher suite names are hardcoded only for TLS
+ 1.3's five suites (RFC 8446 B.4, a small closed set) - everything
+ else is reported as a raw hex value rather than guessed at from a
+ curated "common suites" list, which would be more misleading than a
+ plain number for the suites it didn't happen to cover.
+ Live-verified against a real Cloudflare TLS 1.3 handshake on
+ wlp1s0: "TLS ServerHello version=TLS1.3 cipher=TLS_AES_256_GCM_SHA384"
+ from cloudflare.com's actual production server, confirming both the
+ supported_versions override and the cipher-suite naming.
+ That same live capture surfaced a real, unrelated bug in the QUIC
+ dissector added earlier this session: QuicDissector was also being
+ tried against *TCP* port-443 payloads (a side effect of the
+ L7Registry fix that let QUIC and TLS share port 443 at all), and
+ produced real false "QUIC" labels on TLS 1.3 ciphertext continuation
+ fragments - large encrypted records split across multiple TCP
+ segments, each fed to the parser independently since this project
+ doesn't reassemble by default, so a later fragment's effectively
+ random bytes occasionally passed as a plausible QUIC header. Fixed
+ in two layers: (1) parse_quic() now enforces RFC 9000 17.2's real
+ 20-byte cap on connection ID lengths, closing most of the
+ long-header false-positive surface; (2) L7Dissector gained a
+ transport() method (default kAny, preserving every other
+ dissector's exact current behavior unchanged) so QuicDissector could
+ declare itself UDP-only - necessary because layer (1) alone
+ couldn't touch QUIC's short-header form at all, which by design has
+ no structural signal beyond one bit once header protection can't be
+ removed without connection state. Re-verified against the identical
+ live scenario: zero false QUIC labels on the same Cloudflare TCP
+ handshake afterward, and a repeat of the earlier real HTTP/3 capture
+ against google.com confirmed genuine QUIC traffic still decodes
+ correctly on UDP.
diff --git a/include/packeteer/l7/dissector.hpp b/include/packeteer/l7/dissector.hpp
index b640fcc..5a56f09 100644
--- a/include/packeteer/l7/dissector.hpp
+++ b/include/packeteer/l7/dissector.hpp
@@ -11,6 +11,8 @@
// without touching the L2-L4 decode path or main.cpp's dispatch logic.
namespace packeteer::net {
+enum class Transport { kTcp, kUdp, kAny };
+
class L7Dissector {
public:
virtual ~L7Dissector() = default;
@@ -21,6 +23,20 @@ public:
// this later without changing the registry's shape.
virtual std::uint16_t port() const = 0;
+ // Which transport this dissector applies to. Defaults to kAny --
+ // for every protocol here except one, the port number alone was
+ // already an unambiguous filter (DNS/NTP/DHCP only ever run over
+ // UDP, HTTP/SSH/FTP only over TCP, and so on), so this was never
+ // needed until QUIC: it shares port 443 with TLS but runs
+ // *exclusively* over UDP. Trying it against TCP payloads produced
+ // real false positives - its short-header form in particular has
+ // no structural signal beyond one bit, since header protection
+ // can't be removed without connection state, so essentially any
+ // TCP ciphertext continuation fragment on port 443 could pass.
+ // Caught via live capture against real cloudflare.com TLS traffic,
+ // not by inspection.
+ virtual Transport transport() const { return Transport::kAny; }
+
// A one-line summary of the payload, or nullopt if it doesn't look
// like this protocol (e.g. truncated/malformed).
virtual std::optional<std::string> summarize(std::span<const unsigned char> payload) const = 0;
@@ -30,18 +46,22 @@ class L7Registry {
public:
void add(const L7Dissector* dissector) { dissectors_.push_back(dissector); }
- // Tries every dissector registered for `port`, not just the
- // first: two different protocols can genuinely share a
- // well-known port number when one runs over TCP and the other
- // over UDP (443 is TLS/HTTPS over TCP *and* QUIC/HTTP3 over UDP)
- // - this registry has no transport dimension, only a port
- // number, so without this a dissector registered earlier for the
- // same port would permanently shadow a later one the moment both
- // exist, even on payloads the earlier one can't actually parse.
- std::optional<std::string> dissect(std::uint16_t port,
+ // Tries every dissector registered for `port` whose transport
+ // matches (or declares kAny), not just the first: two different
+ // protocols can genuinely share a well-known port number when one
+ // runs over TCP and the other over UDP (443 is TLS/HTTPS over TCP
+ // *and* QUIC/HTTP3 over UDP) - without this a dissector
+ // registered earlier for the same port would permanently shadow a
+ // later one the moment both exist, even on payloads the earlier
+ // one can't actually parse, or on a transport it was never meant
+ // to run over at all.
+ std::optional<std::string> dissect(std::uint16_t port, Transport transport,
std::span<const unsigned char> payload) const {
for (const auto* dissector : dissectors_) {
if (dissector->port() != port) continue;
+ if (dissector->transport() != Transport::kAny && dissector->transport() != transport) {
+ continue;
+ }
if (auto summary = dissector->summarize(payload)) return summary;
}
return std::nullopt;
diff --git a/include/packeteer/l7/quic.hpp b/include/packeteer/l7/quic.hpp
index 9c9ac85..a675902 100644
--- a/include/packeteer/l7/quic.hpp
+++ b/include/packeteer/l7/quic.hpp
@@ -69,13 +69,25 @@ inline std::optional<QuicPacket> parse_quic(std::span<const unsigned char> bytes
std::size_t pos = 5;
if (pos >= bytes.size()) return std::nullopt;
std::uint8_t dcid_len = bytes[pos++];
- if (pos + dcid_len > bytes.size()) return std::nullopt;
+ // RFC 9000 17.2: connection ID length MUST NOT exceed 20 bytes for
+ // this QUIC version - a real protocol constraint, not an invented
+ // heuristic, and one that matters here: this dissector runs on
+ // every unmatched TCP/UDP segment on port 443, including mid-record
+ // TLS ciphertext continuation fragments (large TLS records split
+ // across several TCP segments, each fed through independently
+ // since this project doesn't reassemble by default). Those
+ // fragments are effectively random bytes to this parser, and
+ // without this bound they passed the earlier checks often enough
+ // to produce real false "QUIC" labels on genuine TLS traffic --
+ // caught via live capture against cloudflare.com, not by
+ // inspection.
+ if (dcid_len > 20 || pos + dcid_len > bytes.size()) return std::nullopt;
std::vector<unsigned char> dcid(bytes.begin() + pos, bytes.begin() + pos + dcid_len);
pos += dcid_len;
if (pos >= bytes.size()) return std::nullopt;
std::uint8_t scid_len = bytes[pos++];
- if (pos + scid_len > bytes.size()) return std::nullopt;
+ if (scid_len > 20 || pos + scid_len > bytes.size()) return std::nullopt;
std::vector<unsigned char> scid(bytes.begin() + pos, bytes.begin() + pos + scid_len);
QuicLongPacketType type;
@@ -122,6 +134,13 @@ class QuicDissector : public L7Dissector {
public:
std::uint16_t port() const override { return kQuicPort; }
+ // QUIC runs exclusively over UDP - declaring this (rather than
+ // the default kAny) is what actually stops this dissector from
+ // ever being tried against TCP:443 payloads at all, which the
+ // DCID/SCID length bound above can't do on its own for the
+ // short-header case (see dissector.hpp's Transport comment).
+ Transport transport() const override { return Transport::kUdp; }
+
std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
auto pkt = parse_quic(payload);
if (!pkt) return std::nullopt;
diff --git a/include/packeteer/l7/tls.hpp b/include/packeteer/l7/tls.hpp
index 40893fc..2d0d08f 100644
--- a/include/packeteer/l7/tls.hpp
+++ b/include/packeteer/l7/tls.hpp
@@ -1,23 +1,26 @@
#pragma once
#include <cstdint>
+#include <cstdio>
#include <optional>
#include <span>
#include <string>
+#include <vector>
#include "packeteer/byteio.hpp"
#include "packeteer/l7/dissector.hpp"
-// TLS ClientHello -> SNI extension parsing. Most web traffic is TLS
-// today, so HTTP alone covers a shrinking fraction of it - SNI is what
+// TLS handshake parsing: ClientHello (SNI, ALPN) and ServerHello
+// (negotiated version, cipher suite). Most web traffic is TLS today,
+// so HTTP alone covers a shrinking fraction of it - this is what
// makes a packet analyzer useful against that traffic without
-// decrypting anything: the server name is sent in cleartext in the
-// ClientHello, before any encryption starts, in every TLS version this
-// parses (the ClientHello/extension wire format hasn't changed across
-// versions - only what happens after it has).
+// decrypting anything: everything read here is sent in cleartext,
+// before any encryption starts, in every TLS version that uses this
+// wire format for the handshake (only what happens *after* the
+// handshake has changed across versions).
//
// Same scope as the other L7 dissectors: single-segment, best-effort.
-// A ClientHello padded across multiple TCP segments (large cookie/PSK
+// A hello padded across multiple TCP segments (large cookie/PSK
// extensions, unusual but possible) is only partially visible here.
// Every length field is bounds-checked against what's actually left in
// the buffer before use - this is exactly the kind of nested,
@@ -28,31 +31,51 @@ namespace packeteer::net {
inline constexpr std::uint16_t kTlsPort = 443;
inline constexpr std::uint8_t kTlsContentTypeHandshake = 0x16;
inline constexpr std::uint8_t kTlsHandshakeTypeClientHello = 0x01;
+inline constexpr std::uint8_t kTlsHandshakeTypeServerHello = 0x02;
inline constexpr std::uint16_t kTlsExtensionServerName = 0x0000;
+inline constexpr std::uint16_t kTlsExtensionAlpn = 0x0010;
+inline constexpr std::uint16_t kTlsExtensionSupportedVersions = 0x002B;
-struct TlsClientHello {
- std::optional<std::string> server_name; // SNI, if the extension was present and well-formed
+namespace detail {
+
+struct TlsHandshakeMessage {
+ std::uint8_t handshake_type;
+ std::span<const unsigned char> body; // exactly the declared handshake length, bounds-checked
};
-inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsigned char> bytes) {
- // Record header: ContentType(1) ProtocolVersion(2) Length(2)
+// Record header: ContentType(1) ProtocolVersion(2) Length(2), then
+// Handshake header: HandshakeType(1) Length(3, 24-bit BE). Shared by
+// ClientHello and ServerHello - both are handshake messages inside a
+// TLS record, differing only in handshake_type and everything after
+// this point.
+inline std::optional<TlsHandshakeMessage> read_tls_handshake(std::span<const unsigned char> bytes) {
if (bytes.size() < 5) return std::nullopt;
if (bytes[0] != kTlsContentTypeHandshake) return std::nullopt;
std::uint16_t record_len = read_be16(bytes, 3);
if (bytes.size() < static_cast<std::size_t>(5) + record_len) return std::nullopt;
std::span<const unsigned char> handshake = bytes.subspan(5);
-
- // Handshake header: HandshakeType(1) Length(3, 24-bit BE)
if (handshake.size() < 4) return std::nullopt;
- if (handshake[0] != kTlsHandshakeTypeClientHello) return std::nullopt;
std::uint32_t hs_len = (static_cast<std::uint32_t>(handshake[1]) << 16) |
(static_cast<std::uint32_t>(handshake[2]) << 8) |
static_cast<std::uint32_t>(handshake[3]);
std::span<const unsigned char> body = handshake.subspan(4);
if (body.size() < hs_len) return std::nullopt;
- body = body.first(hs_len); // never read past the declared handshake body
+ return TlsHandshakeMessage{handshake[0], body.first(hs_len)}; // never read past hs_len
+}
+
+} // namespace detail
+
+struct TlsClientHello {
+ std::optional<std::string> server_name; // SNI, if present and well-formed
+ std::optional<std::vector<std::string>> alpn_protocols; // in the client's preference order
+};
+
+inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsigned char> bytes) {
+ auto handshake = detail::read_tls_handshake(bytes);
+ if (!handshake || handshake->handshake_type != kTlsHandshakeTypeClientHello) return std::nullopt;
+ auto body = handshake->body;
std::size_t offset = 0;
@@ -82,7 +105,7 @@ inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsi
offset += compression_len;
TlsClientHello hello;
- if (offset == body.size()) return hello; // no extensions block: no SNI, still a valid hello
+ if (offset == body.size()) return hello; // no extensions block: still a valid hello
// extensions: length(2) + data
if (body.size() < offset + 2) return std::nullopt;
@@ -114,6 +137,25 @@ inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsi
reinterpret_cast<const char*>(body.data() + name_start), name_len);
}
}
+ } else if (ext_type == kTlsExtensionAlpn && ext_len >= 2) {
+ // ProtocolNameList: list_len(2) + entries, each a
+ // length(1)-prefixed protocol name (e.g. "h2", "http/1.1").
+ std::uint16_t list_len = read_be16(body, ext_data_start);
+ std::size_t list_start = ext_data_start + 2;
+ std::size_t list_end = list_start + list_len;
+ if (list_end <= ext_data_end) {
+ std::vector<std::string> protocols;
+ std::size_t pos = list_start;
+ while (pos < list_end) {
+ std::uint8_t name_len = body[pos];
+ ++pos;
+ if (pos + name_len > list_end) break; // malformed entry: stop, keep what we have
+ protocols.emplace_back(reinterpret_cast<const char*>(body.data() + pos),
+ name_len);
+ pos += name_len;
+ }
+ if (!protocols.empty()) hello.alpn_protocols = std::move(protocols);
+ }
}
offset = ext_data_end;
@@ -122,17 +164,127 @@ inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsi
return hello;
}
+struct TlsServerHello {
+ std::uint16_t cipher_suite; // the single negotiated suite, not a list
+ std::uint16_t negotiated_version; // from supported_versions if present, else legacy_version
+};
+
+inline std::optional<TlsServerHello> parse_tls_server_hello(std::span<const unsigned char> bytes) {
+ auto handshake = detail::read_tls_handshake(bytes);
+ if (!handshake || handshake->handshake_type != kTlsHandshakeTypeServerHello) return std::nullopt;
+ auto body = handshake->body;
+
+ std::size_t offset = 0;
+
+ // legacy_version(2) - TLS 1.3 always sets this to 0x0303 (TLS
+ // 1.2) for middlebox compatibility; the real version, if 1.3, is
+ // only signaled via the supported_versions extension below.
+ if (body.size() < offset + 2) return std::nullopt;
+ std::uint16_t legacy_version = read_be16(body, offset);
+ offset += 2;
+
+ // random(32)
+ if (body.size() < offset + 32) return std::nullopt;
+ offset += 32;
+
+ // legacy_session_id_echo: length(1) + data
+ if (body.size() < offset + 1) return std::nullopt;
+ std::uint8_t session_id_len = body[offset];
+ offset += 1;
+ if (body.size() < offset + session_id_len) return std::nullopt;
+ offset += session_id_len;
+
+ // cipher_suite(2): a single value here, unlike ClientHello's list
+ if (body.size() < offset + 2) return std::nullopt;
+ std::uint16_t cipher_suite = read_be16(body, offset);
+ offset += 2;
+
+ // legacy_compression_method(1)
+ if (body.size() < offset + 1) return std::nullopt;
+ offset += 1;
+
+ TlsServerHello hello{cipher_suite, legacy_version};
+ if (offset == body.size()) return hello; // no extensions: pre-TLS-1.3 hello, legacy_version stands
+
+ // extensions: length(2) + data
+ if (body.size() < offset + 2) return std::nullopt;
+ std::uint16_t extensions_len = read_be16(body, offset);
+ offset += 2;
+ if (body.size() < static_cast<std::size_t>(offset) + extensions_len) return std::nullopt;
+ std::size_t extensions_end = offset + extensions_len;
+
+ while (offset + 4 <= extensions_end) {
+ std::uint16_t ext_type = read_be16(body, offset);
+ std::uint16_t ext_len = read_be16(body, offset + 2);
+ std::size_t ext_data_start = offset + 4;
+ std::size_t ext_data_end = ext_data_start + ext_len;
+ if (ext_data_end > extensions_end) break;
+
+ // In a ServerHello (unlike ClientHello), supported_versions
+ // carries exactly one 2-byte version - the one actually
+ // negotiated - not a list of offered ones.
+ if (ext_type == kTlsExtensionSupportedVersions && ext_len == 2) {
+ hello.negotiated_version = read_be16(body, ext_data_start);
+ }
+
+ offset = ext_data_end;
+ }
+
+ return hello;
+}
+
+inline std::string tls_version_name(std::uint16_t version) {
+ switch (version) {
+ case 0x0301: return "TLS1.0";
+ case 0x0302: return "TLS1.1";
+ case 0x0303: return "TLS1.2";
+ case 0x0304: return "TLS1.3";
+ default: return "unknown";
+ }
+}
+
+// Only TLS 1.3's cipher suites are named - a small, closed set (five
+// total, RFC 8446 B.4). Earlier TLS versions have hundreds of IANA-
+// registered suites; guessing at a curated subset of "common" ones
+// would be more misleading than useful, so anything else is reported
+// by its raw hex value instead of a guessed name.
+inline std::string tls_cipher_suite_name(std::uint16_t cipher_suite) {
+ switch (cipher_suite) {
+ case 0x1301: return "TLS_AES_128_GCM_SHA256";
+ case 0x1302: return "TLS_AES_256_GCM_SHA384";
+ case 0x1303: return "TLS_CHACHA20_POLY1305_SHA256";
+ case 0x1304: return "TLS_AES_128_CCM_SHA256";
+ case 0x1305: return "TLS_AES_128_CCM_8_SHA256";
+ default: {
+ char buf[8];
+ std::snprintf(buf, sizeof(buf), "0x%04x", cipher_suite);
+ return buf;
+ }
+ }
+}
+
class TlsSniDissector : public L7Dissector {
public:
std::uint16_t port() const override { return kTlsPort; }
std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
- auto hello = parse_tls_client_hello(payload);
- if (!hello) return std::nullopt;
-
- std::string out = "TLS ClientHello";
- if (hello->server_name) out += " SNI=" + *hello->server_name;
- return out;
+ if (auto hello = parse_tls_client_hello(payload)) {
+ std::string out = "TLS ClientHello";
+ if (hello->server_name) out += " SNI=" + *hello->server_name;
+ if (hello->alpn_protocols) {
+ out += " ALPN=";
+ for (std::size_t i = 0; i < hello->alpn_protocols->size(); ++i) {
+ if (i > 0) out += ",";
+ out += (*hello->alpn_protocols)[i];
+ }
+ }
+ return out;
+ }
+ if (auto server_hello = parse_tls_server_hello(payload)) {
+ return "TLS ServerHello version=" + tls_version_name(server_hello->negotiated_version) +
+ " cipher=" + tls_cipher_suite_name(server_hello->cipher_suite);
+ }
+ return std::nullopt;
}
};
diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp
index 79ec283..c538cec 100644
--- a/include/packeteer/summarize.hpp
+++ b/include/packeteer/summarize.hpp
@@ -151,9 +151,10 @@ inline const net::L7Registry& l7_registry() {
// to a well-known server port), then the source port (a server's
// reply, coming from that same well-known port).
inline std::optional<std::string> l7_summarize(std::span<const unsigned char> payload,
- std::uint16_t src_port, std::uint16_t dst_port) {
- if (auto summary = l7_registry().dissect(dst_port, payload)) return summary;
- return l7_registry().dissect(src_port, payload);
+ std::uint16_t src_port, std::uint16_t dst_port,
+ net::Transport transport) {
+ if (auto summary = l7_registry().dissect(dst_port, transport, payload)) return summary;
+ return l7_registry().dissect(src_port, transport, payload);
}
// IPv4 and IPv6 headers carry different fields (ttl vs. hop_limit,
@@ -184,7 +185,8 @@ inline std::string summarize_transport_and_above(const IpInfo& info) {
tcp_flags_to_string(tcp->header.flags).c_str(), tcp->header.seq,
tcp->header.ack, tcp->header.window);
out += tcp_buf;
- if (auto l7 = l7_summarize(tcp->payload, tcp->header.src_port, tcp->header.dst_port)) {
+ if (auto l7 = l7_summarize(tcp->payload, tcp->header.src_port, tcp->header.dst_port,
+ net::Transport::kTcp)) {
out += " | " + *l7;
}
}
@@ -194,7 +196,8 @@ inline std::string summarize_transport_and_above(const IpInfo& info) {
std::snprintf(udp_buf, sizeof(udp_buf), " | UDP %u -> %u len=%u",
udp->header.src_port, udp->header.dst_port, udp->header.length);
out += udp_buf;
- if (auto l7 = l7_summarize(udp->payload, udp->header.src_port, udp->header.dst_port)) {
+ if (auto l7 = l7_summarize(udp->payload, udp->header.src_port, udp->header.dst_port,
+ net::Transport::kUdp)) {
out += " | " + *l7;
} else if (auto rtcp = net::parse_rtcp_heuristic(udp->payload)) {
// RTP/RTCP have no fixed port to key a real dissector
diff --git a/tests/test_dissector.cpp b/tests/test_dissector.cpp
index 9dd4135..4517ae7 100644
--- a/tests/test_dissector.cpp
+++ b/tests/test_dissector.cpp
@@ -23,9 +23,10 @@ private:
class AlwaysMatchesDissector : public L7Dissector {
public:
- AlwaysMatchesDissector(std::uint16_t port, std::string label)
- : port_(port), label_(std::move(label)) {}
+ AlwaysMatchesDissector(std::uint16_t port, std::string label, Transport transport = Transport::kAny)
+ : port_(port), label_(std::move(label)), transport_(transport) {}
std::uint16_t port() const override { return port_; }
+ Transport transport() const override { return transport_; }
std::optional<std::string> summarize(std::span<const unsigned char>) const override {
return label_;
}
@@ -33,6 +34,7 @@ public:
private:
std::uint16_t port_;
std::string label_;
+ Transport transport_;
};
} // namespace
@@ -46,7 +48,7 @@ TEST_CASE("L7Registry falls through to a later dissector on the same port "
registry.add(&tls_like);
registry.add(&quic_like);
- auto result = registry.dissect(443, {});
+ auto result = registry.dissect(443, Transport::kTcp, {});
REQUIRE(result.has_value());
CHECK(*result == "QUIC something");
}
@@ -59,7 +61,7 @@ TEST_CASE("L7Registry still returns the first dissector to succeed, not the last
registry.add(&first);
registry.add(&second);
- auto result = registry.dissect(80, {});
+ auto result = registry.dissect(80, Transport::kTcp, {});
REQUIRE(result.has_value());
CHECK(*result == "first");
}
@@ -70,5 +72,30 @@ TEST_CASE("L7Registry returns nullopt when no dissector on the port matches") {
L7Registry registry;
registry.add(&only);
- CHECK_FALSE(registry.dissect(443, {}).has_value());
+ CHECK_FALSE(registry.dissect(443, Transport::kTcp, {}).has_value());
+}
+
+TEST_CASE("L7Registry skips a dissector whose declared transport doesn't match, even on the "
+ "right port") {
+ // The actual fix for QUIC's false positives on TCP:443: a
+ // dissector that only claims UDP must never be tried against a
+ // TCP payload on the same port, regardless of what its own
+ // summarize() would have returned.
+ AlwaysMatchesDissector udp_only(443, "UDP thing", Transport::kUdp);
+
+ L7Registry registry;
+ registry.add(&udp_only);
+
+ CHECK_FALSE(registry.dissect(443, Transport::kTcp, {}).has_value());
+ CHECK(registry.dissect(443, Transport::kUdp, {}).has_value());
+}
+
+TEST_CASE("L7Registry's default kAny transport matches either TCP or UDP") {
+ AlwaysMatchesDissector any(53, "DNS-like"); // default transport: kAny
+
+ L7Registry registry;
+ registry.add(&any);
+
+ CHECK(registry.dissect(53, Transport::kTcp, {}).has_value());
+ CHECK(registry.dissect(53, Transport::kUdp, {}).has_value());
}
diff --git a/tests/test_quic.cpp b/tests/test_quic.cpp
index 2366176..4b02be5 100644
--- a/tests/test_quic.cpp
+++ b/tests/test_quic.cpp
@@ -78,6 +78,25 @@ TEST_CASE("parse_quic rejects a long-header packet whose DCID length exceeds the
CHECK_FALSE(parse_quic(bytes).has_value());
}
+TEST_CASE("parse_quic rejects a DCID/SCID length past RFC 9000's 20-byte cap even with room in "
+ "the buffer") {
+ // A real protocol bound, not a buffer-size check: plenty of bytes
+ // are available here, the claimed length is just illegal for this
+ // QUIC version. This is what actually stops a mid-record TLS
+ // ciphertext continuation fragment (effectively random bytes to
+ // this parser, since this project doesn't reassemble TCP by
+ // default) from occasionally passing as a plausible QUIC header --
+ // found via live capture against real cloudflare.com traffic, not
+ // by inspection.
+ std::vector<unsigned char> bytes = {0xC0, 0x00, 0x00, 0x00, 0x01, 21};
+ bytes.resize(bytes.size() + 21, 0xAA); // plenty of room for a 21-byte DCID
+ CHECK_FALSE(parse_quic(bytes).has_value());
+
+ std::vector<unsigned char> scid_bytes = {0xC0, 0x00, 0x00, 0x00, 0x01, 0, 21};
+ scid_bytes.resize(scid_bytes.size() + 21, 0xAA); // plenty of room for a 21-byte SCID
+ CHECK_FALSE(parse_quic(scid_bytes).has_value());
+}
+
TEST_CASE("QuicDissector claims port 443 and formats an Initial packet") {
QuicDissector dissector;
CHECK(dissector.port() == kQuicPort);
diff --git a/tests/test_tls.cpp b/tests/test_tls.cpp
index 7a3ad27..bdb5300 100644
--- a/tests/test_tls.cpp
+++ b/tests/test_tls.cpp
@@ -22,7 +22,8 @@ void append_be16(std::vector<unsigned char>& out, std::uint16_t v) {
// length instead of the real one (computed here, not via post-hoc
// offset math into the finished buffer - equally fragile).
std::vector<unsigned char> build_client_hello(const std::string& sni,
- bool corrupt_sni_ext_len = false) {
+ bool corrupt_sni_ext_len = false,
+ std::vector<std::string> alpn_protocols = {}) {
std::vector<unsigned char> body;
body.push_back(0x03);
body.push_back(0x03); // client_version: TLS 1.2
@@ -52,6 +53,20 @@ std::vector<unsigned char> build_client_hello(const std::string& sni,
append_be16(extensions, ext_len);
extensions.insert(extensions.end(), sni_ext_data.begin(), sni_ext_data.end());
}
+ if (!alpn_protocols.empty()) {
+ std::vector<unsigned char> protocol_list;
+ for (const auto& proto : alpn_protocols) {
+ protocol_list.push_back(static_cast<unsigned char>(proto.size()));
+ protocol_list.insert(protocol_list.end(), proto.begin(), proto.end());
+ }
+ std::vector<unsigned char> alpn_ext_data;
+ append_be16(alpn_ext_data, static_cast<std::uint16_t>(protocol_list.size()));
+ alpn_ext_data.insert(alpn_ext_data.end(), protocol_list.begin(), protocol_list.end());
+
+ append_be16(extensions, kTlsExtensionAlpn);
+ append_be16(extensions, static_cast<std::uint16_t>(alpn_ext_data.size()));
+ extensions.insert(extensions.end(), alpn_ext_data.begin(), alpn_ext_data.end());
+ }
append_be16(body, static_cast<std::uint16_t>(extensions.size()));
body.insert(body.end(), extensions.begin(), extensions.end());
@@ -73,6 +88,47 @@ std::vector<unsigned char> build_client_hello(const std::string& sni,
return record;
}
+// Builds a real, well-formed TLS record containing a ServerHello.
+// `negotiated_version`, when set, adds a supported_versions extension
+// carrying that single 2-byte version - how a TLS 1.3 ServerHello
+// actually signals its real version, since legacy_version is always
+// 0x0303 there for middlebox compatibility.
+std::vector<unsigned char> build_server_hello(std::uint16_t cipher_suite,
+ std::optional<std::uint16_t> negotiated_version = {}) {
+ std::vector<unsigned char> body;
+ append_be16(body, 0x0303); // legacy_version: always TLS 1.2 on the wire
+ body.insert(body.end(), 32, 0x00); // random
+ body.push_back(0x00); // legacy_session_id_echo length: 0
+ append_be16(body, cipher_suite);
+ body.push_back(0x00); // legacy_compression_method: null
+
+ std::vector<unsigned char> extensions;
+ if (negotiated_version) {
+ append_be16(extensions, kTlsExtensionSupportedVersions);
+ append_be16(extensions, 2);
+ append_be16(extensions, *negotiated_version);
+ }
+ append_be16(body, static_cast<std::uint16_t>(extensions.size()));
+ body.insert(body.end(), extensions.begin(), extensions.end());
+
+ std::vector<unsigned char> handshake;
+ handshake.push_back(kTlsHandshakeTypeServerHello);
+ std::uint32_t hs_len = static_cast<std::uint32_t>(body.size());
+ handshake.push_back(static_cast<unsigned char>((hs_len >> 16) & 0xFF));
+ handshake.push_back(static_cast<unsigned char>((hs_len >> 8) & 0xFF));
+ handshake.push_back(static_cast<unsigned char>(hs_len & 0xFF));
+ handshake.insert(handshake.end(), body.begin(), body.end());
+
+ std::vector<unsigned char> record;
+ record.push_back(kTlsContentTypeHandshake);
+ record.push_back(0x03);
+ record.push_back(0x03);
+ append_be16(record, static_cast<std::uint16_t>(handshake.size()));
+ record.insert(record.end(), handshake.begin(), handshake.end());
+
+ return record;
+}
+
} // namespace
TEST_CASE("parse_tls_client_hello extracts a real SNI extension") {
@@ -130,3 +186,60 @@ TEST_CASE("TlsSniDissector claims port 443 and its summary matches parse_tls_cli
CHECK(summary->substr(0, 3) == "TLS");
CHECK(summary->find("SNI=packeteer.test") != std::string::npos);
}
+
+TEST_CASE("parse_tls_client_hello extracts ALPN protocols in order") {
+ auto record = build_client_hello("example.com", false, {"h2", "http/1.1"});
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value());
+ REQUIRE(hello->alpn_protocols.has_value());
+ REQUIRE(hello->alpn_protocols->size() == 2);
+ CHECK((*hello->alpn_protocols)[0] == "h2");
+ CHECK((*hello->alpn_protocols)[1] == "http/1.1");
+}
+
+TEST_CASE("parse_tls_client_hello leaves alpn_protocols unset when the extension is absent") {
+ auto record = build_client_hello("example.com");
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value());
+ CHECK_FALSE(hello->alpn_protocols.has_value());
+}
+
+TEST_CASE("parse_tls_server_hello extracts the negotiated cipher suite") {
+ auto record = build_server_hello(0x1301); // TLS_AES_128_GCM_SHA256
+ auto hello = parse_tls_server_hello(record);
+ REQUIRE(hello.has_value());
+ CHECK(hello->cipher_suite == 0x1301);
+}
+
+TEST_CASE("parse_tls_server_hello uses legacy_version when there's no supported_versions extension") {
+ auto record = build_server_hello(0xC02F); // a TLS 1.2-era cipher suite
+ auto hello = parse_tls_server_hello(record);
+ REQUIRE(hello.has_value());
+ CHECK(hello->negotiated_version == 0x0303); // TLS 1.2
+}
+
+TEST_CASE("parse_tls_server_hello prefers supported_versions over legacy_version for TLS 1.3") {
+ auto record = build_server_hello(0x1301, std::uint16_t{0x0304});
+ auto hello = parse_tls_server_hello(record);
+ REQUIRE(hello.has_value());
+ CHECK(hello->negotiated_version == 0x0304); // TLS 1.3, not the legacy 0x0303 on the wire
+}
+
+TEST_CASE("parse_tls_server_hello rejects a ClientHello handshake type") {
+ auto record = build_client_hello("example.com");
+ CHECK_FALSE(parse_tls_server_hello(record).has_value());
+}
+
+TEST_CASE("tls_cipher_suite_name names all five TLS 1.3 suites and falls back to hex otherwise") {
+ CHECK(tls_cipher_suite_name(0x1301) == "TLS_AES_128_GCM_SHA256");
+ CHECK(tls_cipher_suite_name(0x1305) == "TLS_AES_128_CCM_8_SHA256");
+ CHECK(tls_cipher_suite_name(0xC02F) == "0xc02f");
+}
+
+TEST_CASE("TlsSniDissector formats a ServerHello with version and cipher") {
+ TlsSniDissector dissector;
+ auto record = build_server_hello(0x1301, std::uint16_t{0x0304});
+ auto summary = dissector.summarize(record);
+ REQUIRE(summary.has_value());
+ CHECK(*summary == "TLS ServerHello version=TLS1.3 cipher=TLS_AES_128_GCM_SHA256");
+}