curl/tests/unit/unit3400.c
Aritra Basu e78b1b3ecc
HTTP/3: add proxy CONNECT and MASQUE CONNECT-UDP support (ngtcp2 QUIC)
This patch adds two major proxy capabilities to curl (ngtcp2 QUIC):
- HTTP/3 Proxy CONNECT: Tunnel HTTP/1.1 or HTTP/2 traffic through an
  HTTPS proxy that speaks HTTP/3 (QUIC) using the standard CONNECT
  method over an HTTP/3 connection.
- MASQUE CONNECT-UDP: Tunnel HTTP/3 (QUIC) traffic through an HTTP
  proxy (speaking HTTP/1.1, HTTP/2, or HTTP/3) using the extended
  CONNECT method with the CONNECT-UDP protocol (RFC9297 & RFC9298).

Public API additions:
- `CURLPROXY_HTTPS3`: new proxy type constant for HTTP/3 proxy
- `--proxy-http3`: new CLI flag to negotiate HTTP/3 with HTTPS proxy

The implementation adds two new filters:
- `H3-PROXY` - enables negotiating HTTP/3 (QUIC) to the proxy and
  running CONNECT/CONNECT-UDP through that proxy transport.
- `CAPSULE` - dedicated filter inserted between QUIC transport and
  HTTP-PROXY to handle datagram capsule encapsulation/decapsulation.

Here is how the curl filter chaining looks in different scenarios:
- HTTP/3 Proxy CONNECT (tunneling TCP protocols over QUIC proxy):
  conn -> HTTP/1.1 or HTTP/2  -> SSL -> HTTP-PROXY ->
                                 H3-PROXY -> HAPPY-EYEBALLS -> UDP
- MASQUE CONNECT-UDP (tunneling QUIC over any proxy):
  conn -> HTTP/3 -> CAPSULE -> HTTP-PROXY -> H3-PROXY ->
                               HAPPY-EYEBALLS -> UDP
  conn -> HTTP/3 -> CAPSULE -> HTTP-PROXY -> H1-PROXY or H2-PROXY ->
                               SSL -> HAPPY-EYEBALLS -> TCP

- Both features currently require the ngtcp2 QUIC backend.
- Both features are experimental (disabled by default). Enable with
  `--enable-proxy-http3`(autotools) or `-DUSE_PROXY_HTTP3=ON`(CMake).

Tests:
- tests/unit/unit3400.c: Unit tests for capsule protocol encode/decode
- tests/http/test_60_h3_proxy.py: Comprehensive pytest integration suite
- tests/http/testenv/h2o.py: Managing h2o instances with HTTP/1.1, HTTP/2,
  and HTTP/3 (QUIC) listeners, proxy.connect and proxy.connect-udp enabled.

References:
  RFC 9297 - HTTP Datagrams and the Capsule Protocol
  RFC 9298 - Proxying UDP in HTTP
  RFC 9000 §16 — Variable-Length Integer Encoding

Signed-off-by: Aritra Basu <aritrbas+gh@cisco.com>

Closes #21153
2026-05-27 08:49:53 +02:00

268 lines
10 KiB
C

