Skip to content

Commit 0d72e34

Browse files
author
ChasingImpact
committed
utilities/tests: align CONNECT validation with RFC 9113 s8.3 & RFC 8441 s4; add tests; changelog
1 parent 9268b72 commit 0d72e34

File tree

4 files changed

+380
-5
lines changed

4 files changed

+380
-5
lines changed

CHANGELOG.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
Release History
22
===============
33

4+
dev
5+
---
6+
7+
**API Changes (Backward Incompatible)**
8+
9+
- Support for Python 3.9 has been removed.
10+
11+
**API Changes (Backward Compatible)**
12+
13+
- Support for Python 3.14 has been added.
14+
- Align CONNECT pseudo-header validation with RFC 9113 s8.3 and RFC 8441 s4.
15+
Ordinary CONNECT now requires ``:method=CONNECT`` and ``:authority``, and
16+
forbids ``:scheme``/``:path``. Extended CONNECT (e.g., WebSocket) requires
17+
``:scheme``, ``:path``, ``:authority`` plus ``:protocol``. (PR #1309)
18+
19+
20+
**Bugfixes**
21+
22+
-
23+
424
4.3.0 (2025-08-23)
525
------------------
626

src/h2/utilities.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,18 +385,39 @@ def _check_pseudo_header_field_acceptability(pseudo_headers: set[bytes | str] |
385385
if invalid_response_headers:
386386
msg = f"Encountered request-only headers {invalid_response_headers}"
387387
raise ProtocolError(msg)
388+
388389
elif (not hdr_validation_flags.is_response_header and
389390
not hdr_validation_flags.is_trailer):
390-
# This is a request, so we need to have seen :path, :method, and
391-
# :scheme.
392-
_assert_header_in_set(b":path", pseudo_headers)
391+
# Request header block.
393392
_assert_header_in_set(b":method", pseudo_headers)
394-
_assert_header_in_set(b":scheme", pseudo_headers)
393+
394+
is_connect = (method == b"CONNECT")
395+
is_extended_connect = is_connect and (b":protocol" in pseudo_headers)
396+
397+
if is_connect and not is_extended_connect:
398+
# Ordinary CONNECT (RFC 9113 s8.3):
399+
# MUST NOT include :scheme or :path.
400+
if b":scheme" in pseudo_headers or b":path" in pseudo_headers:
401+
msg = "Ordinary CONNECT MUST NOT include :scheme or :path"
402+
raise ProtocolError(msg)
403+
# :authority presence is enforced elsewhere; no extra asserts here.
404+
elif is_extended_connect:
405+
# Extended CONNECT (RFC 8441 s4): require the regular tuple.
406+
_assert_header_in_set(b":scheme", pseudo_headers)
407+
_assert_header_in_set(b":path", pseudo_headers)
408+
# :authority presence validated by host/authority checker.
409+
else:
410+
# Non-CONNECT requests require :scheme and :path (RFC 9113 s8.3).
411+
_assert_header_in_set(b":scheme", pseudo_headers)
412+
_assert_header_in_set(b":path", pseudo_headers)
413+
395414
invalid_request_headers = pseudo_headers & _RESPONSE_ONLY_HEADERS
396415
if invalid_request_headers:
397416
msg = f"Encountered response-only headers {invalid_request_headers}"
398417
raise ProtocolError(msg)
399-
if method != b"CONNECT":
418+
419+
# If not CONNECT, then :protocol is invalid.
420+
if not is_connect:
400421
invalid_headers = pseudo_headers & _CONNECT_REQUEST_ONLY_HEADERS
401422
if invalid_headers:
402423
msg = f"Encountered connect-request-only headers {invalid_headers!r}"
@@ -698,3 +719,4 @@ def _check_size_limit(self) -> None:
698719
if self._size_limit is not None:
699720
while len(self) > self._size_limit:
700721
self.popitem(last=False)
722+
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""unit tests for ordinary vs extended CONNECT validation on the client side."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from h2.config import H2Configuration
8+
from h2.connection import H2Connection
9+
from h2.utilities import HeaderValidationFlags, validate_outbound_headers
10+
11+
12+
def _new_conn() -> H2Connection:
13+
c = H2Connection(
14+
config=H2Configuration(client_side=True, header_encoding="utf-8")
15+
)
16+
c.initiate_connection()
17+
# settings ack frame: length=0, type=4, flags=1(ACK), stream=0
18+
c.receive_data(b"\x00\x00\x00\x04\x01\x00\x00\x00\x00")
19+
return c
20+
21+
22+
def _client_req_flags() -> HeaderValidationFlags:
23+
# client, not trailers, not response, not push
24+
return HeaderValidationFlags(
25+
is_client=True,
26+
is_trailer=False,
27+
is_response_header=False,
28+
is_push_promise=False,
29+
)
30+
31+
32+
def test_ordinary_connect_allows_no_scheme_no_path_and_send_headers_ok() -> None:
33+
# ---- bytes for validate_outbound_headers ----
34+
hdrs_bytes = [
35+
(b":method", b"CONNECT"),
36+
(b":authority", b"example.com:443"),
37+
]
38+
# should not raise
39+
list(validate_outbound_headers(hdrs_bytes, _client_req_flags()))
40+
41+
# ---- str is fine for send_headers due to header_encoding ----
42+
hdrs_str = [
43+
(":method", "CONNECT"),
44+
(":authority", "example.com:443"),
45+
]
46+
conn = _new_conn()
47+
# should not raise
48+
conn.send_headers(1, hdrs_str, end_stream=True)
49+
50+
51+
def test_ordinary_connect_rejects_path_or_scheme() -> None:
52+
bad1 = [
53+
(b":method", b"CONNECT"),
54+
(b":authority", b"example.com:443"),
55+
(b":path", b"/"),
56+
]
57+
bad2 = [
58+
(b":method", b"CONNECT"),
59+
(b":authority", b"example.com:443"),
60+
(b":scheme", b"https"),
61+
]
62+
with pytest.raises(Exception):
63+
list(validate_outbound_headers(bad1, _client_req_flags()))
64+
with pytest.raises(Exception):
65+
list(validate_outbound_headers(bad2, _client_req_flags()))
66+
67+
68+
def test_extended_connect_requires_regular_tuple_and_send_headers_ok() -> None:
69+
hdrs_bytes = [
70+
(b":method", b"CONNECT"),
71+
(b":protocol", b"websocket"),
72+
(b":scheme", b"https"),
73+
(b":path", b"/chat?room=1"),
74+
(b":authority", b"ws.example.com"),
75+
]
76+
# should not raise
77+
list(validate_outbound_headers(hdrs_bytes, _client_req_flags()))
78+
79+
hdrs_str = [
80+
(":method", "CONNECT"),
81+
(":protocol", "websocket"),
82+
(":scheme", "https"),
83+
(":path", "/chat?room=1"),
84+
(":authority", "ws.example.com"),
85+
]
86+
conn = _new_conn()
87+
# should not raise
88+
conn.send_headers(3, hdrs_str, end_stream=True)
89+
90+
91+
def test_non_connect_still_requires_scheme_and_path() -> None:
92+
hdrs_bytes = [
93+
(b":method", b"GET"),
94+
(b":authority", b"example.com"),
95+
# omit :scheme and :path -> should raise
96+
]
97+
with pytest.raises(Exception):
98+
list(validate_outbound_headers(hdrs_bytes, _client_req_flags()))
99+

0 commit comments

Comments
 (0)