diff --git a/docs/project/changelog.rst b/docs/project/changelog.rst index 0a9b4c69e..fad749039 100644 --- a/docs/project/changelog.rst +++ b/docs/project/changelog.rst @@ -79,6 +79,10 @@ Improvements * Replied with HTTP 405 Method Not Allowed when the handshake request doesn't use the GET method, instead of closing the connection. +* Replied with HTTP 414 URI Too Long or 431 Request Header Fields Too Large + when the handshake request exceeds a security limit, instead of closing + the connection. + * Added the ``reconnect_delays`` argument for customizing the delays between reconnection attempts in :func:`~asyncio.client.connect`, beyond existing ``WEBSOCKETS_BACKOFF_*`` environment variables. diff --git a/docs/reference/exceptions.rst b/docs/reference/exceptions.rst index 7cf3a529e..575e5d549 100644 --- a/docs/reference/exceptions.rst +++ b/docs/reference/exceptions.rst @@ -34,6 +34,14 @@ also reported by :func:`~websockets.asyncio.server.serve` in logs. .. autoexception:: SecurityError +.. autoexception:: RequestLineTooLong + +.. autoexception:: StatusLineTooLong + +.. autoexception:: HeaderLineTooLong + +.. autoexception:: TooManyHeaders + .. autoexception:: ProxyError .. autoexception:: InvalidProxyMessage diff --git a/docs/topics/security.rst b/docs/topics/security.rst index e91f73b15..acbc87d61 100644 --- a/docs/topics/security.rst +++ b/docs/topics/security.rst @@ -46,6 +46,13 @@ You may change these limits by setting the :envvar:`WEBSOCKETS_MAX_LINE_LENGTH`, :envvar:`WEBSOCKETS_MAX_NUM_HEADERS`, and :envvar:`WEBSOCKETS_MAX_BODY_SIZE` environment variables respectively. +When a handshake request exceeds one of these limits, the server replies with +HTTP 414 URI Too Long or 431 Request Header Fields Too Large, then closes the +connection. + +When a handshake response exceeds one of these limits, the client closes the +connection and raises an exception. + Identification -------------- diff --git a/src/websockets/__init__.py b/src/websockets/__init__.py index 09f1b01f0..e48249e50 100644 --- a/src/websockets/__init__.py +++ b/src/websockets/__init__.py @@ -35,6 +35,7 @@ "ConnectionClosedError", "ConnectionClosedOK", "DuplicateParameter", + "HeaderLineTooLong", "InvalidHandshake", "InvalidHeader", "InvalidHeaderFormat", @@ -55,7 +56,10 @@ "PayloadTooBig", "ProtocolError", "ProxyError", + "RequestLineTooLong", "SecurityError", + "StatusLineTooLong", + "TooManyHeaders", "WebSocketException", # .frames "Close", @@ -101,6 +105,7 @@ ConnectionClosedError, ConnectionClosedOK, DuplicateParameter, + HeaderLineTooLong, InvalidHandshake, InvalidHeader, InvalidHeaderFormat, @@ -121,7 +126,10 @@ PayloadTooBig, ProtocolError, ProxyError, + RequestLineTooLong, SecurityError, + StatusLineTooLong, + TooManyHeaders, WebSocketException, ) from .frames import Close, CloseCode, Frame, Opcode @@ -168,6 +176,7 @@ "ConnectionClosedError": ".exceptions", "ConnectionClosedOK": ".exceptions", "DuplicateParameter": ".exceptions", + "HeaderLineTooLong": ".exceptions", "InvalidHandshake": ".exceptions", "InvalidHeader": ".exceptions", "InvalidHeaderFormat": ".exceptions", @@ -188,7 +197,10 @@ "PayloadTooBig": ".exceptions", "ProtocolError": ".exceptions", "ProxyError": ".exceptions", + "RequestLineTooLong": ".exceptions", "SecurityError": ".exceptions", + "StatusLineTooLong": ".exceptions", + "TooManyHeaders": ".exceptions", "WebSocketException": ".exceptions", # .frames "Close": ".frames", diff --git a/src/websockets/cli.py b/src/websockets/cli.py index f6081feb0..4b0190c79 100644 --- a/src/websockets/cli.py +++ b/src/websockets/cli.py @@ -115,7 +115,7 @@ async def interactive_client(uri: str, **kwargs: Any) -> None: else: print(f"Connected to {uri}.") - # Read messsages from stdin in a thread because Windows doesn't support + # Read messages from stdin in a thread because Windows doesn't support # reading asynchronously (#1681), and a daemon thread to avoid blocking # Ctrl-C because signals are only delivered to the main thread. loop = asyncio.get_event_loop() diff --git a/src/websockets/client.py b/src/websockets/client.py index 0fbcda60c..be85e26ff 100644 --- a/src/websockets/client.py +++ b/src/websockets/client.py @@ -15,6 +15,7 @@ InvalidStatus, InvalidUpgrade, NegotiationError, + StatusLineTooLong, ) from .extensions import ClientExtensionFactory, Extension from .headers import ( @@ -305,6 +306,12 @@ def parse(self) -> Generator[None]: self.reader.read_exact, self.reader.read_to_eof, ) + except StatusLineTooLong as exc: + self.handshake_exc = exc + self.send_eof() + self.parser = self.discard() + next(self.parser) # start coroutine + yield except Exception as exc: self.handshake_exc = InvalidMessage( "did not receive a valid HTTP response" diff --git a/src/websockets/exceptions.py b/src/websockets/exceptions.py index 200e594bc..4f08a13b1 100644 --- a/src/websockets/exceptions.py +++ b/src/websockets/exceptions.py @@ -9,6 +9,10 @@ * :exc:`InvalidProxy` * :exc:`InvalidHandshake` * :exc:`SecurityError` + * :exc:`RequestLineTooLong` + * :exc:`StatusLineTooLong` + * :exc:`HeaderLineTooLong` + * :exc:`TooManyHeaders` * :exc:`ProxyError` * :exc:`InvalidProxyMessage` * :exc:`InvalidProxyStatus` @@ -50,6 +54,10 @@ "InvalidProxy", "InvalidHandshake", "SecurityError", + "RequestLineTooLong", + "StatusLineTooLong", + "HeaderLineTooLong", + "TooManyHeaders", "ProxyError", "InvalidProxyMessage", "InvalidProxyStatus", @@ -210,6 +218,34 @@ class SecurityError(InvalidHandshake): """ +class RequestLineTooLong(SecurityError): + """ + Raised when the request line of a handshake request is too long. + + """ + + +class StatusLineTooLong(SecurityError): + """ + Raised when the status line of a handshake response is too long. + + """ + + +class HeaderLineTooLong(SecurityError): + """ + Raised when a header line of a handshake request or response is too long. + + """ + + +class TooManyHeaders(SecurityError): + """ + Raised when a handshake request or response has too many headers. + + """ + + class ProxyError(InvalidHandshake): """ Raised when failing to connect to a proxy. diff --git a/src/websockets/http11.py b/src/websockets/http11.py index 81a35bed1..606adbb7c 100644 --- a/src/websockets/http11.py +++ b/src/websockets/http11.py @@ -9,7 +9,13 @@ from typing import Callable from .datastructures import Headers -from .exceptions import SecurityError +from .exceptions import ( + HeaderLineTooLong, + RequestLineTooLong, + SecurityError, + StatusLineTooLong, + TooManyHeaders, +) from .version import version as websockets_version @@ -106,7 +112,9 @@ def exception(self) -> Exception | None: # pragma: no cover @classmethod def parse( cls, - read_line: Callable[[int], Generator[None, None, bytes | bytearray]], + read_line: Callable[ + [int, type[Exception]], Generator[None, None, bytes | bytearray] + ], ) -> Generator[None, None, Request]: """ Parse a WebSocket handshake request. @@ -128,7 +136,9 @@ def parse( Raises: EOFError: If the connection is closed without a full HTTP request. - SecurityError: If the request exceeds a security limit. + RequestLineTooLong: If the request line is too long. + HeaderLineTooLong: If a header line is too long. + TooManyHeaders: If there are too many headers. UnicodeDecodeError: If the request method or path isn't ASCII. ValueError: If the request isn't well formatted. @@ -140,7 +150,7 @@ def parse( # implement HTTP/1.1 strictly, there's little need for lenient parsing. try: - request_line = yield from parse_line(read_line) + request_line = yield from parse_line(read_line, RequestLineTooLong) except EOFError as exc: raise EOFError("connection closed while reading HTTP request line") from exc @@ -217,9 +227,13 @@ def exception(self) -> Exception | None: # pragma: no cover @classmethod def parse( cls, - read_line: Callable[[int], Generator[None, None, bytes | bytearray]], + read_line: Callable[ + [int, type[Exception]], Generator[None, None, bytes | bytearray] + ], read_exact: Callable[[int], Generator[None, None, bytes | bytearray]], - read_to_eof: Callable[[int], Generator[None, None, bytes | bytearray]], + read_to_eof: Callable[ + [int, type[Exception]], Generator[None, None, bytes | bytearray] + ], proxy: bool = False, ) -> Generator[None, None, Response]: """ @@ -241,7 +255,10 @@ def parse( Raises: EOFError: If the connection is closed without a full HTTP response. - SecurityError: If the response exceeds a security limit. + StatusLineTooLong: If the status line is too long. + HeaderLineTooLong: If a header line is too long. + TooManyHeaders: If there are too many headers. + SecurityError: If the response body exceeds a security limit. LookupError: If the response isn't well formatted. ValueError: If the response isn't well formatted. @@ -249,7 +266,7 @@ def parse( # https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2 try: - status_line = yield from parse_line(read_line) + status_line = yield from parse_line(read_line, StatusLineTooLong) except EOFError as exc: raise EOFError("connection closed while reading HTTP status line") from exc @@ -311,7 +328,10 @@ def serialize(self) -> bytes: def parse_line( - read_line: Callable[[int], Generator[None, None, bytes | bytearray]], + read_line: Callable[ + [int, type[Exception]], Generator[None, None, bytes | bytearray] + ], + too_long_exc_type: type[Exception] = SecurityError, ) -> Generator[None, None, bytes | bytearray]: """ Parse a single line. @@ -321,16 +341,15 @@ def parse_line( Args: read_line: Generator-based coroutine that reads a LF-terminated line or raises an exception if there isn't enough data. + too_long_exc_type: exception to raise if the line is too long; + defaults to :exc:`SecurityError`. Raises: EOFError: If the connection is closed without a CRLF. SecurityError: If the response exceeds a security limit. """ - try: - line = yield from read_line(MAX_LINE_LENGTH) - except RuntimeError: - raise SecurityError("line too long") + line = yield from read_line(MAX_LINE_LENGTH, too_long_exc_type) # Not mandatory but safe - https://datatracker.ietf.org/doc/html/rfc7230#section-3.5 if not line.endswith(b"\r\n"): raise EOFError("line without CRLF") @@ -338,7 +357,9 @@ def parse_line( def parse_headers( - read_line: Callable[[int], Generator[None, None, bytes | bytearray]], + read_line: Callable[ + [int, type[Exception]], Generator[None, None, bytes | bytearray] + ], ) -> Generator[None, None, Headers]: """ Parse HTTP headers. @@ -353,7 +374,8 @@ def parse_headers( Raises: EOFError: If the connection is closed without complete headers. - SecurityError: If the request exceeds a security limit. + HeaderLineTooLong: If a header line is too long. + TooManyHeaders: If there are too many headers. ValueError: If the request isn't well formatted. """ @@ -364,7 +386,7 @@ def parse_headers( headers = Headers() for _ in range(MAX_NUM_HEADERS + 1): try: - line = yield from parse_line(read_line) + line = yield from parse_line(read_line, HeaderLineTooLong) except EOFError as exc: raise EOFError("connection closed while reading HTTP headers") from exc if line == b"": @@ -390,7 +412,7 @@ def parse_headers( headers.set_insecure(name, value) else: - raise SecurityError("too many HTTP headers") + raise TooManyHeaders(f"expected no more than {MAX_NUM_HEADERS} headers") return headers @@ -398,9 +420,13 @@ def parse_headers( def read_body( status_code: int, headers: Headers, - read_line: Callable[[int], Generator[None, None, bytes | bytearray]], + read_line: Callable[ + [int, type[Exception]], Generator[None, None, bytes | bytearray] + ], read_exact: Callable[[int], Generator[None, None, bytes | bytearray]], - read_to_eof: Callable[[int], Generator[None, None, bytes | bytearray]], + read_to_eof: Callable[ + [int, type[Exception]], Generator[None, None, bytes | bytearray] + ], ) -> Generator[None, None, bytes | bytearray]: # https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.3 @@ -420,7 +446,7 @@ def read_body( body = b"" while True: - chunk_size_line = yield from parse_line(read_line) + chunk_size_line = yield from parse_line(read_line, SecurityError) raw_chunk_size = chunk_size_line.split(b";", 1)[0] # Set a lower limit than default_max_str_digits; 1 EB is plenty. if len(raw_chunk_size) > 15: @@ -450,7 +476,4 @@ def read_body( return (yield from read_exact(content_length)) else: - try: - return (yield from read_to_eof(MAX_BODY_SIZE)) - except RuntimeError: - raise SecurityError(f"body too large: over {MAX_BODY_SIZE} bytes") + return (yield from read_to_eof(MAX_BODY_SIZE, SecurityError)) diff --git a/src/websockets/server.py b/src/websockets/server.py index 0a2b6a795..a846d5d6b 100644 --- a/src/websockets/server.py +++ b/src/websockets/server.py @@ -11,6 +11,7 @@ from .datastructures import Headers, MultipleValuesError from .exceptions import ( + HeaderLineTooLong, InvalidHandshake, InvalidHeader, InvalidHeaderValue, @@ -19,6 +20,8 @@ InvalidOrigin, InvalidUpgrade, NegotiationError, + RequestLineTooLong, + TooManyHeaders, ) from .extensions import Extension, ServerExtensionFactory from .headers import ( @@ -563,6 +566,27 @@ def parse(self) -> Generator[None]: request = yield from Request.parse( self.reader.read_line, ) + except RequestLineTooLong as exc: + self.handshake_exc = exc + if self.debug: + self.logger.debug("! request line too long", exc_info=True) + response = self.reject( + # Change to http.HTTPStatus.URI_TOO_LONG when dropping Python < 3.13 + http.HTTPStatus.REQUEST_URI_TOO_LONG, + f"Failed to open a WebSocket connection: {exc}.\n", + ) + self.send_response(response) + yield + except (HeaderLineTooLong, TooManyHeaders) as exc: + self.handshake_exc = exc + if self.debug: + self.logger.debug("! header fields too large", exc_info=True) + response = self.reject( + http.HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, + f"Failed to open a WebSocket connection: {exc}.\n", + ) + self.send_response(response) + yield except Exception as exc: self.handshake_exc = InvalidMessage( "did not receive a valid HTTP request" diff --git a/src/websockets/streams.py b/src/websockets/streams.py index 309ce152d..04b4739c6 100644 --- a/src/websockets/streams.py +++ b/src/websockets/streams.py @@ -17,7 +17,11 @@ def __init__(self) -> None: self.buffer = bytearray() self.eof = False - def read_line(self, m: int) -> Generator[None, None, bytearray]: + def read_line( + self, + m: int, + too_long_exc_type: type[Exception] = RuntimeError, + ) -> Generator[None, None, bytearray]: """ Read a LF-terminated line from the stream. @@ -27,10 +31,12 @@ def read_line(self, m: int) -> Generator[None, None, bytearray]: Args: m: Maximum number bytes to read; this is a security limit. + too_long_exc_type: exception to raise if the line ends in more + than ``m`` bytes; defaults to :exc:`RuntimeError`. Raises: EOFError: If the stream ends without a LF. - RuntimeError: If the stream ends in more than ``m`` bytes. + RuntimeError: If the line ends in more than ``m`` bytes. """ n = 0 # number of bytes to read @@ -41,12 +47,14 @@ def read_line(self, m: int) -> Generator[None, None, bytearray]: break p = len(self.buffer) if p > m: - raise RuntimeError(f"read {p} bytes, expected no more than {m} bytes") + raise too_long_exc_type( + f"read {p} bytes, expected no more than {m} bytes" + ) if self.eof: raise EOFError(f"stream ends after {p} bytes, before end of line") yield if n > m: - raise RuntimeError(f"read {n} bytes, expected no more than {m} bytes") + raise too_long_exc_type(f"read {n} bytes, expected no more than {m} bytes") r = self.buffer[:n] del self.buffer[:n] return r @@ -74,7 +82,11 @@ def read_exact(self, n: int) -> Generator[None, None, bytearray]: del self.buffer[:n] return r - def read_to_eof(self, m: int) -> Generator[None, None, bytearray]: + def read_to_eof( + self, + m: int, + too_long_exc_type: type[Exception] = RuntimeError, + ) -> Generator[None, None, bytearray]: """ Read all bytes from the stream. @@ -82,6 +94,8 @@ def read_to_eof(self, m: int) -> Generator[None, None, bytearray]: Args: m: Maximum number bytes to read; this is a security limit. + too_long_exc_type: exception to raise if the stream ends in more + than ``m`` bytes; defaults to :exc:`RuntimeError`. Raises: RuntimeError: If the stream ends in more than ``m`` bytes. @@ -90,7 +104,9 @@ def read_to_eof(self, m: int) -> Generator[None, None, bytearray]: while not self.eof: p = len(self.buffer) if p > m: - raise RuntimeError(f"read {p} bytes, expected no more than {m} bytes") + raise too_long_exc_type( + f"read {p} bytes, expected no more than {m} bytes" + ) yield r = self.buffer[:] del self.buffer[:] diff --git a/tests/test_client.py b/tests/test_client.py index c81cc8689..697b31166 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -13,6 +13,7 @@ InvalidHeader, InvalidMessage, InvalidStatus, + StatusLineTooLong, ) from websockets.frames import OP_TEXT, Frame from websockets.http11 import Request, Response @@ -278,7 +279,7 @@ def test_receive_truncated_response(self, _generate_key): "connection closed while reading HTTP headers", ) - def test_receive_random_response(self, _generate_key): + def test_receive_junk_response(self, _generate_key): """Client receives a junk handshake response.""" client = ClientProtocol(URI) client.receive_data(b"220 smtp.invalid\r\n") @@ -298,6 +299,18 @@ def test_receive_random_response(self, _generate_key): "invalid HTTP status line: 220 smtp.invalid", ) + def test_receive_status_line_too_long(self, _generate_key): + """Client receives a handshake response with a status line that's too long.""" + client = ClientProtocol(URI) + client.receive_data(b"HTTP/1.1 " + b"a" * 8191 + b"\r\n\r\n") + + self.assertEqual(client.events_received(), []) + self.assertIsInstance(client.handshake_exc, StatusLineTooLong) + self.assertEqual( + str(client.handshake_exc), + "read 8202 bytes, expected no more than 8192 bytes", + ) + @contextlib.contextmanager def alter_and_receive_response(client): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 26975ad2f..1cb6c8953 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -95,6 +95,22 @@ def test_str(self): SecurityError("redirect from WSS to WS"), "redirect from WSS to WS", ), + ( + RequestLineTooLong("read 8202 bytes, expected no more than 8192 bytes"), + "read 8202 bytes, expected no more than 8192 bytes", + ), + ( + StatusLineTooLong("read 8202 bytes, expected no more than 8192 bytes"), + "read 8202 bytes, expected no more than 8192 bytes", + ), + ( + HeaderLineTooLong("read 8202 bytes, expected no more than 8192 bytes"), + "read 8202 bytes, expected no more than 8192 bytes", + ), + ( + TooManyHeaders("expected no more than 128 headers"), + "expected no more than 128 headers", + ), ( ProxyError("failed to connect to SOCKS proxy"), "failed to connect to SOCKS proxy", diff --git a/tests/test_http11.py b/tests/test_http11.py index c966399f3..0a80662bc 100644 --- a/tests/test_http11.py +++ b/tests/test_http11.py @@ -1,5 +1,11 @@ from websockets.datastructures import Headers -from websockets.exceptions import SecurityError +from websockets.exceptions import ( + HeaderLineTooLong, + RequestLineTooLong, + SecurityError, + StatusLineTooLong, + TooManyHeaders, +) from websockets.http11 import * from websockets.http11 import parse_headers from websockets.streams import StreamReader @@ -58,6 +64,17 @@ def test_parse_empty(self): "connection closed while reading HTTP request line", ) + def test_parse_request_line_too_long(self): + # Request line contains 5 + 8186 + 9 = 8200 bytes before the line + # ending, which exceeds the 8192-byte limit. + self.reader.feed_data(b"GET /" + b"a" * 8186 + b" HTTP/1.1\r\n\r\n") + with self.assertRaises(RequestLineTooLong) as raised: + next(self.parse()) + self.assertEqual( + str(raised.exception), + "read 8202 bytes, expected no more than 8192 bytes", + ) + def test_parse_invalid_request_line(self): self.reader.feed_data(b"GET /\r\n\r\n") with self.assertRaises(ValueError) as raised: @@ -176,6 +193,17 @@ def test_parse_empty(self): "connection closed while reading HTTP status line", ) + def test_parse_status_line_too_long(self): + # Status line contains 9 + 8191 = 8200 bytes before the line ending, + # which exceeds the 8192-byte limit. + self.reader.feed_data(b"HTTP/1.1 " + b"a" * 8191 + b"\r\n\r\n") + with self.assertRaises(StatusLineTooLong) as raised: + next(self.parse()) + self.assertEqual( + str(raised.exception), + "read 8202 bytes, expected no more than 8192 bytes", + ) + def test_parse_invalid_status_line(self): self.reader.feed_data(b"Hello!\r\n") with self.assertRaises(ValueError) as raised: @@ -248,7 +276,7 @@ def test_parse_body_too_large(self): next(self.parse()) self.assertEqual( str(raised.exception), - "body too large: over 1048576 bytes", + "read 1048577 bytes, expected no more than 1048576 bytes", ) def test_parse_body_with_content_length(self): @@ -430,16 +458,21 @@ def test_parse_invalid_value(self): with self.assertRaises(ValueError): self.parse_headers() - def test_parse_too_long_value(self): + def test_parse_too_many_headers(self): self.reader.feed_data(b"foo: bar\r\n" * 129 + b"\r\n") - with self.assertRaises(SecurityError): + with self.assertRaises(TooManyHeaders) as raised: self.parse_headers() + self.assertEqual(str(raised.exception), "expected no more than 128 headers") def test_parse_too_long_line(self): - # Header line contains 5 + 8186 + 2 = 8193 bytes. - self.reader.feed_data(b"foo: " + b"a" * 8186 + b"\r\n\r\n") - with self.assertRaises(SecurityError): + # Header line contains 5 + 8195 = 8200 bytes before the line ending, + # which exceeds the 8192-byte limit. + self.reader.feed_data(b"foo: " + b"a" * 8195 + b"\r\n\r\n") + with self.assertRaises(HeaderLineTooLong) as raised: self.parse_headers() + self.assertEqual( + str(raised.exception), "read 8202 bytes, expected no more than 8192 bytes" + ) def test_parse_invalid_line_ending(self): self.reader.feed_data(b"foo: bar\n\n") diff --git a/tests/test_server.py b/tests/test_server.py index 6e0a77037..f2cec5eae 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,12 +7,15 @@ from websockets.datastructures import Headers from websockets.exceptions import ( + HeaderLineTooLong, InvalidHeader, InvalidMessage, InvalidMethod, InvalidOrigin, InvalidUpgrade, NegotiationError, + RequestLineTooLong, + TooManyHeaders, ) from websockets.frames import OP_TEXT, Frame from websockets.http11 import Request, Response @@ -258,6 +261,102 @@ def test_receive_junk_request(self): "invalid HTTP request line: HELO relay.invalid", ) + @patch("email.utils.formatdate", return_value=DATE) + def test_receive_request_line_too_long(self, _formatdate): + """Server rejects a handshake request with a request line that's too long.""" + server = ServerProtocol() + server.receive_data(b"GET /" + b"a" * 8186 + b" HTTP/1.1\r\n\r\n") + + self.assertEqual(server.events_received(), []) + self.assertIsInstance(server.handshake_exc, RequestLineTooLong) + self.assertEqual( + str(server.handshake_exc), + "read 8202 bytes, expected no more than 8192 bytes", + ) + + self.assertEqual( + server.data_to_send(), + [ + f"HTTP/1.1 414 URI Too Long\r\n" + f"Date: {DATE}\r\n" + f"Connection: close\r\n" + f"Content-Length: 90\r\n" + f"Content-Type: text/plain; charset=utf-8\r\n" + f"\r\n" + f"Failed to open a WebSocket connection: " + f"read 8202 bytes, expected no more than 8192 bytes.\n".encode(), + b"", + ] + if sys.version_info[:2] >= (3, 13) + else [ + f"HTTP/1.1 414 Request-URI Too Long\r\n" + f"Date: {DATE}\r\n" + f"Connection: close\r\n" + f"Content-Length: 90\r\n" + f"Content-Type: text/plain; charset=utf-8\r\n" + f"\r\n" + f"Failed to open a WebSocket connection: " + f"read 8202 bytes, expected no more than 8192 bytes.\n".encode(), + b"", + ], + ) + self.assertTrue(server.close_expected()) + + @patch("email.utils.formatdate", return_value=DATE) + def test_receive_header_line_too_long(self, _formatdate): + """Server rejects a handshake request with a header line that's too long.""" + server = ServerProtocol() + server.receive_data(b"GET / HTTP/1.1\r\nX-Long: " + b"a" * 8192 + b"\r\n\r\n") + + self.assertEqual(server.events_received(), []) + self.assertIsInstance(server.handshake_exc, HeaderLineTooLong) + self.assertEqual( + str(server.handshake_exc), + "read 8202 bytes, expected no more than 8192 bytes", + ) + + self.assertEqual( + server.data_to_send(), + [ + f"HTTP/1.1 431 Request Header Fields Too Large\r\n" + f"Date: {DATE}\r\n" + f"Connection: close\r\n" + f"Content-Length: 90\r\n" + f"Content-Type: text/plain; charset=utf-8\r\n" + f"\r\n" + f"Failed to open a WebSocket connection: " + f"read 8202 bytes, expected no more than 8192 bytes.\n".encode(), + b"", + ], + ) + self.assertTrue(server.close_expected()) + + @patch("email.utils.formatdate", return_value=DATE) + def test_receive_too_many_headers(self, _formatdate): + """Server rejects a handshake request with too many headers.""" + server = ServerProtocol() + server.receive_data(b"GET / HTTP/1.1\r\n" + b"X-Foo: bar\r\n" * 129 + b"\r\n") + + self.assertEqual(server.events_received(), []) + self.assertIsInstance(server.handshake_exc, TooManyHeaders) + self.assertEqual(str(server.handshake_exc), "expected no more than 128 headers") + + self.assertEqual( + server.data_to_send(), + [ + f"HTTP/1.1 431 Request Header Fields Too Large\r\n" + f"Date: {DATE}\r\n" + f"Connection: close\r\n" + f"Content-Length: 74\r\n" + f"Content-Type: text/plain; charset=utf-8\r\n" + f"\r\n" + f"Failed to open a WebSocket connection: " + f"expected no more than 128 headers.\n".encode(), + b"", + ], + ) + self.assertTrue(server.close_expected()) + class ResponseTests(unittest.TestCase): """Test generating opening handshake responses.""" diff --git a/tests/test_streams.py b/tests/test_streams.py index fd7c66a0b..357eb4cba 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -56,6 +56,17 @@ def test_read_line_too_long(self): "read 5 bytes, expected no more than 2 bytes", ) + def test_read_line_too_long_custom_exc_type(self): + self.reader.feed_data(b"spam\neggs\n") + + gen = self.reader.read_line(2, too_long_exc_type=ValueError) + with self.assertRaises(ValueError) as raised: + next(gen) + self.assertEqual( + str(raised.exception), + "read 5 bytes, expected no more than 2 bytes", + ) + def test_read_line_too_long_need_more_data(self): self.reader.feed_data(b"spa") @@ -67,6 +78,16 @@ def test_read_line_too_long_need_more_data(self): "read 3 bytes, expected no more than 2 bytes", ) + def test_read_line_too_long_custom_exc_type_need_more_data(self): + self.reader.feed_data(b"spa") + gen = self.reader.read_line(2, too_long_exc_type=ValueError) + with self.assertRaises(ValueError) as raised: + next(gen) + self.assertEqual( + str(raised.exception), + "read 3 bytes, expected no more than 2 bytes", + ) + def test_read_exact(self): self.reader.feed_data(b"spameggs") @@ -133,6 +154,17 @@ def test_read_to_eof_too_long(self): "read 4 bytes, expected no more than 2 bytes", ) + def test_read_to_eof_too_long_custom_exc_type(self): + gen = self.reader.read_to_eof(2, too_long_exc_type=ValueError) + + self.reader.feed_data(b"spam") + with self.assertRaises(ValueError) as raised: + next(gen) + self.assertEqual( + str(raised.exception), + "read 4 bytes, expected no more than 2 bytes", + ) + def test_at_eof_after_feed_data(self): gen = self.reader.at_eof() self.assertGeneratorRunning(gen)