From 4ab87523d48bbeb6e911a500bfddf4fc9997c191 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sun, 13 Sep 2026 10:24:53 +0000 Subject: [PATCH] h3: only accept WEBTRANSPORT_STREAM as the first frame Per draft-ietf-webtrans-http3 section 4.3, a WEBTRANSPORT_STREAM frame is only allowed as the very first frame of a request stream. Receiving it on a stream which is already in use - such as a CONNECT stream - or after another frame must be treated as a connection error of type H3_FRAME_ERROR. --- src/aioquic/h3/connection.py | 20 +++++++++ tests/test_webtransport.py | 78 +++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/aioquic/h3/connection.py b/src/aioquic/h3/connection.py index f38f8a131..e11d90e1b 100644 --- a/src/aioquic/h3/connection.py +++ b/src/aioquic/h3/connection.py @@ -137,6 +137,10 @@ class DatagramError(ProtocolError): error_code = ErrorCode.H3_DATAGRAM_ERROR +class FrameError(ProtocolError): + error_code = ErrorCode.H3_FRAME_ERROR + + class FrameUnexpected(ProtocolError): error_code = ErrorCode.H3_FRAME_UNEXPECTED @@ -354,6 +358,7 @@ def __init__(self, stream_id: int) -> None: self.headers_recv_state: HeadersState = HeadersState.INITIAL self.headers_send_state: HeadersState = HeadersState.INITIAL self.push_id: Optional[int] = None + self.received_frame = False self.session_id: Optional[int] = None self.stream_id = stream_id self.stream_type: Optional[int] = None @@ -994,6 +999,19 @@ def _receive_request_or_push_data( # WEBTRANSPORT_STREAM frames last until the end of the stream if stream.frame_type == FrameType.WEBTRANSPORT_STREAM: + # Per draft-ietf-webtrans-http3, a WEBTRANSPORT_STREAM + # frame is only allowed as the very first frame of a + # request stream, in any other position it is an + # H3_FRAME_ERROR. + if ( + stream.headers_send_state != HeadersState.INITIAL + or stream.push_id is not None + or stream.received_frame + ): + raise FrameError( + "WEBTRANSPORT_STREAM frame must be the first frame" + ) + stream.session_id = stream.frame_size stream.frame_size = None @@ -1015,6 +1033,8 @@ def _receive_request_or_push_data( ) return http_events + stream.received_frame = True + # log frame if ( self._quic_logger is not None diff --git a/tests/test_webtransport.py b/tests/test_webtransport.py index 004fbb797..1031f51f7 100644 --- a/tests/test_webtransport.py +++ b/tests/test_webtransport.py @@ -1,6 +1,13 @@ from unittest import TestCase -from aioquic.h3.connection import H3_ALPN, ErrorCode, H3Connection +from aioquic.buffer import encode_uint_var +from aioquic.h3.connection import ( + H3_ALPN, + ErrorCode, + FrameType, + H3Connection, + encode_frame, +) from aioquic.h3.events import ( DatagramReceived, HeadersReceived, @@ -191,6 +198,75 @@ def test_bidirectional_stream_server_initiated(self): ], ) + def test_bidirectional_stream_on_connect_stream(self): + """ + A WEBTRANSPORT_STREAM frame on the CONNECT stream is a protocol + violation and must close the connection with H3_FRAME_ERROR. + """ + with h3_fake_client_and_server(QUIC_CONFIGURATION_OPTIONS) as ( + quic_client, + quic_server, + ): + h3_client = H3Connection(quic_client, enable_webtransport=True) + h3_server = H3Connection(quic_server, enable_webtransport=True) + + # create session + session_id = self._make_session(h3_client, h3_server) + + # receive a WEBTRANSPORT_STREAM frame on the CONNECT stream + quic_client.send_stream_data( + session_id, + encode_uint_var(FrameType.WEBTRANSPORT_STREAM) + + encode_uint_var(session_id) + + b"AAAA", + ) + + events = h3_transfer(quic_client, h3_server) + self.assertEqual(events, []) + self.assertEqual( + quic_server.closed, + ( + ErrorCode.H3_FRAME_ERROR, + "WEBTRANSPORT_STREAM frame must be the first frame", + ), + ) + + def test_bidirectional_stream_not_first_frame(self): + """ + A WEBTRANSPORT_STREAM frame is only allowed as the very first frame + of a stream, any prior frame must close the connection with + H3_FRAME_ERROR. + """ + with h3_fake_client_and_server(QUIC_CONFIGURATION_OPTIONS) as ( + quic_client, + quic_server, + ): + h3_client = H3Connection(quic_client, enable_webtransport=True) + h3_server = H3Connection(quic_server, enable_webtransport=True) + + # create session + session_id = self._make_session(h3_client, h3_server) + + # receive an unknown frame followed by a WEBTRANSPORT_STREAM + # frame on a fresh bidirectional stream + stream_id = quic_client.get_next_available_stream_id() + quic_client.send_stream_data( + stream_id, + encode_frame(0x21, b"") + + encode_uint_var(FrameType.WEBTRANSPORT_STREAM) + + encode_uint_var(session_id), + ) + + events = h3_transfer(quic_client, h3_server) + self.assertEqual(events, []) + self.assertEqual( + quic_server.closed, + ( + ErrorCode.H3_FRAME_ERROR, + "WEBTRANSPORT_STREAM frame must be the first frame", + ), + ) + def test_unidirectional_stream(self): with h3_client_and_server(QUIC_CONFIGURATION_OPTIONS) as ( quic_client,