Skip to content

Commit b8718b8

Browse files
committed
Reply with HTTP 405 to non-GET handshake requests.
Fix #1677.
1 parent b245513 commit b8718b8

8 files changed

Lines changed: 93 additions & 29 deletions

File tree

docs/project/changelog.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ Backwards-incompatible changes
4040

4141
websockets 16.1 is the last version supporting Python 3.10.
4242

43+
.. admonition:: ``process_request`` may receive requests using an HTTP method
44+
other than ``GET``.
45+
:class: important
46+
47+
Previously, the server closed the connection without returning an HTTP
48+
response. Now, ``process_request`` runs and can return an HTTP response.
49+
4350
.. admonition:: In the :mod:`threading` implementation, the ``socket`` argument
4451
is renamed to ``sock``.
4552
:class: note
@@ -64,6 +71,9 @@ New features
6471
Improvements
6572
............
6673

74+
* Replied with HTTP 405 Method Not Allowed when the handshake request doesn't
75+
use the GET method, instead of closing the connection.
76+
6777
* Added the ``reconnect_delays`` argument for customizing the delays between
6878
reconnection attempts in :func:`~asyncio.client.connect`, beyond existing
6979
``WEBSOCKETS_BACKOFF_*`` environment variables.

src/websockets/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"InvalidHeaderFormat",
4141
"InvalidHeaderValue",
4242
"InvalidMessage",
43+
"InvalidMethod",
4344
"InvalidOrigin",
4445
"InvalidParameterName",
4546
"InvalidParameterValue",
@@ -105,6 +106,7 @@
105106
InvalidHeaderFormat,
106107
InvalidHeaderValue,
107108
InvalidMessage,
109+
InvalidMethod,
108110
InvalidOrigin,
109111
InvalidParameterName,
110112
InvalidParameterValue,
@@ -171,6 +173,7 @@
171173
"InvalidHeaderFormat": ".exceptions",
172174
"InvalidHeaderValue": ".exceptions",
173175
"InvalidMessage": ".exceptions",
176+
"InvalidMethod": ".exceptions",
174177
"InvalidOrigin": ".exceptions",
175178
"InvalidParameterName": ".exceptions",
176179
"InvalidParameterValue": ".exceptions",

src/websockets/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* :exc:`InvalidProxyMessage`
1414
* :exc:`InvalidProxyStatus`
1515
* :exc:`InvalidMessage`
16+
* :exc:`InvalidMethod`
1617
* :exc:`InvalidStatus`
1718
* :exc:`InvalidStatusCode` (legacy)
1819
* :exc:`InvalidHeader`
@@ -53,6 +54,7 @@
5354
"InvalidProxyMessage",
5455
"InvalidProxyStatus",
5556
"InvalidMessage",
57+
"InvalidMethod",
5658
"InvalidStatus",
5759
"InvalidHeader",
5860
"InvalidHeaderFormat",
@@ -242,6 +244,19 @@ class InvalidMessage(InvalidHandshake):
242244
"""
243245

244246

247+
class InvalidMethod(InvalidHandshake):
248+
"""
249+
Raised when a handshake request doesn't use HTTP GET.
250+
251+
"""
252+
253+
def __init__(self, method: str) -> None:
254+
self.method = method
255+
256+
def __str__(self) -> str:
257+
return f"unsupported HTTP method: {self.method}"
258+
259+
245260
class InvalidStatus(InvalidHandshake):
246261
"""
247262
Raised when a handshake response rejects the WebSocket upgrade.

