Commit dc13cfb931 for openssl.org
commit dc13cfb931200f3f720e3f6f0769e7601dd99801
Author: Neil Horman <nhorman@openssl.org>
Date: Tue Aug 25 15:04:16 2026 -0400
Add a test to check for quic unvalidated credit
Adds a test to ensure that, when sending a coalesced frame that should
drive more than the available unvalidated credit that a server has, we
stop sending when we reach that limit.
Reviewed-by: Saša NedvÄ›dický <sashan@openssl.org>
Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
Reviewed-by: Andrew Dinh <andrewd@openssl.org>
Merge-date: Tue Sep 29 11:52:15 2026
diff --git a/test/quicapitest.c b/test/quicapitest.c
index ddaed7025c..726bdd7e19 100644
--- a/test/quicapitest.c
+++ b/test/quicapitest.c
@@ -9,6 +9,8 @@
#include <stdio.h>
#include <string.h>
+#include <fcntl.h>
+#include <stdint.h>
#include <openssl/opensslconf.h>
#include <openssl/quic.h>
@@ -23,6 +25,7 @@
#include "internal/quic_error.h"
#include "internal/quic_ssl.h"
#include "internal/quic_port.h"
+#include "internal/quic_txp.h"
static OSSL_LIB_CTX *libctx = NULL;
static char *propq = NULL;
@@ -3398,6 +3401,344 @@ err:
return testresult;
}
+#define CLIENT_PORT 4080
+#define SERVER_PORT 8040
+#define QUIC_MDPL 1200
+#define EXTRA_CERTS 40
+
+static int wait_readable(BIO *b, int timeout_ms)
+{
+ uint64_t expire = ossl_time2ticks(ossl_ms2time(ossl_time2ms(ossl_time_now()) + timeout_ms));
+ uint64_t now;
+
+ for (;;) {
+ if (BIO_pending(b))
+ break;
+ now = ossl_time2ticks(ossl_time_now());
+ if (now > expire)
+ return 0;
+ }
+ return 1;
+}
+
+static int drain_server_output(BIO *b, uint64_t *total,
+ size_t *num_datagrams)
+{
+ unsigned char buf[65536];
+ BIO_MSG msg = { 0 };
+ size_t num_processed;
+ int ret = 0;
+
+ for (;;) {
+ msg.data = buf;
+ msg.data_len = 65535;
+ num_processed = 0;
+ if (!BIO_recvmmsg(b, &msg, 1, 1, 0, &num_processed)) {
+ return !!ret;
+ } else {
+ *total += msg.data_len;
+ *num_datagrams += num_processed;
+ ret++;
+ continue;
+ }
+ }
+}
+
+static int send_coalesced_initial(OSSL_QTX *qtx, QUIC_PKT_HDR *hdr,
+ const BIO_ADDR *peer, uint64_t first_pn)
+{
+ static const unsigned char one_padding[1];
+ static const unsigned char final_padding[23];
+ OSSL_QTX_IOVEC iovec = { 0 };
+ OSSL_QTX_PKT pkt = { 0 };
+ size_t i;
+
+ pkt.hdr = hdr;
+ pkt.iovec = &iovec;
+ pkt.num_iovec = 1;
+ pkt.peer = peer;
+
+ /* 30 * 38-byte packets + one 60-byte packet = one 1200-byte datagram. */
+ for (i = 0; i < 31; ++i) {
+ iovec.buf = i < 30 ? one_padding : final_padding;
+ iovec.buf_len = i < 30 ? sizeof(one_padding) : sizeof(final_padding);
+ pkt.pn = first_pn + i;
+ pkt.flags = i < 30 ? OSSL_QTX_PKT_FLAG_COALESCE : 0;
+ if (!ossl_qtx_write_pkt(qtx, &pkt))
+ return 0;
+ }
+
+ if (ossl_qtx_get_queue_len_datagrams(qtx) != 1
+ || ossl_qtx_get_queue_len_bytes(qtx) != QUIC_MDPL) {
+ TEST_info("crafted queue has %zu datagrams / %zu bytes, "
+ "expected 1 / %d",
+ ossl_qtx_get_queue_len_datagrams(qtx),
+ ossl_qtx_get_queue_len_bytes(qtx), QUIC_MDPL);
+ return 0;
+ }
+
+ if (ossl_qtx_flush_net(qtx) != QTX_FLUSH_NET_RES_OK) {
+ TEST_info("failed to flush crafted datagram");
+ return 0;
+ }
+
+ return 1;
+}
+
+/*
+ * Test that we don't exceed our amplification limit when a peer
+ * sends coalesced frames
+ */
+static int test_quic_amplification_limit(void)
+{
+ SSL_CTX *sctx = NULL, *cctx = NULL;
+ SSL *listener = NULL, *client = NULL, *server = NULL;
+ QUIC_CHANNEL *cch = NULL, *sch = NULL;
+ OSSL_QTX *inject_qtx = NULL;
+ OSSL_QTX_ARGS qtx_args = { 0 };
+ QUIC_PKT_HDR hdr = { 0 };
+ BIO_ADDR *server_addr = NULL, *client_addr = NULL;
+ BIO_ADDR *shadow_server_addr = NULL;
+ int rc, ssl_err, i, ret = 0;
+ uint64_t server_bytes = 0;
+ size_t server_datagrams = 0;
+ uint64_t first_injected_pn;
+ const uint64_t client_bytes = 2 * QUIC_MDPL;
+ const uint64_t rfc_ceiling = 3 * client_bytes;
+ struct in_addr ina;
+ static const unsigned char alpn[] = { 8, 'o', 's', 's', 'l', 't', 'e', 's', 't' };
+ X509 *chain_cert = NULL;
+ BIO *c_bio = NULL, *s_bio = NULL;
+
+ sctx = create_server_ctx();
+ cctx = create_client_ctx();
+ if (!TEST_ptr(sctx) || !TEST_ptr(cctx))
+ goto err;
+
+ /*
+ * Ensure that we better know the form of the handshake messages sent
+ * we set the groups and ciphersuites so we know how big the client and server
+ * hello frames will be so the math for adding and draining credit is
+ * predictable
+ */
+ if (!TEST_true(SSL_CTX_set_options(cctx, SSL_OP_NO_RX_CERTIFICATE_COMPRESSION))
+ || !TEST_true(SSL_CTX_set_options(sctx, SSL_OP_NO_TX_CERTIFICATE_COMPRESSION)))
+ goto err;
+
+ if (!TEST_true(SSL_CTX_set1_groups_list(sctx, "X25519"))
+ || !TEST_true(SSL_CTX_set1_groups_list(cctx, "X25519"))) {
+ TEST_skip("Skipping amplification test due to lack of X25519 group");
+ ret = 1;
+ goto err;
+ }
+ if (!TEST_true(SSL_CTX_set_ciphersuites(sctx, "TLS_AES_128_GCM_SHA256"))
+ || !TEST_true(SSL_CTX_set_ciphersuites(cctx, "TLS_AES_128_GCM_SHA256"))) {
+ TEST_skip("Skipping amplification test due to lack of aes-128-gcm-sha256 cipher");
+ ret = 1;
+ goto err;
+ }
+
+ /*
+ * We're going to send a set of extra certs that don't create a valid
+ * verification chain, so don't bother verifying
+ */
+ SSL_CTX_set_verify(sctx, SSL_VERIFY_NONE, NULL);
+ SSL_CTX_set_verify(cctx, SSL_VERIFY_NONE, NULL);
+
+ if (!TEST_true(SSL_CTX_check_private_key(sctx)))
+ goto err;
+
+ /*
+ * Get out leaf certificate and add it 40 times as extra_certs.
+ * This makes our server hello large, so that we drain our unvalidated
+ * credit on the server in response to the initial client hello
+ */
+ chain_cert = SSL_CTX_get0_certificate(sctx);
+
+ for (i = 0; i < EXTRA_CERTS; ++i) {
+ if (!TEST_int_eq(X509_up_ref(chain_cert), 1))
+ goto err;
+ if (!TEST_int_eq(SSL_CTX_add_extra_chain_cert(sctx, chain_cert), 1))
+ goto err;
+ }
+
+ /*
+ * Create a bio dgram pair, and attach them to the client and server ssl objects.
+ * We do this so we have access to the bios and can inject and drain frames as needed.
+ * Also, its important to make the bio ring buffer sizes large enough so that we don't
+ * accidentally drop frames.
+ */
+ ina.s_addr = htonl(INADDR_LOOPBACK);
+ if (!TEST_true(BIO_new_bio_dgram_pair(&c_bio, 65535, &s_bio, 65535)))
+ goto err;
+ if (!TEST_ptr((server_addr = create_addr(&ina, SERVER_PORT)))
+ || (!TEST_ptr((client_addr = create_addr(&ina, CLIENT_PORT)))))
+ goto err;
+
+ if (!TEST_true(bio_addr_bind(c_bio, client_addr)))
+ goto err;
+ client_addr = NULL;
+
+ if (!TEST_true(bio_addr_bind(s_bio, server_addr)))
+ goto err;
+ shadow_server_addr = server_addr;
+ server_addr = NULL;
+
+ if (!TEST_ptr((listener = SSL_new_listener(sctx,
+ SSL_LISTENER_FLAG_NO_VALIDATE)))
+ || (!TEST_true(SSL_listen(listener)))
+ || (!TEST_ptr((client = SSL_new(cctx))))
+ || (!TEST_true(SSL_set1_initial_peer_addr(client, shadow_server_addr)))
+ || (!TEST_int_eq(SSL_set_alpn_protos(client, alpn, sizeof(alpn)), 0))
+ || (!TEST_true(SSL_set_tlsext_host_name(client, "localhost")))
+ || (!TEST_ptr((cch = ossl_quic_conn_get_channel(client)))))
+ goto err;
+
+ SSL_set_bio(listener, s_bio, s_bio);
+ SSL_set_bio(client, c_bio, c_bio);
+
+ if (!TEST_true(SSL_set_blocking_mode(listener, 0)))
+ goto err;
+
+ if (!TEST_true(SSL_set_blocking_mode(client, 0)))
+ goto err;
+
+ /*
+ * Create a bogus qtx so that we can inject a crafted frame after the
+ * initial client and server hello are exchanged.
+ */
+ qtx_args.bio = SSL_get_wbio(client);
+ qtx_args.mdpl = QUIC_MDPL;
+ qtx_args.libctx = libctx;
+ inject_qtx = ossl_qtx_new(&qtx_args);
+ if (!TEST_ptr(inject_qtx))
+ goto err;
+
+ /*
+ * Install the initial secret to our bogus qtx so that our subsequent
+ * crafted frame gets accepted on the server channel.
+ */
+ hdr.type = QUIC_PKT_TYPE_INITIAL;
+ hdr.fixed = 1;
+ hdr.pn_len = 4;
+ hdr.version = QUIC_VERSION_1;
+ hdr.dst_conn_id = cch->init_dcid;
+ hdr.src_conn_id = cch->init_scid;
+ if (!TEST_true(ossl_quic_provide_initial_secret(libctx, NULL, &hdr.dst_conn_id,
+ 0, NULL, inject_qtx)))
+ goto err;
+
+ /* This emits a valid, padded, one-datagram X25519 ClientHello. */
+ rc = SSL_connect(client);
+ if (rc > 0)
+ goto err;
+ ssl_err = SSL_get_error(client, rc);
+ if (ssl_err != SSL_ERROR_WANT_READ && ssl_err != SSL_ERROR_WANT_WRITE)
+ goto err;
+
+ /*
+ * Make sure that the client hello was received at the server
+ */
+ if (!wait_readable(s_bio, 1000)) {
+ TEST_info("timed out waiting for the ClientHello");
+ goto err;
+ }
+
+ /*
+ * Accept the new connection on the server
+ */
+ for (i = 0; i < 64 && server == NULL; ++i) {
+ ERR_clear_error();
+ if (!TEST_true(SSL_handle_events(listener)))
+ goto err;
+ server = SSL_accept_connection(listener, 0);
+ ERR_clear_error();
+ }
+ if (!TEST_ptr(server))
+ goto err;
+ sch = ossl_quic_conn_get_channel(server);
+ if (!TEST_ptr(sch))
+ goto err;
+
+ if (!TEST_true(SSL_set_blocking_mode(server, 0)))
+ goto err;
+
+ first_injected_pn = ossl_quic_tx_packetiser_get_next_pn(
+ cch->txp, QUIC_PN_SPACE_INITIAL);
+ TEST_info("next client Initial packet number: %llu",
+ (unsigned long long)first_injected_pn);
+
+ /* Count but never deliver the server flight to the QUIC client. */
+ if (!TEST_true(drain_server_output(c_bio, &server_bytes, &server_datagrams)))
+ goto err;
+
+ TEST_info("phase 1 complete: legitimate ClientHello credit exhausted");
+
+ /*
+ * At this point the client has sent 1200 bytes, and the server should respond
+ * with 3600 bytes of server hello/certificate data, which should drain
+ * out unvalidated credit on the server.
+ *
+ * Given that, send another 1200 byte packet from the client, containing
+ * a bunch of initial frames. The server should respond to each of these
+ * with 3600 bytes of handshake data, just as it did above, but
+ * (if the server is honoring the 3x amplification limit, will stop after
+ * sending the first one.
+ */
+ if (!TEST_true(send_coalesced_initial(inject_qtx, &hdr, shadow_server_addr,
+ first_injected_pn)))
+ goto err;
+ TEST_info("phase 2: sent one 1200-byte datagram containing 31 Initials");
+
+ /*
+ * Tick our state machine, making the server send responses, and counting
+ * how many datagrams and bytes are received by the client
+ */
+ for (i = 0; i < 64; ++i) {
+ if (!TEST_true(SSL_handle_events(listener)))
+ goto err;
+ if (!TEST_true(SSL_handle_events(server)))
+ goto err;
+ drain_server_output(c_bio, &server_bytes, &server_datagrams);
+ }
+
+ TEST_info("client UDP payload: %llu bytes in 2 datagrams",
+ (unsigned long long)client_bytes);
+ TEST_info("RFC 9000 ceiling: %llu bytes",
+ (unsigned long long)rfc_ceiling);
+ TEST_info("server UDP payload: %llu bytes in %llu datagrams",
+ (unsigned long long)server_bytes, (unsigned long long)server_datagrams);
+ TEST_info("amplification ratio: %.2fx",
+ (double)server_bytes / (double)client_bytes);
+ TEST_info("handshake complete: %s",
+ sch->handshake_complete ? "yes" : "no");
+ TEST_info("bogus credit >100k: %s",
+ ossl_quic_tx_packetiser_check_unvalidated_credit(sch->txp, 100000)
+ ? "yes"
+ : "no");
+
+ /*
+ * rfc_ceiling should be 7200 (3600 bytes for the first client hello
+ * and 3600 more for our bogus coalesced frame above). Make sure we
+ * didn't receive more than that on the client
+ */
+ if (!TEST_uint64_t_le(server_bytes, rfc_ceiling))
+ goto err;
+
+ ret = 1;
+
+err:
+ ossl_qtx_free(inject_qtx);
+ SSL_free(server);
+ SSL_free(client);
+ SSL_free(listener);
+ SSL_CTX_free(cctx);
+ SSL_CTX_free(sctx);
+ BIO_ADDR_free(client_addr);
+ BIO_ADDR_free(server_addr);
+ return ret;
+}
+
static SSL *quic_verify_ssl = NULL;
static int quic_verify_cb(int ok, X509_STORE_CTX *ctx)
@@ -4485,6 +4826,7 @@ int setup_tests(void)
#endif
ADD_TEST(test_server_method_with_ssl_new);
ADD_TEST(test_ssl_accept_connection);
+ ADD_TEST(test_quic_amplification_limit);
ADD_TEST(test_ssl_set_verify);
ADD_TEST(test_accept_stream);
ADD_TEST(test_reject_stream_gc);