Skip to content

Commit 362bbfc

Browse files
committed
Harden protocol support T2 claims
1 parent 82e42e1 commit 362bbfc

9 files changed

Lines changed: 169 additions & 8 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"claim_ids":["clm:webtransport-h3-draft13-initial-flow-settings.t0","clm:webtransport-h3-draft13-initial-flow-settings.t1","clm:webtransport-h3-draft13-initial-flow-settings.t2"],"feature_id":"feat:webtransport-h3-draft13-initial-flow-settings","schema_version":"ssot.evidence.runtime.v1","status":"passed","target_tier":"T2","test_ids":["tst:pytest.webtransport-h3-draft13-initial-flow-settings.t0.proof-graph","tst:pytest.webtransport-h3-draft13-initial-flow-settings.t1.proof-graph","tst:pytest.webtransport-h3-draft13-initial-flow-settings.t2.proof-graph","tst:webtransport-h3-initial-flow-settings-gap","tst:protocol-support-matrix-drift"]}
1+
{"claim_ids":["clm:webtransport-h3-draft13-initial-flow-settings.t0","clm:webtransport-h3-draft13-initial-flow-settings.t1","clm:webtransport-h3-draft13-initial-flow-settings.t2"],"feature_id":"feat:webtransport-h3-draft13-initial-flow-settings","schema_version":"ssot.evidence.runtime.v1","status":"passed","target_tier":"T2","test_ids":["tst:pytest.webtransport-h3-draft13-initial-flow-settings.t0.proof-graph","tst:pytest.webtransport-h3-draft13-initial-flow-settings.t1.proof-graph","tst:pytest.webtransport-h3-draft13-initial-flow-settings.t2.proof-graph","tst:webtransport-h3-initial-flow-settings-gap","tst:protocol-support-matrix-drift","tst:webtransport-t2-runtime-robustness"]}

.ssot/registry.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

pkgs/tigrcorn-protocols/src/tigrcorn_protocols/webtransport/wire.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ class WebTransportSession:
276276
path: str
277277
selected_protocol: str | None = None
278278
flow: WebTransportFlowController = field(default_factory=WebTransportFlowController)
279+
flow_control_enabled: bool = True
279280
state: SessionState = SessionState.OPEN
280281
streams: dict[int, StreamDirection] = field(default_factory=dict)
281282
datagrams: list[bytes] = field(default_factory=list)
@@ -341,22 +342,25 @@ def accept(self, request: ConnectRequest) -> ConnectDecision:
341342
path=_required_header(request.headers, ":path"),
342343
selected_protocol=request.selected_protocol,
343344
flow=_flow_from_request(request),
345+
flow_control_enabled=_flow_control_enabled(request),
344346
)
345347
self.sessions[session.session_id] = session
346348
return decision
347349

348350
def open_stream(self, session_id: str, stream_id: int, direction: StreamDirection) -> None:
349351
session = self._session(session_id)
350352
session.ensure_accepting_traffic()
351-
session.flow.open_stream(direction)
353+
if session.flow_control_enabled:
354+
session.flow.open_stream(direction)
352355
session.streams[stream_id] = direction
353356

354357
def receive_stream_data(self, session_id: str, stream_id: int, data: bytes, direction: StreamDirection) -> None:
355358
session = self._session(session_id)
356359
session.ensure_accepting_traffic()
357360
if stream_id not in session.streams:
358361
self.open_stream(session_id, stream_id, direction)
359-
session.flow.allow_stream_data(stream_id, direction, len(data))
362+
if session.flow_control_enabled:
363+
session.flow.allow_stream_data(stream_id, direction, len(data))
360364

361365
def receive_datagram(self, session_id: str, payload: bytes) -> None:
362366
session = self._session(session_id)
@@ -402,6 +406,8 @@ def apply_capsule(self, session_id: str, capsule: Capsule) -> dict[str, Any]:
402406
CAPSULE_WT_STREAMS_BLOCKED_BIDI,
403407
CAPSULE_WT_STREAMS_BLOCKED_UNI,
404408
}:
409+
if not session.flow_control_enabled:
410+
return {"event": "webtransport.flow-control-ignored", "capsule_type": capsule.capsule_type}
405411
session.flow.apply_flow_control_capsule(capsule)
406412
return {"event": "webtransport.flow-control", "capsule_type": capsule.capsule_type}
407413
if capsule.capsule_type in {CAPSULE_WT_STREAM, CAPSULE_WT_STREAM_FIN}:
@@ -734,6 +740,12 @@ def _flow_from_request(request: ConnectRequest) -> WebTransportFlowController:
734740
)
735741