src/websockets/http11.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,14 @@ class Request:
8383
Attributes:
8484
path: Request path, including optional query.
8585
headers: Request headers.
86+
method: Request method; WebSocket handshake requests use GET.
8687
"""
8788

8889
path: str
8990
headers: Headers
91+
# method comes before path in the HTTP request, but it has a default,
92+
# so it must be declared after path and headers which don't have one.
93+
method: str = "GET"
9094
# body isn't useful is the context of this library.
9195

9296
_exception: Exception | None = None
@@ -109,14 +113,12 @@ def parse(
109113
110114
This is a generator-based coroutine.
111115
112-
The request path isn't URL-decoded or validated in any way.
116+
The request method, path, and headers are expected to contain only ASCII
117+
characters. The request path isn't URL-decoded or validated in any way.
113118
114-
The request path and headers are expected to contain only ASCII
115-
characters. Other characters are represented with surrogate escapes.
116-
117-
:meth:`parse` doesn't attempt to read the request body because
118-
WebSocket handshake requests don't have one. If the request contains a
119-
body, it may be read from the data stream after :meth:`parse` returns.
119+
:meth:`parse` doesn't read the request body because WebSocket handshake
120+
requests don't have one. If the request contains a body, it may be read
121+
from the data stream after :meth:`parse` returns.
120122
121123
Args:
122124
read_line: Generator-based coroutine that reads a LF-terminated
@@ -130,25 +132,24 @@ def parse(
130132
"""
131133
# https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.1
132134

133-
# Parsing is simple because fixed values are expected for method and
134-
# version and because path isn't checked. Since WebSocket software tends
135-
# to implement HTTP/1.1 strictly, there's little need for lenient parsing.
135+
# Parsing is simple because a fixed value is expected for the version
136+
# and because path isn't checked. Since WebSocket libraries generally
137+
# implement HTTP/1.1 strictly, there's little need for lenient parsing.
136138

137139
try:
138140
request_line = yield from parse_line(read_line)
139141
except EOFError as exc:
140142
raise EOFError("connection closed while reading HTTP request line") from exc
141143

142144
try:
143-
method, raw_path, protocol = request_line.split(b" ", 2)
145+
raw_method, raw_path, protocol = request_line.split(b" ", 2)
144146
except ValueError: # not enough values to unpack (expected 3, got 1-2)
145147
raise ValueError(f"invalid HTTP request line: {d(request_line)}") from None
146148
if protocol != b"HTTP/1.1":
147149
raise ValueError(
148150
f"unsupported protocol; expected HTTP/1.1: {d(request_line)}"
149151
)
150-
if method != b"GET":
151-
raise ValueError(f"unsupported HTTP method; expected GET; got {d(method)}")
152+
method = raw_method.decode("ascii")
152153

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

170-
return cls(path, headers)
171+
return cls(path, headers, method)
171172

172173
def serialize(self) -> bytes:
173174
"""
@@ -176,7 +177,7 @@ def serialize(self) -> bytes:
176177
"""
177178
# Since the request line and headers only contain ASCII characters,
178179
# we can keep this simple.
179-
request = f"GET {self.path} HTTP/1.1\r\n".encode()
180+
request = f"{self.method} {self.path} HTTP/1.1\r\n".encode()
180181
request += self.headers.serialize()
181182
return request
182183

