Skip to content

Commit 85068ed

Browse files
authored
Merge pull request #1309 from chasingimpact/fix/ordinary-connect-no-path
HTTP/2: accept ordinary CONNECT without :path/:scheme; add tests (pseudo-headers + round-trip)
2 parents adcb88c + 9242941 commit 85068ed

File tree

4 files changed

+368
-8
lines changed

4 files changed

+368
-8
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ dev
1313

1414
- Support for Python 3.14 has been added.
1515
- Support for PyPy 3.11 has been added.
16+
- Align CONNECT pseudo-header validation with RFC 9113 s8.3 and RFC 8441 s4.
17+
Ordinary CONNECT now requires ``:method=CONNECT`` and ``:authority``, and
18+
forbids ``:scheme``/``:path``. Extended CONNECT (e.g., WebSocket) requires
19+
``:scheme``, ``:path``, ``:authority`` plus ``:protocol``. (PR #1309)
1620

1721
**Bugfixes**
1822

src/h2/utilities.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -361,9 +361,11 @@ def _reject_pseudo_header_fields(headers: Iterable[Header],
361361
)
362362

363363

364-
def _check_pseudo_header_field_acceptability(pseudo_headers: set[bytes | str] | set[bytes] | set[str],
365-
method: bytes | None,
366-
hdr_validation_flags: HeaderValidationFlags) -> None:
364+
def _check_pseudo_header_field_acceptability( # noqa: C901
365+
pseudo_headers: set[bytes | str] | set[bytes] | set[str],
366+
method: bytes | None,
367+
hdr_validation_flags: HeaderValidationFlags,
368+
) -> None:
367369
"""
368370
Given the set of pseudo-headers present in a header block and the
369371
validation flags, confirms that RFC 7540 allows them.
@@ -387,16 +389,36 @@ def _check_pseudo_header_field_acceptability(pseudo_headers: set[bytes | str] |
387389
raise ProtocolError(msg)
388390
elif (not hdr_validation_flags.is_response_header and
389391
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)
392+
# Request header block.
393393
_assert_header_in_set(b":method", pseudo_headers)
394-
_assert_header_in_set(b":scheme", pseudo_headers)
394+
395+
is_connect = (method == b"CONNECT")
396+
is_extended_connect = is_connect and (b":protocol" in pseudo_headers)
397+
398+
if is_connect and not is_extended_connect:
399+
# Ordinary CONNECT (RFC 9113 s8.3):
400+
# MUST NOT include :scheme or :path.
401+
if b":scheme" in pseudo_headers or b":path" in pseudo_headers:
402+
msg = "Ordinary CONNECT MUST NOT include :scheme or :path"
403+
raise ProtocolError(msg)
404+
# :authority presence is enforced elsewhere; no extra asserts here.
405+
elif is_extended_connect:
406+
# Extended CONNECT (RFC 8441 s4): require the regular tuple.
407+
_assert_header_in_set(b":scheme", pseudo_headers)
408+
_assert_header_in_set(b":path", pseudo_headers)
409+
# :authority presence validated by host/authority checker.
410+
else:
411+
# Non-CONNECT requests require :scheme and :path (RFC 9113 s8.3).
412+
_assert_header_in_set(b":scheme", pseudo_headers)
413+
_assert_header_in_set(b":path", pseudo_headers)
414+
395415
invalid_request_headers = pseudo_headers & _RESPONSE_ONLY_HEADERS
396416
if invalid_request_headers:
397417
msg = f"Encountered response-only headers {invalid_request_headers}"
398418
raise ProtocolError(msg)
399-
if method != b"CONNECT":
419+
420+
# If not CONNECT, then :protocol is invalid.
421+
if not is_connect:
400422
invalid_headers = pseudo_headers & _CONNECT_REQUEST_ONLY_HEADERS
401423
if invalid_headers:
402424
msg = f"Encountered connect-request-only headers {invalid_headers!r}"
@@ -698,3 +720,4 @@ def _check_size_limit(self) -> None:
698720
if self._size_limit is not None:
699721
while len(self) > self._size_limit:
700722
self.popitem(last=False)
723+
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)