736742

743+
def _flow_control_enabled(request: ConnectRequest) -> bool:
744+
if request.carrier is Carrier.H3:
745+
return int(request.negotiated_settings.get(SETTING_WT_MAX_SESSIONS, 0)) > 1
746+
return True
747+
748+
737749
def _header(headers: Mapping[str, str], name: str) -> str | None:
738750
target = name.lower()
739751
for key, value in headers.items():

tests/test_connect_relay_local_negatives.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import socket
55
import unittest
6+
from contextlib import closing
67

78
from tigrcorn.config.load import build_config
89
from tigrcorn.constants import H2_PREFACE
@@ -104,6 +105,17 @@ async def _issue_h3_connect(port: int, authority: str) -> tuple[list[tuple[bytes
104105
sock.close()
105106

106107

108+
def _closed_tcp_port() -> int:
109+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
110+
sock.bind(('127.0.0.1', 0))
111+
return int(sock.getsockname()[1])
112+
113+
114+
def _relay(config):
115+
config.http.connect_policy = 'relay'
116+
config.read_timeout = 0.1
117+
118+
107119
class ConnectRelayIndependentLocalNegativeTests(unittest.IsolatedAsyncioTestCase):
108120
async def test_http2_connect_policy_deny_and_allowlist_rejection_end_stream(self) -> None:
109121
upstream = await asyncio.start_server(lambda r, w: None, '127.0.0.1', 0)
@@ -169,6 +181,38 @@ def allowlist(config):
169181
upstream.close()
170182
await upstream.wait_closed()
171183

184+
async def test_http2_connect_bad_authority_and_upstream_failure_end_stream(self) -> None:
185+
server, port = await _start_server(http_versions=['2'], config_mutator=_relay)
186+
try:
187+
headers, body, ended = await _issue_h2_connect(port, 'bad-target')
188+
self.assertIn((b':status', b'400'), headers)
189+
self.assertEqual(body, b'bad connect target')
190+
self.assertTrue(ended)
191+
192+
closed_port = _closed_tcp_port()
193+
headers, body, ended = await _issue_h2_connect(port, f'127.0.0.1:{closed_port}')
194+
self.assertIn((b':status', b'502'), headers)
195+
self.assertEqual(body, b'bad gateway')
196+
self.assertTrue(ended)
197+
finally:
198+
await server.close()
199+
200+
async def test_http3_connect_bad_authority_and_upstream_failure_end_stream(self) -> None:
201+
server, port = await _start_server(http_versions=['3'], transport='udp', config_mutator=_relay)
202+
try:
203+
headers, body, ended = await _issue_h3_connect(port, 'bad-target')
204+
self.assertIn((b':status', b'400'), headers)
205+
self.assertEqual(body, b'bad connect target')
206+
self.assertTrue(ended)
207+
208+
closed_port = _closed_tcp_port()
209+
headers, body, ended = await _issue_h3_connect(port, f'127.0.0.1:{closed_port}')
210+
self.assertIn((b':status', b'502'), headers)
211+
self.assertEqual(body, b'bad gateway')
212+
self.assertTrue(ended)
213+
finally:
214+
await server.close()
215+
172216

173217
if __name__ == '__main__':
174218
unittest.main()

tests/test_protocol_support_matrix.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,27 @@ def test_protocol_support_matrix_named_tests_are_linked_to_evidence() -> None:
6565
test = tests[test_id]
6666
assert test["path"] == expected_path
6767
assert test["evidence_ids"], test_id
68+
69+
70+
def test_protocol_support_t2_claims_use_runtime_robustness_tests() -> None:
71+
claims = _rows_by_id("claims")
72+
tests = _rows_by_id("tests")
73+
expected = {
74+
"clm:http3-iana-registry-conformance.t2": (
75+
"tst:pytest.http3-iana-registry-conformance.t2.proof-graph",
76+
"tests/test_webtransport_h3_draft13_iana.py",
77+
),
78+
"clm:webtransport-h3-draft13-initial-flow-settings.t2": (
79+
"tst:pytest.webtransport-h3-draft13-initial-flow-settings.t2.proof-graph",
80+
"tests/test_webtransport_t2_runtime_robustness.py",
81+
),
82+
"clm:http2-connect-relay-not-general-proxy.t2": (
83+
"tst:pytest.http2-connect-relay-not-general-proxy.t2.proof-graph",
84+
"tests/test_connect_relay_local_negatives.py",
85+
),
86+
}
87+
88+
for claim_id, (test_id, expected_path) in expected.items():
89+
assert test_id in claims[claim_id]["test_ids"]
90+
assert tests[test_id]["path"] == expected_path
91+
assert tests[test_id]["status"] == "passing"

tests/test_webtransport_h3_draft13_flow_control.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515

1616

1717
def _runtime() -> WebTransportWireRuntime:
18-
runtime = WebTransportWireRuntime(max_sessions=1)
18+
runtime = WebTransportWireRuntime(max_sessions=2)
1919
runtime.accept(
2020
ConnectRequest(
2121
stream_id=4,
2222
headers={":method": "CONNECT", ":protocol": "webtransport", ":scheme": "https", ":authority": "a", ":path": "/wt"},
2323
carrier=Carrier.H3,
24-
negotiated_settings=h3_draft13_settings(1),
24+
negotiated_settings=h3_draft13_settings(2),
2525
)
2626
)
2727
return runtime

tests/test_webtransport_h3_draft13_iana.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
from __future__ import annotations
22

3+
import pytest
4+
5+
from tigrcorn_core.errors import ProtocolError
6+
from tigrcorn_core.utils.bytes import encode_quic_varint
37
from tigrcorn.protocols.http3.codec import (
48
H3_DATAGRAM_ERROR,
59
H3_SETTINGS_ERROR,
10+
HTTP3ConnectionError,
11+
HTTP3_RESERVED_FRAME_TYPES,
612
QPACK_DECODER_STREAM_ERROR,
713
SETTING_ENABLE_CONNECT_PROTOCOL,
814
SETTING_H3_DATAGRAM,
15+
decode_settings,
16+
encode_settings,
917
http3_iana_registry_snapshot,
18+
is_grease_identifier,
19+
is_reserved_frame_type,
1020
)
1121
from tigrcorn.webtransport.wire import (
1222
CAPSULE_WT_CLOSE_SESSION,
@@ -50,3 +60,38 @@ def test_http3_iana_registry_snapshot_covers_runtime_constants() -> None:
5060
assert snapshot["error_codes"]["H3_DATAGRAM_ERROR"] == H3_DATAGRAM_ERROR == 0x33
5161
assert snapshot["error_codes"]["H3_SETTINGS_ERROR"] == H3_SETTINGS_ERROR == 0x0109
5262
assert snapshot["error_codes"]["QPACK_DECODER_STREAM_ERROR"] == QPACK_DECODER_STREAM_ERROR == 0x0202
63+
64+
65+
def test_http3_iana_registry_snapshot_is_isolated_copy() -> None:
66+
snapshot = http3_iana_registry_snapshot()
67+
snapshot["settings"]["SETTINGS_H3_DATAGRAM"] = 0
68+
69+
assert http3_iana_registry_snapshot()["settings"]["SETTINGS_H3_DATAGRAM"] == SETTING_H3_DATAGRAM
70+
71+
72+
def test_http3_settings_reserved_and_duplicate_identifiers_fail_closed() -> None:
73+
with pytest.raises(ProtocolError, match="reserved HTTP/3 setting"):
74+
encode_settings({0x00: 1})
75+
76+
duplicate_payload = (
77+
encode_quic_varint(SETTING_H3_DATAGRAM)
78+
+ encode_quic_varint(1)
79+
+ encode_quic_varint(SETTING_H3_DATAGRAM)
80+
+ encode_quic_varint(1)
81+
)
82+
with pytest.raises(HTTP3ConnectionError) as exc_info:
83+
decode_settings(duplicate_payload)
84+
assert exc_info.value.error_code == H3_SETTINGS_ERROR
85+
86+
reserved_payload = encode_quic_varint(0x02) + encode_quic_varint(1)
87+
with pytest.raises(HTTP3ConnectionError) as exc_info:
88+
decode_settings(reserved_payload)
89+
assert exc_info.value.error_code == H3_SETTINGS_ERROR
90+
91+
92+
def test_http3_frame_registry_reserved_and_grease_detection() -> None:
93+
assert {0x02, 0x06, 0x08, 0x09} <= set(HTTP3_RESERVED_FRAME_TYPES)
94+
assert is_reserved_frame_type(0x02)
95+
assert is_grease_identifier(0x21)
96+
assert is_grease_identifier(0x40)
97+
assert not is_reserved_frame_type(0x41)

tests/test_webtransport_h3_draft13_settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ def test_webtransport_h3_draft13_initial_flow_settings_are_canonical() -> None:
3333

3434

3535
def test_webtransport_h3_draft13_accept_applies_initial_flow_settings() -> None:
36-
runtime = WebTransportWireRuntime(max_sessions=1)
36+
runtime = WebTransportWireRuntime(max_sessions=2)
3737
settings = h3_draft13_settings(
38-
1,
38+
2,
3939
init=WebTransportInit(max_data=128, max_streams_uni=1, max_streams_bidi=2),
4040
)
4141

tests/test_webtransport_t2_runtime_robustness.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
CAPSULE_WT_DATA_BLOCKED,
1212
CAPSULE_WT_DRAIN_SESSION,
1313
CAPSULE_WT_MAX_DATA,
14+
CAPSULE_WT_MAX_STREAMS_BIDI,
1415
CAPSULE_WT_STREAM_DATA_BLOCKED,
1516
Capsule,
1617
Carrier,
1718
ConnectRequest,
1819
StreamDirection,
20+
WebTransportInit,
1921
WebTransportWireError,
2022
WebTransportWireRuntime,
2123
decode_h3_datagram_payload,
@@ -98,6 +100,40 @@ def test_webtransport_t2_h3_datagram_payload_underflow_fails_closed() -> None:
98100
decode_h3_datagram_payload(b"")
99101

100102

103+
def test_webtransport_t2_h3_single_session_ignores_flow_control_capsules() -> None:
104+
runtime = WebTransportWireRuntime(max_sessions=1)
105+
_accept_h3(runtime)
106+
107+
assert runtime.apply_capsule("4", Capsule(CAPSULE_WT_MAX_DATA, b"\xff")) == {
108+
"event": "webtransport.flow-control-ignored",
109+
"capsule_type": CAPSULE_WT_MAX_DATA,
110+
}
111+
assert runtime.apply_capsule("4", Capsule(CAPSULE_WT_MAX_STREAMS_BIDI, encode_varints(1))) == {
112+
"event": "webtransport.flow-control-ignored",
113+
"capsule_type": CAPSULE_WT_MAX_STREAMS_BIDI,
114+
}
115+
runtime.receive_stream_data("4", 8, b"unlimited", StreamDirection.BIDI)
116+
117+
flow = runtime.sessions["4"].flow
118+
assert flow.max_data == 0
119+
assert flow.max_streams_bidi == 0
120+
assert flow.data_sent == 0
121+
122+
123+
def test_webtransport_t2_h3_initial_limits_are_enforced_when_flow_control_enabled() -> None:
124+
runtime = WebTransportWireRuntime(max_sessions=2)
125+
settings = h3_draft13_settings(2, init=WebTransportInit(max_data=3, max_streams_bidi=1, max_streams_uni=1))
126+
assert runtime.accept(_request(4, Carrier.H3, settings)).accepted is True
127+
128+
runtime.open_stream("4", 8, StreamDirection.BIDI)
129+
with pytest.raises(WebTransportWireError, match="WT_MAX_STREAMS bidi exceeded"):
130+
runtime.open_stream("4", 12, StreamDirection.BIDI)
131+
132+
runtime.receive_stream_data("4", 8, b"abc", StreamDirection.BIDI)
133+
with pytest.raises(WebTransportWireError, match="WT_MAX_DATA exceeded"):
134+
runtime.receive_stream_data("4", 8, b"d", StreamDirection.BIDI)
135+
136+
101137
def test_webtransport_t2_buffering_rejects_established_session() -> None:
102138
runtime = WebTransportWireRuntime(max_sessions=1)
103139
_accept_h3(runtime)

0 commit comments

Comments
 (0)