diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..3480c6fc3
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,10 @@
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.13"
+
+sphinx:
+ configuration: docs/source/conf.py
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 65cebf456..06111c33d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,26 @@
Release History
===============
+4.3.0 (2025-08-23)
+------------------
+
+**API Changes (Backward Incompatible)**
+
+- Reject header names and values containing illegal characters, based on RFC 9113, section 8.2.1.
+ The main Python API is compatible, but some previously valid requests/response headers might now be blocked.
+ Use the `validate_inbound_headers` config option if needed.
+ Thanks to Sebastiano Sartor (sebsrt) for the report.
+
+**API Changes (Backward Compatible)**
+
+- h2 events now have tighter type bounds, e.g. `stream_id` is guaranteed to not be `None` for most events now.
+ This simplifies downstream type checking.
+- Various typing-related improvements.
+
+**Bugfixes**
+
+- Fix error value when opening a new stream on too many open streams.
+
4.2.0 (2025-02-01)
------------------
diff --git a/MANIFEST.in b/MANIFEST.in
index 25d6814dc..ff2f15801 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -11,3 +11,4 @@ recursive-include examples *.py *.crt *.key *.pem *.csr
include README.rst LICENSE CHANGELOG.rst pyproject.toml
global-exclude *.pyc *.pyo *.swo *.swp *.map *.yml *.DS_Store
+exclude .readthedocs.yaml
diff --git a/README.rst b/README.rst
index 2b221d777..dd01fe3a2 100644
--- a/README.rst
+++ b/README.rst
@@ -62,7 +62,7 @@ to large feature requests and changes.
Before you contribute (either by opening an issue or filing a pull request),
please `read the contribution guidelines`_.
-.. _read the contribution guidelines: http://python-hyper.org/en/latest/contributing.html
+.. _read the contribution guidelines: https://python-hyper.org/en/latest/contributing.html
License
=======
diff --git a/pyproject.toml b/pyproject.toml
index 9035d3955..0830a91d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,7 +73,7 @@ packaging = [
"check-manifest==0.50",
"readme-renderer==44.0",
"build>=1.2.2,<2",
- "twine>=5.1.1,<6",
+ "twine>=6.1.0,<7",
"wheel>=0.45.0,<1",
]
@@ -183,9 +183,6 @@ python = """
"""
[tool.tox.env_run_base]
-pass_env = [
- "GITHUB_*",
-]
dependency_groups = ["testing"]
commands = [
["python", "-bb", "-m", "pytest", "--cov-report=xml", "--cov-report=term", "--cov=h2", { replace = "posargs", extend = true }]
diff --git a/src/h2/__init__.py b/src/h2/__init__.py
index 0764daadb..7f0ccd774 100644
--- a/src/h2/__init__.py
+++ b/src/h2/__init__.py
@@ -3,4 +3,4 @@
"""
from __future__ import annotations
-__version__ = "4.2.0"
+__version__ = "4.3.0"
diff --git a/src/h2/connection.py b/src/h2/connection.py
index 28be9fca2..313efc146 100644
--- a/src/h2/connection.py
+++ b/src/h2/connection.py
@@ -793,8 +793,9 @@ def send_headers(self,
# Check we can open the stream.
if stream_id not in self.streams:
max_open_streams = self.remote_settings.max_concurrent_streams
- if (self.open_outbound_streams + 1) > max_open_streams:
- msg = f"Max outbound streams is {max_open_streams}, {self.open_outbound_streams} open"
+ value = self.open_outbound_streams # take a copy due to the property accessor having side affects
+ if (value + 1) > max_open_streams:
+ msg = f"Max outbound streams is {max_open_streams}, {value} open"
raise TooManyStreamsError(msg)
self.state_machine.process_input(ConnectionInputs.SEND_HEADERS)
@@ -1593,8 +1594,9 @@ def _receive_headers_frame(self, frame: HeadersFrame) -> tuple[list[Frame], list
# stream ID is valid.
if frame.stream_id not in self.streams:
max_open_streams = self.local_settings.max_concurrent_streams
- if (self.open_inbound_streams + 1) > max_open_streams:
- msg = f"Max outbound streams is {max_open_streams}, {self.open_outbound_streams} open"
+ value = self.open_inbound_streams # take a copy due to the property accessor having side affects
+ if (value + 1) > max_open_streams:
+ msg = f"Max inbound streams is {max_open_streams}, {value} open"
raise TooManyStreamsError(msg)
# Let's decode the headers. We handle headers as bytes internally up
@@ -1806,9 +1808,7 @@ def _receive_window_update_frame(self, frame: WindowUpdateFrame) -> tuple[list[F
)
# FIXME: Should we split this into one event per active stream?
- window_updated_event = WindowUpdated()
- window_updated_event.stream_id = 0
- window_updated_event.delta = frame.window_increment
+ window_updated_event = WindowUpdated(stream_id=0, delta=frame.window_increment)
stream_events = [window_updated_event]
frames = []
@@ -1825,9 +1825,9 @@ def _receive_ping_frame(self, frame: PingFrame) -> tuple[list[Frame], list[Event
evt: PingReceived | PingAckReceived
if "ACK" in frame.flags:
- evt = PingAckReceived()
+ evt = PingAckReceived(ping_data=frame.opaque_data)
else:
- evt = PingReceived()
+ evt = PingReceived(ping_data=frame.opaque_data)
# automatically ACK the PING with the same 'opaque data'
f = PingFrame(0)
@@ -1835,7 +1835,6 @@ def _receive_ping_frame(self, frame: PingFrame) -> tuple[list[Frame], list[Event
f.opaque_data = frame.opaque_data
frames.append(f)
- evt.ping_data = frame.opaque_data
events.append(evt)
return frames, events
@@ -1974,8 +1973,7 @@ def _receive_unknown_frame(self, frame: ExtensionFrame) -> tuple[list[Frame], li
self.config.logger.debug(
"Received unknown extension frame (ID %d)", frame.stream_id,
)
- event = UnknownFrameReceived()
- event.frame = frame
+ event = UnknownFrameReceived(frame=frame)
return [], [event]
def _local_settings_acked(self) -> dict[SettingCodes | int, ChangedSetting]:
diff --git a/src/h2/events.py b/src/h2/events.py
index b81fd1a63..6aab0713d 100644
--- a/src/h2/events.py
+++ b/src/h2/events.py
@@ -11,24 +11,41 @@
from __future__ import annotations
import binascii
-from typing import TYPE_CHECKING
+import sys
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
from .settings import ChangedSetting, SettingCodes, Settings, _setting_code_from_int
if TYPE_CHECKING: # pragma: no cover
- from hpack import HeaderTuple
+ from hpack.struct import Header
from hyperframe.frame import Frame
from .errors import ErrorCodes
+if sys.version_info < (3, 10): # pragma: no cover
+ kw_only: dict[str, bool] = {}
+else: # pragma: no cover
+ kw_only = {"kw_only": True}
+
+
+_LAZY_INIT: Any = object()
+"""
+Some h2 events are instantiated by the state machine, but its attributes are
+subsequently populated by H2Stream. To make this work with strict type annotations
+on the events, they are temporarily set to this placeholder value.
+This value should never be exposed to users.
+"""
+
+
class Event:
"""
Base class for h2 events.
"""
-
+@dataclass(**kw_only)
class RequestReceived(Event):
"""
The RequestReceived event is fired whenever all of a request's headers
@@ -47,31 +64,35 @@ class RequestReceived(Event):
Added ``stream_ended`` and ``priority_updated`` properties.
"""
- def __init__(self) -> None:
- #: The Stream ID for the stream this request was made on.
- self.stream_id: int | None = None
+ stream_id: int
+ """The Stream ID for the stream this request was made on."""
+
+ headers: list[Header] = _LAZY_INIT
+ """The request headers."""
+
+ stream_ended: StreamEnded | None = None
+ """
+ If this request also ended the stream, the associated
+ :class:`StreamEnded
` event will be available
+ here.
- #: The request headers.
- self.headers: list[HeaderTuple] | None = None
+ .. versionadded:: 2.4.0
+ """
- #: If this request also ended the stream, the associated
- #: :class:`StreamEnded ` event will be available
- #: here.
- #:
- #: .. versionadded:: 2.4.0
- self.stream_ended: StreamEnded | None = None
+ priority_updated: PriorityUpdated | None = None
+ """
+ If this request also had associated priority information, the
+ associated :class:`PriorityUpdated `
+ event will be available here.
- #: If this request also had associated priority information, the
- #: associated :class:`PriorityUpdated `
- #: event will be available here.
- #:
- #: .. versionadded:: 2.4.0
- self.priority_updated: PriorityUpdated | None = None
+ .. versionadded:: 2.4.0
+ """
def __repr__(self) -> str:
return f""
+@dataclass(**kw_only)
class ResponseReceived(Event):
"""
The ResponseReceived event is fired whenever response headers are received.
@@ -86,31 +107,35 @@ class ResponseReceived(Event):
Added ``stream_ended`` and ``priority_updated`` properties.
"""
- def __init__(self) -> None:
- #: The Stream ID for the stream this response was made on.
- self.stream_id: int | None = None
+ stream_id: int
+ """The Stream ID for the stream this response was made on."""
+
+ headers: list[Header] = _LAZY_INIT
+ """The response headers."""
+
+ stream_ended: StreamEnded | None = None
+ """
+ If this response also ended the stream, the associated
+ :class:`StreamEnded ` event will be available
+ here.
- #: The response headers.
- self.headers: list[HeaderTuple] | None = None
+ .. versionadded:: 2.4.0
+ """
- #: If this response also ended the stream, the associated
- #: :class:`StreamEnded ` event will be available
- #: here.
- #:
- #: .. versionadded:: 2.4.0
- self.stream_ended: StreamEnded | None = None
+ priority_updated: PriorityUpdated | None = None
+ """
+ If this response also had associated priority information, the
+ associated :class:`PriorityUpdated `
+ event will be available here.
- #: If this response also had associated priority information, the
- #: associated :class:`PriorityUpdated `
- #: event will be available here.
- #:
- #: .. versionadded:: 2.4.0
- self.priority_updated: PriorityUpdated | None = None
+ .. versionadded:: 2.4.0
+ """
def __repr__(self) -> str:
return f""
+@dataclass(**kw_only)
class TrailersReceived(Event):
"""
The TrailersReceived event is fired whenever trailers are received on a
@@ -128,25 +153,28 @@ class TrailersReceived(Event):
Added ``stream_ended`` and ``priority_updated`` properties.
"""
- def __init__(self) -> None:
- #: The Stream ID for the stream on which these trailers were received.
- self.stream_id: int | None = None
+ stream_id: int
+ """The Stream ID for the stream on which these trailers were received."""
+
+ headers: list[Header] = _LAZY_INIT
+ """The trailers themselves."""
+
+ stream_ended: StreamEnded | None = None
+ """
+ Trailers always end streams. This property has the associated
+ :class:`StreamEnded ` in it.
- #: The trailers themselves.
- self.headers: list[HeaderTuple] | None = None
+ .. versionadded:: 2.4.0
+ """
- #: Trailers always end streams. This property has the associated
- #: :class:`StreamEnded ` in it.
- #:
- #: .. versionadded:: 2.4.0
- self.stream_ended: StreamEnded | None = None
+ priority_updated: PriorityUpdated | None = None
+ """
+ If the trailers also set associated priority information, the
+ associated :class:`PriorityUpdated `
+ event will be available here.
- #: If the trailers also set associated priority information, the
- #: associated :class:`PriorityUpdated `
- #: event will be available here.
- #:
- #: .. versionadded:: 2.4.0
- self.priority_updated: PriorityUpdated | None = None
+ .. versionadded:: 2.4.0
+ """
def __repr__(self) -> str:
return f""
@@ -207,7 +235,7 @@ class _PushedRequestSent(_HeadersSent):
"""
-
+@dataclass(**kw_only)
class InformationalResponseReceived(Event):
"""
The InformationalResponseReceived event is fired when an informational
@@ -231,25 +259,26 @@ class InformationalResponseReceived(Event):
Added ``priority_updated`` property.
"""
- def __init__(self) -> None:
- #: The Stream ID for the stream this informational response was made
- #: on.
- self.stream_id: int | None = None
+ stream_id: int
+ """The Stream ID for the stream this informational response was made on."""
- #: The headers for this informational response.
- self.headers: list[HeaderTuple] | None = None
+ headers: list[Header] = _LAZY_INIT
+ """The headers for this informational response."""
+
+ priority_updated: PriorityUpdated | None = None
+ """
+ If this response also had associated priority information, the
+ associated :class:`PriorityUpdated `
+ event will be available here.
- #: If this response also had associated priority information, the
- #: associated :class:`PriorityUpdated `
- #: event will be available here.
- #:
- #: .. versionadded:: 2.4.0
- self.priority_updated: PriorityUpdated | None = None
+ .. versionadded:: 2.4.0
+ """
def __repr__(self) -> str:
return f""
+@dataclass(**kw_only)
class DataReceived(Event):
"""
The DataReceived event is fired whenever data is received on a stream from
@@ -260,25 +289,28 @@ class DataReceived(Event):
Added ``stream_ended`` property.
"""
- def __init__(self) -> None:
- #: The Stream ID for the stream this data was received on.
- self.stream_id: int | None = None
+ stream_id: int
+ """The Stream ID for the stream this data was received on."""
+
+ data: bytes = _LAZY_INIT
+ """The data itself."""
- #: The data itself.
- self.data: bytes | None = None
+ flow_controlled_length: int = _LAZY_INIT
+ """
+ The amount of data received that counts against the flow control
+ window. Note that padding counts against the flow control window, so
+ when adjusting flow control you should always use this field rather
+ than ``len(data)``.
+ """
- #: The amount of data received that counts against the flow control
- #: window. Note that padding counts against the flow control window, so
- #: when adjusting flow control you should always use this field rather
- #: than ``len(data)``.
- self.flow_controlled_length: int | None = None
+ stream_ended: StreamEnded | None = None
+ """
+ If this data chunk also completed the stream, the associated
+ :class:`StreamEnded ` event will be available
+ here.
- #: If this data chunk also completed the stream, the associated
- #: :class:`StreamEnded ` event will be available
- #: here.
- #:
- #: .. versionadded:: 2.4.0
- self.stream_ended: StreamEnded | None = None
+ .. versionadded:: 2.4.0
+ """
def __repr__(self) -> str:
return (
@@ -292,6 +324,7 @@ def __repr__(self) -> str:
)
+@dataclass(**kw_only)
class WindowUpdated(Event):
"""
The WindowUpdated event is fired whenever a flow control window changes
@@ -301,13 +334,16 @@ class WindowUpdated(Event):
the connection), and the delta in the window size.
"""
- def __init__(self) -> None:
- #: The Stream ID of the stream whose flow control window was changed.
- #: May be ``0`` if the connection window was changed.
- self.stream_id: int | None = None
+ stream_id: int
+ """
+ The Stream ID of the stream whose flow control window was changed.
+ May be ``0`` if the connection window was changed.
+ """
- #: The window delta.
- self.delta: int | None = None
+ delta: int = _LAZY_INIT
+ """
+ The window delta.
+ """
def __repr__(self) -> str:
return f""
@@ -367,6 +403,7 @@ def __repr__(self) -> str:
)
+@dataclass(**kw_only)
class PingReceived(Event):
"""
The PingReceived event is fired whenever a PING is received. It contains
@@ -376,14 +413,14 @@ class PingReceived(Event):
.. versionadded:: 3.1.0
"""
- def __init__(self) -> None:
- #: The data included on the ping.
- self.ping_data: bytes | None = None
+ ping_data: bytes
+ """The data included on the ping."""
def __repr__(self) -> str:
return f""
+@dataclass(**kw_only)
class PingAckReceived(Event):
"""
The PingAckReceived event is fired whenever a PING acknowledgment is
@@ -396,14 +433,14 @@ class PingAckReceived(Event):
Removed deprecated but equivalent ``PingAcknowledged``.
"""
- def __init__(self) -> None:
- #: The data included on the ping.
- self.ping_data: bytes | None = None
+ ping_data: bytes
+ """The data included on the ping."""
def __repr__(self) -> str:
return f""
+@dataclass(**kw_only)
class StreamEnded(Event):
"""
The StreamEnded event is fired whenever a stream is ended by a remote
@@ -411,14 +448,14 @@ class StreamEnded(Event):
locally, but no further data or headers should be expected on that stream.
"""
- def __init__(self) -> None:
- #: The Stream ID of the stream that was closed.
- self.stream_id: int | None = None
+ stream_id: int
+ """The Stream ID of the stream that was closed."""
def __repr__(self) -> str:
return f""
+@dataclass(**kw_only)
class StreamReset(Event):
"""
The StreamReset event is fired in two situations. The first is when the
@@ -430,16 +467,20 @@ class StreamReset(Event):
This event is now fired when h2 automatically resets a stream.
"""
- def __init__(self) -> None:
- #: The Stream ID of the stream that was reset.
- self.stream_id: int | None = None
+ stream_id: int
+ """
+ The Stream ID of the stream that was reset.
+ """
- #: The error code given. Either one of :class:`ErrorCodes
- #: ` or ``int``
- self.error_code: ErrorCodes | None = None
+ error_code: ErrorCodes | int = _LAZY_INIT
+ """
+ The error code given.
+ """
- #: Whether the remote peer sent a RST_STREAM or we did.
- self.remote_reset = True
+ remote_reset: bool = True
+ """
+ Whether the remote peer sent a RST_STREAM or we did.
+ """
def __repr__(self) -> str:
return f""
@@ -460,7 +501,7 @@ def __init__(self) -> None:
self.parent_stream_id: int | None = None
#: The request headers, sent by the remote party in the push.
- self.headers: list[HeaderTuple] | None = None
+ self.headers: list[Header] | None = None
def __repr__(self) -> str:
return (
@@ -601,6 +642,7 @@ def __repr__(self) -> str:
)
+@dataclass(**kw_only)
class UnknownFrameReceived(Event):
"""
The UnknownFrameReceived event is fired when the remote peer sends a frame
@@ -616,9 +658,7 @@ class UnknownFrameReceived(Event):
.. versionadded:: 2.7.0
"""
- def __init__(self) -> None:
- #: The hyperframe Frame object that encapsulates the received frame.
- self.frame: Frame | None = None
+ frame: Frame
def __repr__(self) -> str:
return ""
diff --git a/src/h2/frame_buffer.py b/src/h2/frame_buffer.py
index 30d96e816..e7b0a7126 100644
--- a/src/h2/frame_buffer.py
+++ b/src/h2/frame_buffer.py
@@ -30,7 +30,7 @@ class FrameBuffer:
"""
def __init__(self, server: bool = False) -> None:
- self.data = b""
+ self._data = bytearray()
self.max_frame_size = 0
self._preamble = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" if server else b""
self._preamble_len = len(self._preamble)
@@ -54,7 +54,7 @@ def add_data(self, data: bytes) -> None:
self._preamble_len -= of_which_preamble
self._preamble = self._preamble[of_which_preamble:]
- self.data += data
+ self._data += data
def _validate_frame_length(self, length: int) -> None:
"""
@@ -119,18 +119,18 @@ def __iter__(self) -> FrameBuffer:
def __next__(self) -> Frame:
# First, check that we have enough data to successfully parse the
# next frame header. If not, bail. Otherwise, parse it.
- if len(self.data) < 9:
+ if len(self._data) < 9:
raise StopIteration
try:
- f, length = Frame.parse_frame_header(memoryview(self.data[:9]))
+ f, length = Frame.parse_frame_header(memoryview(self._data[:9]))
except (InvalidDataError, InvalidFrameError) as err: # pragma: no cover
msg = f"Received frame with invalid header: {err!s}"
raise ProtocolError(msg) from err
# Next, check that we have enough length to parse the frame body. If
# not, bail, leaving the frame header data in the buffer for next time.
- if len(self.data) < length + 9:
+ if len(self._data) < length + 9:
raise StopIteration
# Confirm the frame has an appropriate length.
@@ -138,7 +138,7 @@ def __next__(self) -> Frame:
# Try to parse the frame body
try:
- f.parse_body(memoryview(self.data[9:9+length]))
+ f.parse_body(memoryview(self._data[9:9+length]))
except InvalidDataError as err:
msg = "Received frame with non-compliant data"
raise ProtocolError(msg) from err
@@ -148,7 +148,7 @@ def __next__(self) -> Frame:
# At this point, as we know we'll use or discard the entire frame, we
# can update the data.
- self.data = self.data[9+length:]
+ self._data = self._data[9+length:]
# Pass the frame through the header buffer.
new_frame = self._update_header_buffer(f)
diff --git a/src/h2/stream.py b/src/h2/stream.py
index 7d4a12e35..d6f5845c3 100644
--- a/src/h2/stream.py
+++ b/src/h2/stream.py
@@ -7,7 +7,7 @@
from __future__ import annotations
from enum import Enum, IntEnum
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Union, cast
from hpack import HeaderTuple
from hyperframe.frame import AltSvcFrame, ContinuationFrame, DataFrame, Frame, HeadersFrame, PushPromiseFrame, RstStreamFrame, WindowUpdateFrame
@@ -46,7 +46,7 @@
from .windows import WindowManager
if TYPE_CHECKING: # pragma: no cover
- from collections.abc import Generator, Iterable
+ from collections.abc import Callable, Generator, Iterable
from hpack.hpack import Encoder
from hpack.struct import Header, HeaderWeaklyTyped
@@ -131,7 +131,7 @@ def __init__(self, stream_id: int) -> None:
# How the stream was closed. One of StreamClosedBy.
self.stream_closed_by: StreamClosedBy | None = None
- def process_input(self, input_: StreamInputs) -> Any:
+ def process_input(self, input_: StreamInputs) -> list[Event]:
"""
Process a specific input in the state machine.
"""
@@ -195,8 +195,7 @@ def request_received(self, previous_state: StreamState) -> list[Event]:
self.client = False
self.headers_received = True
- event = RequestReceived()
- event.stream_id = self.stream_id
+ event = RequestReceived(stream_id=self.stream_id)
return [event]
def response_received(self, previous_state: StreamState) -> list[Event]:
@@ -208,11 +207,11 @@ def response_received(self, previous_state: StreamState) -> list[Event]:
if not self.headers_received:
assert self.client is True
self.headers_received = True
- event = ResponseReceived()
+ event = ResponseReceived(stream_id=self.stream_id)
else:
assert not self.trailers_received
self.trailers_received = True
- event = TrailersReceived()
+ event = TrailersReceived(stream_id=self.stream_id)
event.stream_id = self.stream_id
return [event]
@@ -224,25 +223,21 @@ def data_received(self, previous_state: StreamState) -> list[Event]:
if not self.headers_received:
msg = "cannot receive data before headers"
raise ProtocolError(msg)
- event = DataReceived()
- event.stream_id = self.stream_id
+ event = DataReceived(stream_id=self.stream_id)
return [event]
def window_updated(self, previous_state: StreamState) -> list[Event]:
"""
Fires when a window update frame is received.
"""
- event = WindowUpdated()
- event.stream_id = self.stream_id
- return [event]
+ return [WindowUpdated(stream_id=self.stream_id)]
def stream_half_closed(self, previous_state: StreamState) -> list[Event]:
"""
Fires when an END_STREAM flag is received in the OPEN state,
transitioning this stream to a HALF_CLOSED_REMOTE state.
"""
- event = StreamEnded()
- event.stream_id = self.stream_id
+ event = StreamEnded(stream_id=self.stream_id)
return [event]
def stream_ended(self, previous_state: StreamState) -> list[Event]:
@@ -250,8 +245,7 @@ def stream_ended(self, previous_state: StreamState) -> list[Event]:
Fires when a stream is cleanly ended.
"""
self.stream_closed_by = StreamClosedBy.RECV_END_STREAM
- event = StreamEnded()
- event.stream_id = self.stream_id
+ event = StreamEnded(stream_id=self.stream_id)
return [event]
def stream_reset(self, previous_state: StreamState) -> list[Event]:
@@ -259,9 +253,7 @@ def stream_reset(self, previous_state: StreamState) -> list[Event]:
Fired when a stream is forcefully reset.
"""
self.stream_closed_by = StreamClosedBy.RECV_RST_STREAM
- event = StreamReset()
- event.stream_id = self.stream_id
- return [event]
+ return [StreamReset(stream_id=self.stream_id)]
def send_new_pushed_stream(self, previous_state: StreamState) -> list[Event]:
"""
@@ -315,21 +307,23 @@ def recv_push_promise(self, previous_state: StreamState) -> list[Event]:
event.parent_stream_id = self.stream_id
return [event]
- def send_end_stream(self, previous_state: StreamState) -> None:
+ def send_end_stream(self, previous_state: StreamState) -> list[Event]:
"""
Called when an attempt is made to send END_STREAM in the
HALF_CLOSED_REMOTE state.
"""
self.stream_closed_by = StreamClosedBy.SEND_END_STREAM
+ return []
- def send_reset_stream(self, previous_state: StreamState) -> None:
+ def send_reset_stream(self, previous_state: StreamState) -> list[Event]:
"""
Called when an attempt is made to send RST_STREAM in a non-closed
stream state.
"""
self.stream_closed_by = StreamClosedBy.SEND_RST_STREAM
+ return []
- def reset_stream_on_error(self, previous_state: StreamState) -> None:
+ def reset_stream_on_error(self, previous_state: StreamState) -> list[Event]:
"""
Called when we need to forcefully emit another RST_STREAM frame on
behalf of the state machine.
@@ -342,15 +336,16 @@ def reset_stream_on_error(self, previous_state: StreamState) -> None:
self.stream_closed_by = StreamClosedBy.SEND_RST_STREAM
error = StreamClosedError(self.stream_id)
-
- event = StreamReset()
- event.stream_id = self.stream_id
- event.error_code = ErrorCodes.STREAM_CLOSED
- event.remote_reset = False
- error._events = [event]
+ error._events = [
+ StreamReset(
+ stream_id=self.stream_id,
+ error_code=ErrorCodes.STREAM_CLOSED,
+ remote_reset=False,
+ ),
+ ]
raise error
- def recv_on_closed_stream(self, previous_state: StreamState) -> None:
+ def recv_on_closed_stream(self, previous_state: StreamState) -> list[Event]:
"""
Called when an unexpected frame is received on an already-closed
stream.
@@ -362,7 +357,7 @@ def recv_on_closed_stream(self, previous_state: StreamState) -> None:
"""
raise StreamClosedError(self.stream_id)
- def send_on_closed_stream(self, previous_state: StreamState) -> None:
+ def send_on_closed_stream(self, previous_state: StreamState) -> list[Event]:
"""
Called when an attempt is made to send data on an already-closed
stream.
@@ -374,7 +369,7 @@ def send_on_closed_stream(self, previous_state: StreamState) -> None:
"""
raise StreamClosedError(self.stream_id)
- def recv_push_on_closed_stream(self, previous_state: StreamState) -> None:
+ def recv_push_on_closed_stream(self, previous_state: StreamState) -> list[Event]:
"""
Called when a PUSH_PROMISE frame is received on a full stop
stream.
@@ -393,7 +388,7 @@ def recv_push_on_closed_stream(self, previous_state: StreamState) -> None:
msg = "Attempted to push on closed stream."
raise ProtocolError(msg)
- def send_push_on_closed_stream(self, previous_state: StreamState) -> None:
+ def send_push_on_closed_stream(self, previous_state: StreamState) -> list[Event]:
"""
Called when an attempt is made to push on an already-closed stream.
@@ -429,9 +424,7 @@ def recv_informational_response(self, previous_state: StreamState) -> list[Event
msg = "Informational response after final response"
raise ProtocolError(msg)
- event = InformationalResponseReceived()
- event.stream_id = self.stream_id
- return [event]
+ return [InformationalResponseReceived(stream_id=self.stream_id)]
def recv_alt_svc(self, previous_state: StreamState) -> list[Event]:
"""
@@ -473,7 +466,7 @@ def recv_alt_svc(self, previous_state: StreamState) -> list[Event]:
# the event and let it get populated.
return [AlternativeServiceAvailable()]
- def send_alt_svc(self, previous_state: StreamState) -> None:
+ def send_alt_svc(self, previous_state: StreamState) -> list[Event]:
"""
Called when sending an ALTSVC frame on this stream.
@@ -489,6 +482,7 @@ def send_alt_svc(self, previous_state: StreamState) -> None:
if self.headers_sent:
msg = "Cannot send ALTSVC after sending response headers."
raise ProtocolError(msg)
+ return []
@@ -561,7 +555,10 @@ def send_alt_svc(self, previous_state: StreamState) -> None:
# (state, input) to tuples of (side_effect_function, end_state). This
# map contains all allowed transitions: anything not in this map is
# invalid and immediately causes a transition to ``closed``.
-_transitions = {
+_transitions: dict[
+ tuple[StreamState, StreamInputs],
+ tuple[Callable[[H2StreamStateMachine, StreamState], list[Event]] | None, StreamState],
+] = {
# State: idle
(StreamState.IDLE, StreamInputs.SEND_HEADERS):
(H2StreamStateMachine.request_sent, StreamState.OPEN),
@@ -1040,10 +1037,11 @@ def receive_push_promise_in_band(self,
events = self.state_machine.process_input(
StreamInputs.RECV_PUSH_PROMISE,
)
- events[0].pushed_stream_id = promised_stream_id
+ push_event = cast("PushedStreamReceived", events[0])
+ push_event.pushed_stream_id = promised_stream_id
hdr_validation_flags = self._build_hdr_validation_flags(events)
- events[0].headers = self._process_received_headers(
+ push_event.headers = self._process_received_headers(
headers, hdr_validation_flags, header_encoding,
)
return [], events
@@ -1077,22 +1075,30 @@ def receive_headers(self,
input_ = StreamInputs.RECV_HEADERS
events = self.state_machine.process_input(input_)
+ headers_event = cast(
+ "Union[RequestReceived, ResponseReceived, TrailersReceived, InformationalResponseReceived]",
+ events[0],
+ )
if end_stream:
es_events = self.state_machine.process_input(
StreamInputs.RECV_END_STREAM,
)
- events[0].stream_ended = es_events[0]
+ # We ensured it's not an information response at the beginning of the method.
+ cast(
+ "Union[RequestReceived, ResponseReceived, TrailersReceived]",
+ headers_event,
+ ).stream_ended = cast("StreamEnded", es_events[0])
events += es_events
self._initialize_content_length(headers)
- if isinstance(events[0], TrailersReceived) and not end_stream:
+ if isinstance(headers_event, TrailersReceived) and not end_stream:
msg = "Trailers must have END_STREAM set"
raise ProtocolError(msg)
hdr_validation_flags = self._build_hdr_validation_flags(events)
- events[0].headers = self._process_received_headers(
+ headers_event.headers = self._process_received_headers(
headers, hdr_validation_flags, header_encoding,
)
return [], events
@@ -1106,6 +1112,7 @@ def receive_data(self, data: bytes, end_stream: bool, flow_control_len: int) ->
"set to %d", self, end_stream, flow_control_len,
)
events = self.state_machine.process_input(StreamInputs.RECV_DATA)
+ data_event = cast("DataReceived", events[0])
self._inbound_window_manager.window_consumed(flow_control_len)
self._track_content_length(len(data), end_stream)
@@ -1113,11 +1120,11 @@ def receive_data(self, data: bytes, end_stream: bool, flow_control_len: int) ->
es_events = self.state_machine.process_input(
StreamInputs.RECV_END_STREAM,
)
- events[0].stream_ended = es_events[0]
+ data_event.stream_ended = cast("StreamEnded", es_events[0])
events.extend(es_events)
- events[0].data = data
- events[0].flow_controlled_length = flow_control_len
+ data_event.data = data
+ data_event.flow_controlled_length = flow_control_len
return [], events
def receive_window_update(self, increment: int) -> tuple[list[Frame], list[Event]]:
@@ -1137,7 +1144,7 @@ def receive_window_update(self, increment: int) -> tuple[list[Frame], list[Event
# this should be treated as a *stream* error, not a *connection* error.
# That means we need to catch the error and forcibly close the stream.
if events:
- events[0].delta = increment
+ cast("WindowUpdated", events[0]).delta = increment
try:
self.outbound_flow_control_window = guard_increment_window(
self.outbound_flow_control_window,
@@ -1146,13 +1153,14 @@ def receive_window_update(self, increment: int) -> tuple[list[Frame], list[Event
except FlowControlError:
# Ok, this is bad. We're going to need to perform a local
# reset.
- event = StreamReset()
- event.stream_id = self.stream_id
- event.error_code = ErrorCodes.FLOW_CONTROL_ERROR
- event.remote_reset = False
-
- events = [event]
- frames = self.reset_stream(event.error_code)
+ events = [
+ StreamReset(
+ stream_id=self.stream_id,
+ error_code=ErrorCodes.FLOW_CONTROL_ERROR,
+ remote_reset=False,
+ ),
+ ]
+ frames = self.reset_stream(ErrorCodes.FLOW_CONTROL_ERROR)
return frames, events
@@ -1220,7 +1228,7 @@ def stream_reset(self, frame: RstStreamFrame) -> tuple[list[Frame], list[Event]]
if events:
# We don't fire an event if this stream is already closed.
- events[0].error_code = _error_code_from_int(frame.error_code)
+ cast("StreamReset", events[0]).error_code = _error_code_from_int(frame.error_code)
return [], events
@@ -1322,7 +1330,7 @@ def _build_headers_frames(self,
def _process_received_headers(self,
headers: Iterable[Header],
header_validation_flags: HeaderValidationFlags,
- header_encoding: bool | str | None) -> Iterable[Header]:
+ header_encoding: bool | str | None) -> list[Header]:
"""
When headers have been received from the remote peer, run a processing
pipeline on them to transform them into the appropriate form for
diff --git a/src/h2/utilities.py b/src/h2/utilities.py
index 8cafdbd50..a7409b388 100644
--- a/src/h2/utilities.py
+++ b/src/h2/utilities.py
@@ -7,8 +7,6 @@
from __future__ import annotations
import collections
-import re
-from string import whitespace
from typing import TYPE_CHECKING, Any, NamedTuple
from hpack.struct import HeaderTuple, NeverIndexedHeaderTuple
@@ -20,7 +18,6 @@
from hpack.struct import Header, HeaderWeaklyTyped
-UPPER_RE = re.compile(b"[A-Z]")
SIGIL = ord(b":")
INFORMATIONAL_START = ord(b"1")
@@ -70,9 +67,6 @@
_CONNECT_REQUEST_ONLY_HEADERS = frozenset([b":protocol"])
-_WHITESPACE = frozenset(map(ord, whitespace))
-
-
def _secure_headers(headers: Iterable[Header],
hdr_validation_flags: HeaderValidationFlags | None) -> Generator[Header, None, None]:
"""
@@ -201,13 +195,10 @@ def validate_headers(headers: Iterable[Header], hdr_validation_flags: HeaderVali
# For example, we avoid tuple unpacking in loops because it represents a
# fixed cost that we don't want to spend, instead indexing into the header
# tuples.
- headers = _reject_empty_header_names(
- headers, hdr_validation_flags,
- )
- headers = _reject_uppercase_header_fields(
+ headers = _reject_illegal_characters(
headers, hdr_validation_flags,
)
- headers = _reject_surrounding_whitespace(
+ headers = _reject_empty_header_names(
headers, hdr_validation_flags,
)
headers = _reject_te(
@@ -225,53 +216,66 @@ def validate_headers(headers: Iterable[Header], hdr_validation_flags: HeaderVali
return _check_path_header(headers, hdr_validation_flags)
-
-def _reject_empty_header_names(headers: Iterable[Header],
+def _reject_illegal_characters(headers: Iterable[Header],
hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]:
"""
- Raises a ProtocolError if any header names are empty (length 0).
- While hpack decodes such headers without errors, they are semantically
- forbidden in HTTP, see RFC 7230, stating that they must be at least one
- character long.
+ Raises a ProtocolError if any header names or values contain illegal characters.
+ See .
"""
for header in headers:
- if len(header[0]) == 0:
- msg = "Received header name with zero length."
+ # > A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+ # > or 0x7f-0xff (all ranges inclusive).
+ for c in header[0]:
+ if 0x41 <= c <= 0x5a:
+ msg = f"Received uppercase header name {header[0]!r}."
+ raise ProtocolError(msg)
+ if c <= 0x20 or c >= 0x7f:
+ msg = f"Illegal character '{chr(c)}' in header name: {header[0]!r}"
+ raise ProtocolError(msg)
+
+ # > With the exception of pseudo-header fields (Section 8.3), which have a name
+ # > that starts with a single colon, field names MUST NOT include a colon (ASCII
+ # > COLON, 0x3a).
+ if header[0].find(b":", 1) != -1:
+ msg = f"Illegal character ':' in header name: {header[0]!r}"
raise ProtocolError(msg)
- yield header
+ # For compatibility with RFC 7230 header fields, we need to allow the field
+ # value to be an empty string. This is ludicrous, but technically allowed.
+ if field_value := header[1]:
+
+ # > A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed
+ # > (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.
+ for c in field_value:
+ if c == 0 or c == 0x0a or c == 0x0d: # noqa: PLR1714
+ msg = f"Illegal character '{chr(c)}' in header value: {field_value!r}"
+ raise ProtocolError(msg)
+
+ # > A field value MUST NOT start or end with an ASCII whitespace character
+ # > (ASCII SP or HTAB, 0x20 or 0x09).
+ if (
+ field_value[0] == 0x20 or
+ field_value[0] == 0x09 or
+ field_value[-1] == 0x20 or
+ field_value[-1] == 0x09
+ ):
+ msg = f"Received header value surrounded by whitespace {field_value!r}"
+ raise ProtocolError(msg)
-def _reject_uppercase_header_fields(headers: Iterable[Header],
- hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]:
- """
- Raises a ProtocolError if any uppercase character is found in a header
- block.
- """
- for header in headers:
- if UPPER_RE.search(header[0]):
- msg = f"Received uppercase header name {header[0]!r}."
- raise ProtocolError(msg)
yield header
-def _reject_surrounding_whitespace(headers: Iterable[Header],
- hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]:
+def _reject_empty_header_names(headers: Iterable[Header],
+ hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]:
"""
- Raises a ProtocolError if any header name or value is surrounded by
- whitespace characters.
+ Raises a ProtocolError if any header names are empty (length 0).
+ While hpack decodes such headers without errors, they are semantically
+ forbidden in HTTP, see RFC 7230, stating that they must be at least one
+ character long.
"""
- # For compatibility with RFC 7230 header fields, we need to allow the field
- # value to be an empty string. This is ludicrous, but technically allowed.
- # The field name may not be empty, though, so we can safely assume that it
- # must have at least one character in it and throw exceptions if it
- # doesn't.
for header in headers:
- if header[0][0] in _WHITESPACE or header[0][-1] in _WHITESPACE:
- msg = f"Received header name surrounded by whitespace {header[0]!r}"
- raise ProtocolError(msg)
- if header[1] and ((header[1][0] in _WHITESPACE) or
- (header[1][-1] in _WHITESPACE)):
- msg = f"Received header value surrounded by whitespace {header[1]!r}"
+ if len(header[0]) == 0:
+ msg = "Received header name with zero length."
raise ProtocolError(msg)
yield header
diff --git a/tests/test_events.py b/tests/test_events.py
index aac913586..a43543c86 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -7,6 +7,7 @@
import inspect
import sys
+import hyperframe.frame
import pytest
from hypothesis import given
from hypothesis.strategies import integers, lists, tuples
@@ -114,9 +115,10 @@ def test_requestreceived_repr(self) -> None:
"""
RequestReceived has a useful debug representation.
"""
- e = h2.events.RequestReceived()
- e.stream_id = 5
- e.headers = self.example_request_headers
+ e = h2.events.RequestReceived(
+ stream_id=5,
+ headers=self.example_request_headers
+ )
assert repr(e) == (
" None:
"""
ResponseReceived has a useful debug representation.
"""
- e = h2.events.ResponseReceived()
- e.stream_id = 500
- e.headers = self.example_response_headers
+ e = h2.events.ResponseReceived(
+ stream_id=500,
+ headers=self.example_response_headers,
+ )
assert repr(e) == (
" None:
"""
TrailersReceived has a useful debug representation.
"""
- e = h2.events.TrailersReceived()
- e.stream_id = 62
- e.headers = self.example_response_headers
+ e = h2.events.TrailersReceived(stream_id=62, headers=self.example_response_headers)
assert repr(e) == (
" None:
"""
InformationalResponseReceived has a useful debug representation.
"""
- e = h2.events.InformationalResponseReceived()
- e.stream_id = 62
- e.headers = self.example_informational_headers
+ e = h2.events.InformationalResponseReceived(
+ stream_id=62,
+ headers=self.example_informational_headers,
+ )
assert repr(e) == (
" None:
"""
DataReceived has a useful debug representation.
"""
- e = h2.events.DataReceived()
- e.stream_id = 888
- e.data = b"abcdefghijklmnopqrstuvwxyz"
- e.flow_controlled_length = 88
+ e = h2.events.DataReceived(
+ stream_id=888,
+ data=b"abcdefghijklmnopqrstuvwxyz",
+ flow_controlled_length=88,
+ )
assert repr(e) == (
" None:
"""
WindowUpdated has a useful debug representation.
"""
- e = h2.events.WindowUpdated()
- e.stream_id = 0
- e.delta = 2**16
+ e = h2.events.WindowUpdated(stream_id=0, delta=2**16)
assert repr(e) == ""
@@ -221,8 +222,7 @@ def test_pingreceived_repr(self) -> None:
"""
PingReceived has a useful debug representation.
"""
- e = h2.events.PingReceived()
- e.ping_data = b"abcdefgh"
+ e = h2.events.PingReceived(ping_data=b"abcdefgh")
assert repr(e) == ""
@@ -230,8 +230,7 @@ def test_pingackreceived_repr(self) -> None:
"""
PingAckReceived has a useful debug representation.
"""
- e = h2.events.PingAckReceived()
- e.ping_data = b"abcdefgh"
+ e = h2.events.PingAckReceived(ping_data=b"abcdefgh")
assert repr(e) == ""
@@ -239,8 +238,7 @@ def test_streamended_repr(self) -> None:
"""
StreamEnded has a useful debug representation.
"""
- e = h2.events.StreamEnded()
- e.stream_id = 99
+ e = h2.events.StreamEnded(stream_id=99)
assert repr(e) == ""
@@ -248,10 +246,11 @@ def test_streamreset_repr(self) -> None:
"""
StreamEnded has a useful debug representation.
"""
- e = h2.events.StreamReset()
- e.stream_id = 919
- e.error_code = h2.errors.ErrorCodes.ENHANCE_YOUR_CALM
- e.remote_reset = False
+ e = h2.events.StreamReset(
+ stream_id=919,
+ error_code=h2.errors.ErrorCodes.ENHANCE_YOUR_CALM,
+ remote_reset=False,
+ )
if sys.version_info >= (3, 11):
assert repr(e) == (
@@ -363,7 +362,7 @@ def test_unknownframereceived_repr(self) -> None:
"""
UnknownFrameReceived has a useful debug representation.
"""
- e = h2.events.UnknownFrameReceived()
+ e = h2.events.UnknownFrameReceived(frame=hyperframe.frame.Frame(1))
assert repr(e) == ""
diff --git a/tests/test_invalid_headers.py b/tests/test_invalid_headers.py
index 192ba10d4..96876c6f5 100644
--- a/tests/test_invalid_headers.py
+++ b/tests/test_invalid_headers.py
@@ -48,6 +48,14 @@ class TestInvalidFrameSequences:
[*base_request_headers, ("name ", "name with trailing space")],
[*base_request_headers, ("name", " value with leading space")],
[*base_request_headers, ("name", "value with trailing space ")],
+ [*base_request_headers, ("illegal:characters", "value")],
+ [*base_request_headers, ("illegal-\r-characters", "value")],
+ [*base_request_headers, ("illegal-\n-characters", "value")],
+ [*base_request_headers, ("illegal-\x00-characters", "value")],
+ [*base_request_headers, ("illegal-\x01-characters", "value")],
+ [*base_request_headers, ("illegal-characters", "some \r value")],
+ [*base_request_headers, ("illegal-characters", "some \n value")],
+ [*base_request_headers, ("illegal-characters", "some \x00 value")],
[header for header in base_request_headers
if header[0] != ":authority"],
[(":protocol", "websocket"), *base_request_headers],
@@ -665,7 +673,7 @@ def test_inbound_header_name_length(self, hdr_validation_flags) -> None:
def test_inbound_header_name_length_full_frame_decode(self, frame_factory) -> None:
f = frame_factory.build_headers_frame([])
- f.data = b"\x00\x00\x05\x00\x00\x00\x00\x04"
+ f.data = b"\x00\x00\x01\x04"
data = f.serialize()
c = h2.connection.H2Connection(config=h2.config.H2Configuration(client_side=False))