/***************************************************************************
* _ _ ____ _
* Project ___| | | | _ \| |
* / __| | | | |_) | |
* | (__| |_| | _ <| |___
* \___|\___/|_| \_\_____|
*
* Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
*
* This software is licensed as described in the file COPYING, which
* you should have received as part of this distribution. The terms
* are also available at https://curl.se/docs/copyright.html.
*
* You may opt to use, copy, modify, merge, publish, distribute and/or sell
* copies of the Software, and permit persons to whom the Software is
* furnished to do so, under the terms of the COPYING file.
*
* This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
* KIND, either express or implied.
*
* SPDX-License-Identifier: curl
*
***************************************************************************/
#include "unitcheck.h"
#include "bufq.h"
#include "capsule.h"
#if defined(USE_PROXY_HTTP3) && defined(USE_NGTCP2) && \
!defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
static void queue_bytes(struct bufq *q, const unsigned char *src, size_t len)
{
size_t nwritten = 0;
CURLcode result = Curl_bufq_write(q, src, len, &nwritten);
fail_unless(result == CURLE_OK, "queue failed");
fail_unless(nwritten == len, "queue short write");
}
#endif
#if defined(USE_PROXY_HTTP3) && \
!defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
static void check_capsule_hdr(size_t payload_len,
const unsigned char *expected,
size_t expected_len)
{
unsigned char hdr[HTTP_CAPSULE_HEADER_MAX_SIZE];
size_t hdr_len;
memset(hdr, 0xA5, sizeof(hdr));
hdr_len = Curl_capsule_encap_udp_hdr(hdr, sizeof(hdr), payload_len);
fail_unless(hdr_len == expected_len, "capsule header length mismatch");
fail_unless(!memcmp(hdr, expected, expected_len),
"capsule header bytes mismatch");
}
static void test_capsule_encap_udp_hdr_boundaries(void)
{
const unsigned char p0[] = { 0x00, 0x01, 0x00 };
const unsigned char p62[] = { 0x00, 0x3F, 0x00 };
const unsigned char p63[] = { 0x00, 0x40, 0x40, 0x00 };
const unsigned char p64[] = { 0x00, 0x40, 0x41, 0x00 };
const unsigned char p16382[] = { 0x00, 0x7F, 0xFF, 0x00 };
const unsigned char p16383[] = { 0x00, 0x80, 0x00, 0x40, 0x00, 0x00 };
const unsigned char p16384[] = { 0x00, 0x80, 0x00, 0x40, 0x01, 0x00 };
check_capsule_hdr(0, p0, sizeof(p0));
check_capsule_hdr(62, p62, sizeof(p62));
check_capsule_hdr(63, p63, sizeof(p63));
check_capsule_hdr(64, p64, sizeof(p64));
check_capsule_hdr(16382, p16382, sizeof(p16382));
check_capsule_hdr(16383, p16383, sizeof(p16383));
check_capsule_hdr(16384, p16384, sizeof(p16384));
}
#endif /* !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
#if defined(USE_PROXY_HTTP3) && defined(USE_NGTCP2) && \
!defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
static void check_capsule_result(struct bufq *q,
const unsigned char *capsule, size_t capslen,
size_t outlen, CURLcode expect_err,
size_t expect_nread)
{
unsigned char out[32];
CURLcode err = CURLE_OK;
size_t nread;
memset(out, 0, sizeof(out));
Curl_bufq_reset(q);
if(capsule && capslen)
queue_bytes(q, capsule, capslen);
nread = Curl_capsule_process_udp_raw(NULL, NULL, q, out, outlen, &err);
fail_unless(err == expect_err, "unexpected capsule error");
fail_unless(nread == expect_nread, "unexpected capsule read size");
}
static void test_capsule_encode_decode_roundtrip(void)
{
struct dynbuf dyn;
struct bufq q;
unsigned char payload[128];
unsigned char out[128];
CURLcode result, err;
size_t payload_len;
size_t i, nread;
for(i = 0; i < sizeof(payload); ++i)
payload[i] = (unsigned char)i;
for(i = 0; i < 2; ++i) {
payload_len = i ? 64 : 7;
memset(out, 0, sizeof(out));
result = Curl_capsule_encap_udp_datagram(&dyn, payload, payload_len);
fail_unless(result == CURLE_OK, "failed to encapsulate UDP datagram");
Curl_bufq_init2(&q, 32, 8, BUFQ_OPT_NONE);
queue_bytes(&q, (const unsigned char *)curlx_dyn_ptr(&dyn),
curlx_dyn_len(&dyn));
err = CURLE_OK;
nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out),
&err);
fail_unless(err == CURLE_OK, "failed to decode UDP datagram");
fail_unless(nread == payload_len, "decoded payload length mismatch");
fail_unless(!memcmp(out, payload, payload_len),
"decoded payload bytes mismatch");
fail_unless(Curl_bufq_is_empty(&q), "decoded capsule must be consumed");
Curl_bufq_free(&q);
curlx_dyn_free(&dyn);
}
}
static void test_capsule_sequential_decode(void)
{
/* Verify that multiple back-to-back capsules in the same bufq are
each decoded in turn and the buffer is fully consumed. */
struct bufq q;
unsigned char out[8];
CURLcode err;
size_t nread;
/* Two back-to-back 3-byte UDP capsules */
const unsigned char two_caps[] = {
0x00, 0x04, 0x00, 0x11, 0x22, 0x33, /* capsule 1: [0x11,0x22,0x33] */
0x00, 0x04, 0x00, 0xAA, 0xBB, 0xCC /* capsule 2: [0xAA,0xBB,0xCC] */
};
Curl_bufq_init2(&q, 32, 4, BUFQ_OPT_NONE);
queue_bytes(&q, two_caps, sizeof(two_caps));
/* First capsule */
memset(out, 0, sizeof(out));
err = CURLE_OK;
nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
fail_unless(err == CURLE_OK, "sequential: first capsule decode failed");
fail_unless(nread == 3, "sequential: first capsule size mismatch");
fail_unless(out[0] == 0x11 && out[1] == 0x22 && out[2] == 0x33,
"sequential: first capsule bytes mismatch");
/* Second capsule */
memset(out, 0, sizeof(out));
err = CURLE_OK;
nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
fail_unless(err == CURLE_OK, "sequential: second capsule decode failed");
fail_unless(nread == 3, "sequential: second capsule size mismatch");
fail_unless(out[0] == 0xAA && out[1] == 0xBB && out[2] == 0xCC,
"sequential: second capsule bytes mismatch");
/* Buffer must be empty after both capsules */
fail_unless(Curl_bufq_is_empty(&q),
"sequential: buffer must be empty after two capsules");
/* No more data - CURLE_AGAIN expected */
err = CURLE_OK;
nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
fail_unless(err == CURLE_AGAIN,
"sequential: empty queue should return AGAIN");
fail_unless(nread == 0, "sequential: empty queue should read zero bytes");
Curl_bufq_free(&q);
}
static void test_capsule_decode_paths(void)
{
struct bufq q;
unsigned char out[8];
CURLcode err = CURLE_OK;
size_t nread;
const unsigned char invalid_type[] = { 0x01 };
const unsigned char partial_len[] = { 0x00, 0x40 };
const unsigned char invalid_context[] = { 0x00, 0x01, 0x01 };
const unsigned char invalid_caps_len[] = { 0x00, 0x00, 0x00 };
const unsigned char partial_payload[] = { 0x00, 0x04, 0x00, 0x11, 0x22 };
const unsigned char payload_3b[] = { 0x00, 0x04, 0x00, 0x11, 0x22, 0x33 };
const unsigned char payload_empty[] = { 0x00, 0x01, 0x00 };
Curl_bufq_init2(&q, 32, 4, BUFQ_OPT_NONE);
check_capsule_result(&q, NULL, 0, 0, CURLE_BAD_FUNCTION_ARGUMENT, 0);
check_capsule_result(&q, NULL, 0, sizeof(out), CURLE_AGAIN, 0);
check_capsule_result(&q, invalid_type, sizeof(invalid_type), sizeof(out),
CURLE_RECV_ERROR, 0);
check_capsule_result(&q, partial_len, sizeof(partial_len), sizeof(out),
CURLE_AGAIN, 0);
check_capsule_result(&q, invalid_context, sizeof(invalid_context),
sizeof(out), CURLE_RECV_ERROR, 0);
check_capsule_result(&q, invalid_caps_len, sizeof(invalid_caps_len),
sizeof(out), CURLE_RECV_ERROR, 0);
check_capsule_result(&q, partial_payload, sizeof(partial_payload),
sizeof(out), CURLE_AGAIN, 0);
/* oversized payload is rejected and discarded */
Curl_bufq_reset(&q);
queue_bytes(&q, payload_3b, sizeof(payload_3b));
nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, 2, &err);
fail_unless(err == CURLE_RECV_ERROR,
"expected RECV_ERROR for short output buffer");
fail_unless(nread == 0, "expected zero read on short output buffer");
fail_unless(Curl_bufq_is_empty(&q),
"oversized capsule must be discarded");
/* zero-length UDP payload is accepted and consumed */
Curl_bufq_reset(&q);
queue_bytes(&q, payload_empty, sizeof(payload_empty));
nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
fail_unless(err == CURLE_OK, "zero-length UDP payload should succeed");
fail_unless(nread == 0, "zero-length UDP payload should read zero");
fail_unless(Curl_bufq_is_empty(&q), "zero-length capsule must be consumed");
/* normal payload decode */
Curl_bufq_reset(&q);
queue_bytes(&q, payload_3b, sizeof(payload_3b));
memset(out, 0, sizeof(out));
nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
fail_unless(err == CURLE_OK, "payload decode should succeed");
fail_unless(nread == 3, "payload decode size mismatch");
fail_unless(out[0] == 0x11 && out[1] == 0x22 && out[2] == 0x33,
"payload decode bytes mismatch");
fail_unless(Curl_bufq_is_empty(&q), "payload capsule must be consumed");
Curl_bufq_free(&q);
}
#endif /* USE_NGTCP2 && !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
static CURLcode test_unit3400(const char *arg)
{
UNITTEST_BEGIN_SIMPLE
(void)arg;
#if defined(USE_PROXY_HTTP3) && \
!defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
test_capsule_encap_udp_hdr_boundaries();
#endif
#if defined(USE_PROXY_HTTP3) && defined(USE_NGTCP2) && \
!defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
test_capsule_encode_decode_roundtrip();
test_capsule_decode_paths();
test_capsule_sequential_decode();
#endif
UNITTEST_END_SIMPLE
}