Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/project/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Backwards-incompatible changes
Previously, the server closed the connection without returning an HTTP
response. Now, ``process_request`` runs and can return an HTTP response.

.. admonition:: ``process_request`` may receive requests using HTTP/1.0.
:class: caution

Previously, the server closed the connection without returning an HTTP
response. Now, ``process_request`` runs and can return an HTTP response.

.. admonition:: Encoding and decoding non-ASCII headers in handshake requests
and responses changed.
:class: note
Expand Down Expand Up @@ -105,6 +111,9 @@ 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 505 HTTP Version Not Supported when the handshake request
doesn't use HTTP/1.1, 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ also reported by :func:`~websockets.asyncio.server.serve` in logs.

.. autoexception:: InvalidMethod

.. autoexception:: InvalidProtocol

.. autoexception:: InvalidStatus

.. autoexception:: InvalidHeader
Expand Down
3 changes: 3 additions & 0 deletions src/websockets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"InvalidOrigin",
"InvalidParameterName",
"InvalidParameterValue",
"InvalidProtocol",
"InvalidProxy",
"InvalidProxyMessage",
"InvalidProxyStatus",
Expand Down Expand Up @@ -115,6 +116,7 @@
InvalidOrigin,
InvalidParameterName,
InvalidParameterValue,
InvalidProtocol,
InvalidProxy,
InvalidProxyMessage,
InvalidProxyStatus,
Expand Down Expand Up @@ -186,6 +188,7 @@
"InvalidOrigin": ".exceptions",
"InvalidParameterName": ".exceptions",
"InvalidParameterValue": ".exceptions",
"InvalidProtocol": ".exceptions",
"InvalidProxy": ".exceptions",
"InvalidProxyMessage": ".exceptions",
"InvalidProxyStatus": ".exceptions",
Expand Down
15 changes: 15 additions & 0 deletions src/websockets/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* :exc:`InvalidProxyStatus`
* :exc:`InvalidMessage`
* :exc:`InvalidMethod`
* :exc:`InvalidProtocol`
* :exc:`InvalidStatus`
* :exc:`InvalidStatusCode` (legacy)
* :exc:`InvalidHeader`
Expand Down Expand Up @@ -63,6 +64,7 @@
"InvalidProxyStatus",
"InvalidMessage",
"InvalidMethod",
"InvalidProtocol",
"InvalidStatus",
"InvalidHeader",
"InvalidHeaderFormat",
Expand Down Expand Up @@ -293,6 +295,19 @@ def __str__(self) -> str:
return f"unsupported HTTP method: {self.method}"


class InvalidProtocol(InvalidHandshake):
"""
Raised when a handshake request doesn't use HTTP/1.1.

"""

def __init__(self, protocol: str) -> None:
self.protocol = protocol

def __str__(self) -> str:
return f"unsupported HTTP version: {self.protocol}"


class InvalidStatus(InvalidHandshake):
"""
Raised when a handshake response rejects the WebSocket upgrade.
Expand Down
18 changes: 11 additions & 7 deletions src/websockets/http11.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,15 @@ class Request:
path: Request path, including optional query.
headers: Request headers.
method: Request method; WebSocket handshake requests use GET.
protocol: Request protocol; WebSocket handshake requests use HTTP/1.1.
"""

path: str
headers: Headers
# method comes before path in the HTTP request, but it has a default,
# so it must be declared after path and headers which don't have one.
# method and protocol have a default value, so they're declared after path
# and headers which don't.
method: str = "GET"
protocol: str = "HTTP/1.1"
# body isn't useful is the context of this library.