src/websockets/server.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
InvalidHeader,
1616
InvalidHeaderValue,
1717
InvalidMessage,
18+
InvalidMethod,
1819
InvalidOrigin,
1920
InvalidUpgrade,
2021
NegotiationError,
@@ -147,6 +148,17 @@ def accept(self, request: Request) -> Response:
147148
http.HTTPStatus.FORBIDDEN,
148149
f"Failed to open a WebSocket connection: {exc}.\n",
149150
)
151+
except InvalidMethod as exc:
152+
request._exception = exc
153+
self.handshake_exc = exc
154+
if self.debug:
155+
self.logger.debug("! invalid method", exc_info=True)
156+
response = self.reject(
157+
http.HTTPStatus.METHOD_NOT_ALLOWED,
158+
f"Failed to open a WebSocket connection: {exc}.\n",
159+
)
160+
response.headers["Allow"] = "GET"
161+
return response
150162
except InvalidUpgrade as exc:
151163
request._exception = exc
152164
self.handshake_exc = exc
@@ -209,10 +221,9 @@ def process_request(
209221
"""
210222
Check a handshake request and negotiate extensions and subprotocol.
211223
212-
This function doesn't verify that the request is an HTTP/1.1 or higher
213-
GET request and doesn't check the ``Host`` header. These controls are
214-
usually performed earlier in the HTTP request handling code. They're
215-
the responsibility of the caller.
224+
This function doesn't verify that that it's an HTTP/1.1 request and
225+
doesn't check the ``Host`` header. These controls must be performed
226+
by the HTTP stack earlier. They're the responsibility of the caller.
216227
217228
Args:
218229
request: WebSocket handshake request received from the client.
@@ -222,10 +233,15 @@ def process_request(
222233
``Sec-WebSocket-Protocol`` headers for the handshake response.
223234
224235
Raises:
236+
InvalidMethod: If the request method isn't GET; then the
237+
server must return a 405 Method Not Allowed error.
225238
InvalidHandshake: If the handshake request is invalid;
226239
then the server must return 400 Bad Request error.
227240
228241
"""
242+
if request.method != "GET":
243+
raise InvalidMethod(request.method)
244+
229245
headers = request.headers
230246

231247
connection: list[ConnectionOption] = sum(
@@ -558,7 +574,7 @@ def parse(self) -> Generator[None]:
558574
yield
559575

560576
if self.debug:
561-
self.logger.debug("< GET %s HTTP/1.1", request.path)
577+
self.logger.debug("< %s %s HTTP/1.1", request.method, request.path)
562578
for key, value in request.headers.raw_items():
563579
self.logger.debug("< %s: %s", key, value)
564580

tests/test_exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ def test_str(self):
103103
InvalidMessage("malformed HTTP message"),
104104
"malformed HTTP message",
105105
),
106+
(
107+
InvalidMethod("POST"),
108+
"unsupported HTTP method: POST",
109+
),
106110
(
107111
InvalidStatus(Response(401, "Unauthorized", Headers())),
108112
"server rejected WebSocket connection: HTTP 401",

tests/test_http11.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,16 @@ def test_parse(self):
2929
b"\r\n"
3030
)
3131
request = self.assertGeneratorReturns(self.parse())
32+
self.assertEqual(request.method, "GET")
3233
self.assertEqual(request.path, "/chat")
3334
self.assertEqual(request.headers["Upgrade"], "websocket")
3435

36+
def test_parse_non_get_method(self):
37+
self.reader.feed_data(b"OPTIONS * HTTP/1.1\r\n\r\n")
38+
request = self.assertGeneratorReturns(self.parse())
39+
self.assertEqual(request.method, "OPTIONS")
40+
self.assertEqual(request.path, "*")
41+
3542
def test_parse_empty(self):
3643
self.reader.feed_eof()
3744
with self.assertRaises(EOFError) as raised:
@@ -59,15 +66,6 @@ def test_parse_unsupported_protocol(self):
5966
"unsupported protocol; expected HTTP/1.1: GET /chat HTTP/1.0",
6067
)
6168

62-
def test_parse_unsupported_method(self):
63-
self.reader.feed_data(b"OPTIONS * HTTP/1.1\r\n\r\n")
64-
with self.assertRaises(ValueError) as raised:
65-
next(self.parse())
66-
self.assertEqual(
67-
str(raised.exception),
68-
"unsupported HTTP method; expected GET; got OPTIONS",
69-
)
70-
7169
def test_parse_invalid_header(self):
7270
self.reader.feed_data(b"GET /chat HTTP/1.1\r\nOops\r\n")
7371
with self.assertRaises(ValueError) as raised:

tests/test_server.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from websockets.exceptions import (
1010
InvalidHeader,
1111
InvalidMessage,
12+
InvalidMethod,
1213
InvalidOrigin,
1314
InvalidUpgrade,
1415
NegotiationError,
@@ -357,6 +358,22 @@ def test_basic(self):
357358

358359
self.assertHandshakeSuccess(server)
359360

361+
def test_invalid_method(self):
362+
"""Handshake fails when the request method isn't GET."""
363+
server = ServerProtocol()
364+
request = make_request()
365+
request.method = "POST"
366+
response = server.accept(request)
367+
server.send_response(response)
368+
369+
self.assertEqual(response.status_code, 405)
370+
self.assertEqual(response.headers["Allow"], "GET")
371+
self.assertHandshakeError(
372+
server,
373+
InvalidMethod,
374+
"unsupported HTTP method: POST",
375+
)
376+
360377
def test_missing_connection(self):
361378
"""Handshake fails when the Connection header is missing."""
362379
server = ServerProtocol()

0 commit comments

Comments
 (0)