Skip to content

Commit af60bd4

Browse files
committed
Return 405 Method Not Allowed for non-GET HTTP requests
Instead of raising an exception when receiving HEAD, POST, or other non-GET HTTP methods, the server now properly returns a 405 Method Not Allowed response with an Allow: GET header. This change: - Adds InvalidMethod exception for unsupported HTTP methods - Modifies Request.parse() to raise InvalidMethod instead of ValueError - Handles InvalidMethod in ServerProtocol.parse() to return 405 response - Updates tests accordingly Fixes #1677
1 parent ce370b2 commit af60bd4

File tree

5 files changed

+99
-5
lines changed

5 files changed

+99
-5
lines changed

src/websockets/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* :exc:`ProxyError`
1313
* :exc:`InvalidProxyMessage`
1414
* :exc:`InvalidProxyStatus`
15+
* :exc:`InvalidMethod`
1516
* :exc:`InvalidMessage`
1617
* :exc:`InvalidStatus`
1718
* :exc:`InvalidStatusCode` (legacy)
@@ -52,6 +53,7 @@
5253
"ProxyError",
5354
"InvalidProxyMessage",
5455
"InvalidProxyStatus",
56+
"InvalidMethod",
5557
"InvalidMessage",
5658
"InvalidStatus",
5759
"InvalidHeader",
@@ -235,6 +237,19 @@ def __str__(self) -> str:
235237
return f"proxy rejected connection: HTTP {self.response.status_code:d}"
236238

237239

240+
class InvalidMethod(InvalidHandshake):
241+
"""
242+
Raised when the HTTP method isn't GET.
243+
244+
"""
245+
246+
def __init__(self, method: str) -> None:
247+
self.method = method
248+
249+
def __str__(self) -> str:
250+
return f"invalid HTTP method; expected GET; got {self.method}"
251+
252+
238253
class InvalidMessage(InvalidHandshake):
239254
"""
240255
Raised when a handshake request or response is malformed.

src/websockets/http11.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Callable
1010

1111
from .datastructures import Headers
12-
from .exceptions import SecurityError
12+
from .exceptions import InvalidMethod, SecurityError
1313
from .version import version as websockets_version
1414

1515

@@ -148,7 +148,7 @@ def parse(
148148
f"unsupported protocol; expected HTTP/1.1: {d(request_line)}"
149149
)
150150
if method != b"GET":
151-
raise ValueError(f"unsupported HTTP method; expected GET; got {d(method)}")
151+
raise InvalidMethod(d(method))
152152
path = raw_path.decode("ascii", "surrogateescape")
153153

154154
headers = yield from parse_headers(read_line)

src/websockets/server.py

Lines changed: 14 additions & 0 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,
@@ -547,6 +548,18 @@ def parse(self) -> Generator[None]:
547548
request = yield from Request.parse(
548549
self.reader.read_line,
549550
)
551+
except InvalidMethod as exc:
552+
self.handshake_exc = exc
553+
response = self.reject(
554+
http.HTTPStatus.METHOD_NOT_ALLOWED,
555+
f"Failed to open a WebSocket connection: {exc}.\n",
556+
)
557+
response.headers["Allow"] = "GET"
558+
self.send_response(response)
559+
self.parser = self.discard()
560+
next(self.parser) # start coroutine
561+
yield
562+
return
550563
except Exception as exc:
551564
self.handshake_exc = InvalidMessage(
552565
"did not receive a valid HTTP request"
@@ -556,6 +569,7 @@ def parse(self) -> Generator[None]:
556569
self.parser = self.discard()
557570
next(self.parser) # start coroutine
558571
yield
572+
return
559573

560574
if self.debug:
561575
self.logger.debug("< GET %s HTTP/1.1", request.path)

tests/test_http11.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from websockets.datastructures import Headers
2-
from websockets.exceptions import SecurityError
2+
from websockets.exceptions import InvalidMethod, SecurityError
33
from websockets.http11 import *
44
from websockets.http11 import parse_headers
55
from websockets.streams import StreamReader
@@ -61,11 +61,11 @@ def test_parse_unsupported_protocol(self):
6161

6262
def test_parse_unsupported_method(self):
6363
self.reader.feed_data(b"OPTIONS * HTTP/1.1\r\n\r\n")
64-
with self.assertRaises(ValueError) as raised:
64+
with self.assertRaises(InvalidMethod) as raised:
6565
next(self.parse())
6666
self.assertEqual(
6767
str(raised.exception),
68-
"unsupported HTTP method; expected GET; got OPTIONS",
68+
"invalid HTTP method; expected GET; got OPTIONS",
6969
)
7070

7171
def test_parse_invalid_header(self):

tests/test_server.py

Lines changed: 65 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,
@@ -257,6 +258,70 @@ def test_receive_junk_request(self):
257258
"invalid HTTP request line: HELO relay.invalid",
258259
)
259260

261+
@patch("email.utils.formatdate", return_value=DATE)
262+
def test_receive_head_request(self, _formatdate):
263+
"""Server receives a HEAD request and returns 405 Method Not Allowed."""
264+
server = ServerProtocol()
265+
server.receive_data(
266+
(
267+
f"HEAD /test HTTP/1.1\r\n"
268+
f"Host: example.com\r\n"
269+
f"\r\n"
270+
).encode(),
271+
)
272+
273+
self.assertEqual(server.events_received(), [])
274+
self.assertIsInstance(server.handshake_exc, InvalidMethod)
275+
self.assertEqual(str(server.handshake_exc), "invalid HTTP method; expected GET; got HEAD")
276+
self.assertEqual(
277+
server.data_to_send(),
278+
[
279+
f"HTTP/1.1 405 Method Not Allowed\r\n"
280+
f"Date: {DATE}\r\n"
281+
f"Connection: close\r\n"
282+
f"Content-Length: 84\r\n"
283+
f"Content-Type: text/plain; charset=utf-8\r\n"
284+
f"Allow: GET\r\n"
285+
f"\r\n"
286+
f"Failed to open a WebSocket connection: "
287+
f"invalid HTTP method; expected GET; got HEAD.\n".encode(),
288+
b"",
289+
],
290+
)
291+
self.assertTrue(server.close_expected())
292+
293+
@patch("email.utils.formatdate", return_value=DATE)
294+
def test_receive_post_request(self, _formatdate):
295+
"""Server receives a POST request and returns 405 Method Not Allowed."""
296+
server = ServerProtocol()
297+
server.receive_data(
298+
(
299+
f"POST /test HTTP/1.1\r\n"
300+
f"Host: example.com\r\n"
301+
f"\r\n"
302+
).encode(),
303+
)
304+
305+
self.assertEqual(server.events_received(), [])
306+
self.assertIsInstance(server.handshake_exc, InvalidMethod)
307+
self.assertEqual(str(server.handshake_exc), "invalid HTTP method; expected GET; got POST")
308+
self.assertEqual(
309+
server.data_to_send(),
310+
[
311+
f"HTTP/1.1 405 Method Not Allowed\r\n"
312+
f"Date: {DATE}\r\n"
313+
f"Connection: close\r\n"
314+
f"Content-Length: 84\r\n"
315+
f"Content-Type: text/plain; charset=utf-8\r\n"
316+
f"Allow: GET\r\n"
317+
f"\r\n"
318+
f"Failed to open a WebSocket connection: "
319+
f"invalid HTTP method; expected GET; got POST.\n".encode(),
320+
b"",
321+
],
322+
)
323+
self.assertTrue(server.close_expected())
324+
260325

261326
class ResponseTests(unittest.TestCase):
262327
"""Test generating opening handshake responses."""

0 commit comments

Comments
 (0)