diff --git a/docs/source/api.rst b/docs/source/api.rst index e667498a4..b17f8015e 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -115,6 +115,10 @@ Protocol Errors :show-inheritance: :members: +.. autoclass:: h2.exceptions.InvalidBodyLengthError + :show-inheritance: + :members: + HTTP/2 Error Codes ------------------ diff --git a/h2/exceptions.py b/h2/exceptions.py index a626d44ca..80de18a08 100644 --- a/h2/exceptions.py +++ b/h2/exceptions.py @@ -130,3 +130,20 @@ class InvalidSettingsValueError(ProtocolError, ValueError): def __init__(self, msg, error_code): super(InvalidSettingsValueError, self).__init__(msg) self.error_code = error_code + + +class InvalidBodyLengthError(ProtocolError): + """ + The remote peer sent more or less data that the Content-Length header + indicated. + + .. versionadded:: 2.0.0 + """ + def __init__(self, expected, actual): + self.expected_length = expected + self.actual_length = actual + + def __str__(self): + return "InvalidBodyLengthError: Expected %d bytes, received %d" % ( + self.expected_length, self.actual_length + ) diff --git a/h2/stream.py b/h2/stream.py index 949ecf039..0654a910f 100644 --- a/h2/stream.py +++ b/h2/stream.py @@ -17,7 +17,9 @@ StreamEnded, PushedStreamReceived, StreamReset, TrailersReceived, PriorityUpdated, ) -from .exceptions import ProtocolError, StreamClosedError +from .exceptions import ( + ProtocolError, StreamClosedError, InvalidBodyLengthError +) from .utilities import guard_increment_window @@ -553,6 +555,12 @@ def __init__(self, stream_id): self.outbound_flow_control_window = 65535 self.inbound_flow_control_window = 65535 + # The expected content length, if any. + self._expected_content_length = None + + # The actual received content length. Always tracked. + self._actual_content_length = 0 + @property def open(self): """ @@ -707,6 +715,8 @@ def receive_headers(self, headers, end_stream): StreamInputs.RECV_END_STREAM ) + self._initialize_content_length(headers) + if isinstance(events[0], TrailersReceived): if not end_stream: raise ProtocolError("Trailers must have END_STREAM set") @@ -720,6 +730,7 @@ def receive_data(self, data, end_stream, flow_control_len): """ events = self.state_machine.process_input(StreamInputs.RECV_DATA) self.inbound_flow_control_window -= flow_control_len + self._track_content_length(len(data), end_stream) if end_stream: events += self.state_machine.process_input( @@ -828,3 +839,35 @@ def _build_headers_frames(self, frames[-1].flags.add('END_HEADERS') return frames + + def _initialize_content_length(self, headers): + """ + Checks the headers for a content-length header and initializes the + _expected_content_length field from it. It's not an error for no + Content-Length header to be present. + """ + for n, v in headers: + if n == 'content-length': + self._expected_content_length = int(v, 10) + return + + def _track_content_length(self, length, end_stream): + """ + Update the expected content length in response to data being received. + Validates that the appropriate amount of data is sent. Always updates + the received data, but only validates the length against the + content-length header if one was sent. + + :param length: The length of the body chunk received. + :param end_stream: If this is the last body chunk received. + """ + self._actual_content_length += length + actual = self._actual_content_length + expected = self._expected_content_length + + if expected is not None: + if expected < actual: + raise InvalidBodyLengthError(expected, actual) + + if end_stream and expected != actual: + raise InvalidBodyLengthError(expected, actual) diff --git a/test/test_invalid_content_lengths.py b/test/test_invalid_content_lengths.py new file mode 100644 index 000000000..6eb2ab0f9 --- /dev/null +++ b/test/test_invalid_content_lengths.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +""" +test_invalid_content_lengths.py +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains tests that use invalid content lengths, and validates that +they fail appropriately. +""" +import pytest + +import h2.connection +import h2.errors +import h2.events +import h2.exceptions + + +class TestInvalidContentLengths(object): + """ + Hyper-h2 raises Protocol Errors when the content-length sent by a remote + peer is not valid. + """ + example_request_headers = [ + (':authority', 'example.com'), + (':path', '/'), + (':scheme', 'https'), + (':method', 'POST'), + ('content-length', '15'), + ] + example_response_headers = [ + (':status', '200'), + ('server', 'fake-serv/0.1.0') + ] + + def test_too_much_data(self, frame_factory): + """ + Remote peers sending data in excess of content-length causes Protocol + Errors. + """ + c = h2.connection.H2Connection(client_side=False) + c.initiate_connection() + c.receive_data(frame_factory.preamble()) + + headers = frame_factory.build_headers_frame( + headers=self.example_request_headers + ) + first_data = frame_factory.build_data_frame(data=b'\x01'*15) + c.receive_data(headers.serialize() + first_data.serialize()) + c.clear_outbound_data_buffer() + + second_data = frame_factory.build_data_frame(data=b'\x01') + with pytest.raises(h2.exceptions.InvalidBodyLengthError) as exp: + c.receive_data(second_data.serialize()) + + assert exp.value.expected_length == 15 + assert exp.value.actual_length == 16 + assert str(exp.value) == ( + "InvalidBodyLengthError: Expected 15 bytes, received 16" + ) + + expected_frame = frame_factory.build_goaway_frame( + last_stream_id=1, + error_code=h2.errors.PROTOCOL_ERROR, + ) + assert c.data_to_send() == expected_frame.serialize() + + def test_insufficient_data(self, frame_factory): + """ + Remote peers sending less data than content-length causes Protocol + Errors. + """ + c = h2.connection.H2Connection(client_side=False) + c.initiate_connection() + c.receive_data(frame_factory.preamble()) + + headers = frame_factory.build_headers_frame( + headers=self.example_request_headers + ) + first_data = frame_factory.build_data_frame(data=b'\x01'*13) + c.receive_data(headers.serialize() + first_data.serialize()) + c.clear_outbound_data_buffer() + + second_data = frame_factory.build_data_frame( + data=b'\x01', + flags=['END_STREAM'], + ) + with pytest.raises(h2.exceptions.InvalidBodyLengthError) as exp: + c.receive_data(second_data.serialize()) + + assert exp.value.expected_length == 15 + assert exp.value.actual_length == 14 + assert str(exp.value) == ( + "InvalidBodyLengthError: Expected 15 bytes, received 14" + ) + + expected_frame = frame_factory.build_goaway_frame( + last_stream_id=1, + error_code=h2.errors.PROTOCOL_ERROR, + ) + assert c.data_to_send() == expected_frame.serialize() + + def test_insufficient_data_empty_frame(self, frame_factory): + """ + Remote peers sending less data than content-length where the last data + frame is empty causes Protocol Errors. + """ + c = h2.connection.H2Connection(client_side=False) + c.initiate_connection() + c.receive_data(frame_factory.preamble()) + + headers = frame_factory.build_headers_frame( + headers=self.example_request_headers + ) + first_data = frame_factory.build_data_frame(data=b'\x01'*14) + c.receive_data(headers.serialize() + first_data.serialize()) + c.clear_outbound_data_buffer() + + second_data = frame_factory.build_data_frame( + data=b'', + flags=['END_STREAM'], + ) + with pytest.raises(h2.exceptions.InvalidBodyLengthError) as exp: + c.receive_data(second_data.serialize()) + + assert exp.value.expected_length == 15 + assert exp.value.actual_length == 14 + assert str(exp.value) == ( + "InvalidBodyLengthError: Expected 15 bytes, received 14" + ) + + expected_frame = frame_factory.build_goaway_frame( + last_stream_id=1, + error_code=h2.errors.PROTOCOL_ERROR, + ) + assert c.data_to_send() == expected_frame.serialize()