_exception: Exception | None = None
Expand Down Expand Up @@ -159,14 +161,16 @@ def parse(
raise EOFError("connection closed while reading HTTP request line") from exc

try:
raw_method, raw_path, protocol = request_line.split(b" ", 2)
raw_method, raw_path, raw_protocol = request_line.split(b" ", 2)
except ValueError: # not enough values to unpack (expected 3, got 1-2)
raise ValueError(f"invalid HTTP request line: {d(request_line)}") from None
if protocol != b"HTTP/1.1":
if raw_protocol not in [b"HTTP/1.1", b"HTTP/1.0"]:
raise ValueError(
f"unsupported protocol; expected HTTP/1.1: {d(request_line)}"
f"unsupported protocol; expected HTTP/1.1 or HTTP/1.0: "
f"{d(request_line)}"
)
method = raw_method.decode("ascii")
protocol = raw_protocol.decode("ascii")

# RFC 9110 defers the definition of URIs to RFC 3986, which allows only
# a subset of ASCII. Non-ASCII IRIs must be UTF-8 then percent-encoded.
Expand All @@ -185,7 +189,7 @@ def parse(
if int(headers["Content-Length"]) != 0:
raise ValueError("unsupported request body")

return cls(path, headers, method)
return cls(path, headers, method, protocol)

def serialize(self) -> bytes:
"""
Expand All @@ -194,7 +198,7 @@ def serialize(self) -> bytes:
"""
# Methods are hardcoded and always ASCII. Non-ASCII paths are converted
# from URI to IRI and percent-encoded. Enforce ASCII as a safety net.
request_line = f"{self.method} {self.path} HTTP/1.1\r\n"
request_line = f"{self.method} {self.path} {self.protocol}\r\n"
request = request_line.encode("ascii")
request += self.headers.serialize()
return request
Expand Down
28 changes: 23 additions & 5 deletions src/websockets/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
InvalidMessage,
InvalidMethod,
InvalidOrigin,
InvalidProtocol,
InvalidUpgrade,
NegotiationError,
RequestLineTooLong,
Expand Down Expand Up @@ -166,6 +167,15 @@ def accept(self, request: Request) -> Response:
)
response.headers["Allow"] = "GET"
return response
except InvalidProtocol as exc:
request._exception = exc
self.handshake_exc = exc
if self.debug:
self.logger.debug("! invalid protocol", exc_info=True)
return self.reject(
http.HTTPStatus.HTTP_VERSION_NOT_SUPPORTED,
f"Failed to open a WebSocket connection: {exc}.\n",
)
except InvalidUpgrade as exc:
request._exception = exc
self.handshake_exc = exc
Expand Down Expand Up @@ -228,9 +238,9 @@ def process_request(
"""
Check a handshake request and negotiate extensions and subprotocol.

This function doesn't verify that that it's an HTTP/1.1 request and
doesn't check the ``Host`` header. These controls must be performed
by the HTTP stack earlier. They're the responsibility of the caller.
This function doesn't check the ``Host`` header. This control must be
performed by the HTTP stack earlier. It's the responsibility of the
caller.

Args:
request: WebSocket handshake request received from the client.
Expand All @@ -242,13 +252,19 @@ def process_request(
Raises:
InvalidMethod: If the request method isn't GET; then the
server must return a 405 Method Not Allowed error.
InvalidProtocol: If the request protocol isn't HTTP/1.1; then
the server must return a 505 HTTP Version Not Supported
error.
InvalidHandshake: If the handshake request is invalid;
then the server must return 400 Bad Request error.
then the server must return a 400 Bad Request error.

"""
if request.method != "GET":
raise InvalidMethod(request.method)

if request.protocol != "HTTP/1.1":
raise InvalidProtocol(request.protocol)

headers = request.headers

connection: list[ConnectionOption] = sum(
Expand Down Expand Up @@ -608,7 +624,9 @@ def parse(self) -> Generator[None]:
yield

if self.debug:
self.logger.debug("< %s %s HTTP/1.1", request.method, request.path)
self.logger.debug(
"< %s %s %s", request.method, request.path, request.protocol
)
for key, value in request.headers.raw_items():
self.logger.debug("< %s: %s", key, value)

Expand Down
4 changes: 4 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ def test_str(self):
InvalidMethod("POST"),
"unsupported HTTP method: POST",
),
(
InvalidProtocol("HTTP/2.0"),
"unsupported HTTP version: HTTP/2.0",
),
(
InvalidStatus(Response(401, "Unauthorized", Headers())),
"server rejected WebSocket connection: HTTP 401",
Expand Down
12 changes: 10 additions & 2 deletions tests/test_http11.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def test_parse(self):
request = self.assertGeneratorReturns(self.parse())
self.assertEqual(request.method, "GET")
self.assertEqual(request.path, "/chat")
self.assertEqual(request.protocol, "HTTP/1.1")
self.assertEqual(request.headers["Upgrade"], "websocket")

def test_parse_non_get_method(self):
Expand All @@ -45,6 +46,13 @@ def test_parse_non_get_method(self):
self.assertEqual(request.method, "OPTIONS")
self.assertEqual(request.path, "*")

def test_parse_http_1_0(self):
self.reader.feed_data(b"GET /chat HTTP/1.0\r\n\r\n")
request = self.assertGeneratorReturns(self.parse())
self.assertEqual(request.method, "GET")
self.assertEqual(request.path, "/chat")
self.assertEqual(request.protocol, "HTTP/1.0")

def test_parse_non_ascii_method(self):
self.reader.feed_data(b"G\xc9T /chat HTTP/1.1\r\n\r\n")
with self.assertRaises(UnicodeDecodeError):
Expand Down Expand Up @@ -85,12 +93,12 @@ def test_parse_invalid_request_line(self):
)

def test_parse_unsupported_protocol(self):
self.reader.feed_data(b"GET /chat HTTP/1.0\r\n\r\n")
self.reader.feed_data(b"GET /chat HTTP/2.0\r\n\r\n")
with self.assertRaises(ValueError) as raised:
next(self.parse())
self.assertEqual(
str(raised.exception),
"unsupported protocol; expected HTTP/1.1: GET /chat HTTP/1.0",
"unsupported protocol; expected HTTP/1.1 or HTTP/1.0: GET /chat HTTP/2.0",
)

def test_parse_invalid_header(self):
Expand Down
16 changes: 16 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
InvalidMessage,
InvalidMethod,
InvalidOrigin,
InvalidProtocol,
InvalidUpgrade,
NegotiationError,
RequestLineTooLong,
Expand Down Expand Up @@ -493,6 +494,21 @@ def test_invalid_method(self):
"unsupported HTTP method: POST",
)

def test_invalid_protocol(self):
"""Handshake fails when the request protocol isn't HTTP/1.1."""
server = ServerProtocol()
request = make_request()
request.protocol = "HTTP/1.0"
response = server.accept(request)
server.send_response(response)

self.assertEqual(response.status_code, 505)
self.assertHandshakeError(
server,
InvalidProtocol,
"unsupported HTTP version: HTTP/1.0",
)

def test_missing_connection(self):
"""Handshake fails when the Connection header is missing."""
server = ServerProtocol()
Expand Down