Repository: sanic-org/sanic (python)

Issue:
Websocket invalid upgrade exception handling b0rkage
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

A client apparently sent no Upgrade header to a websocket endpoint, leading to an error as it should. An ugly traceback is printed on terminal even though the error eventually gets handled correctly it would seem.

It would appear that the websockets module attempts to attach its exception on `request._exception` field which Sanic's Request doesn't have a slot for. This could be hidden if Sanic later used `raise BadRequest(...) from None` rather than `raise SanicException(...)`, suppressing the chain and giving a non-500 error for what really is no server error. Not sure though if that would from this context ever reach the client anyway but at least it could avoid a traceback in server log.

If anyone wants to investigate and make a PR, feel free to (I am currently busy and cannot do that unfortunately).

```python
Traceback (most recent call last):
  File "/home/user/.local/lib/python3.10/site-packages/websockets/server.py", line 111, in accept
    ) = self.process_request(request)
  File "/home/user/.local/lib/python3.10/site-packages/websockets/server.py", line 218, in process_request
    raise InvalidUpgrade("Upgrade", ", ".join(upgrade) if upgrade else None)
websockets.exceptions.InvalidUpgrade: missing Upgrade header

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/user/sanic/sanic/server/protocols/websocket_protocol.py", line 120, in websocket_handshake
    resp: "http11.Response" = ws_proto.accept(request)
  File "/home/user/.local/lib/python3.10/site-packages/websockets/server.py", line 122, in accept
    request._exception = exc
AttributeError: 'Request' object has no attribute '_exception'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "handle_request", line 97, in handle_request
  File "/home/user/sanic/sanic/app.py", line 1047, in _websocket_handler
    ws = await protocol.websocket_handshake(request, subprotocols)
  File "/home/user/sanic/sanic/server/protocols/websocket_protocol.py", line 126, in websocket_handshake
    raise SanicException(msg, status_code=500)
sanic.exceptions.SanicException: Failed to open a WebSocket connection.
See server log for more information.
```


### Code snippet

_No response_

### Expected Behavior

400 Bad Request error reaching the client and being more silent on server side. Including the message of **missing Upgrade header** would be helpful for debugging (e.g. in case Nginx proxy config forgot to forward that header).

### How do you run Sanic?

Sanic CLI

### Operating System

Linux

### Sanic Version

Almost 23.03.0 (a git version slightly before release)

### Additional context

_No response_

Pull request:
Convert Sanic `Request` to WebSocket `Request` in `websocket_handshake`
### Description

This PR is a (potentially naive) solution to fix an issue that missed WebSocket Upgrade request header during a WebSocket handshaking process causing 500 server error being returned.

After debugging, I found that the error stems from Sanic's Request object being passed into `ws_proto.accept(...)` method, which expects a `request` to be a [WebSocket Request object](https://github.com/python-websockets/websockets/blob/5737b474ad7d4a3a5e04d68299f4e5ec34bd62ac/src/websockets/http11.py#L52). The issue is occurred when the `websockets` package is trying to assign an `_exception` value to the request object, resulting the attribute not existent error will be raised, which causes a 500 server error.

My solution is to convert Sanic Request object to be a WebSocket Request object in `websocket_handshake` method. I feel like this is a naive solution and am stilling digging into how a WebSocket requests is processed in Sanic's code, and I am also working on test cases.

May I have some feedback and comments?

### Related issue

closes #2743 

### Code snippets for reproducing the issue

#### server

```python
import sanic

from sanic import Request
from sanic.server.websockets.impl import WebsocketImplProtocol

app = sanic.Sanic("MySanic")

@app.websocket("/ws-echo")
async def ws_echo(request: Request, ws: WebsocketImplProtocol):
    message = "Start"
    while True:
        await ws.send(message)
        message = await ws.recv()


if __name__ == "__main__":
    app.run(auto_reload=True)
```

#### client

```python
import http.client
import base64
import secrets
from urllib.parse import urlparse


def connect_websocket(url: str, message: str) -> None:
    parsed_url = urlparse(url)
    conn = http.client.HTTPConnection(parsed_url.netloc)

    websocket_key = base64.b64encode(secrets.token_bytes(16)).decode('utf-8')
    headers = {
        "Upgrade": "websocket",
        #"Connection": "Upgrade",
        "Sec-WebSocket-Key": websocket_key,
        "Sec-WebSocket-Version": "13"
    }

    conn.putrequest("GET", parsed_url.path)
    for header, value in headers.items():
        conn.putheader(header, value)
    conn.endheaders()

    response = conn.getresponse()
    if response.status == 101 and response.getheader('Upgrade', '').lower() == 'websocket':
        print("WebSocket handshake successful")
        conn.sock.sendall(message.encode())
    else:
        print(f"WebSocket handshake failed: {response.status} {response.reason}")

    conn.close()

websocket_url = "ws://localhost:8000/ws-echo"
connect_websocket(websocket_url, "Hello, WebSocket Server!")

```

Patch:
diff --git a/sanic/server/protocols/websocket_protocol.py b/sanic/server/protocols/websocket_protocol.py
index 52a33bf8..7e026d8d 100644
--- a/sanic/server/protocols/websocket_protocol.py
+++ b/sanic/server/protocols/websocket_protocol.py
@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Optional, Sequence, cast
+from typing import Optional, Sequence, cast
 
 
 try:  # websockets < 11.0
@@ -8,19 +8,18 @@ except ImportError:  # websockets >= 11.0
     from websockets.protocol import State  # type: ignore
     from websockets.server import ServerProtocol  # type: ignore
 
+from websockets import http11
+from websockets.datastructures import Headers as WSHeaders
 from websockets.typing import Subprotocol
 
 from sanic.exceptions import SanicException
 from sanic.log import logger
+from sanic.request import Request
 from sanic.server import HttpProtocol
 
 from ..websockets.impl import WebsocketImplProtocol
 
 
-if TYPE_CHECKING:
-    from websockets import http11
-
-
 OPEN = State.OPEN
 CLOSING = State.CLOSING
 CLOSED = State.CLOSED
@@ -94,6 +93,13 @@ class WebSocketProtocol(HttpProtocol):
         else:
             return super().close_if_idle()
 
+    @staticmethod
+    def sanic_request_to_ws_request(request: Request):
+        return http11.Request(
+            path=request.path,
+            headers=WSHeaders(request.headers),
+        )
+
     async def websocket_handshake(
         self, request, subprotocols: Optional[Sequence[str]] = None
     ):
@@ -117,7 +123,7 @@ class WebSocketProtocol(HttpProtocol):
                 state=OPEN,
                 logger=logger,
             )
-            resp: "http11.Response" = ws_proto.accept(request)
+            resp = ws_proto.accept(self.sanic_request_to_ws_request(request))
         except Exception:
             msg = (
                 "Failed to open a WebSocket connection.\n"


Test patch:
diff --git a/tests/test_ws_handlers.py b/tests/test_ws_handlers.py
index 62362924..25932ed4 100644
--- a/tests/test_ws_handlers.py
+++ b/tests/test_ws_handlers.py
@@ -1,3 +1,6 @@
+import base64
+import secrets
+
 from typing import Any, Callable, Coroutine
 
 import pytest
@@ -70,6 +73,23 @@ def test_ws_handler(
     assert ws_proxy.client_received == ["test 1", "test 2"]
 
 
+def test_ws_handler_invalid_upgrade(app: Sanic):
+    @app.websocket("/ws")
+    async def ws_echo_handler(request: Request, ws: Websocket):
+        async for msg in ws:
+            await ws.send(msg)
+
+    ws_key = base64.b64encode(secrets.token_bytes(16)).decode("utf-8")
+    invalid_upgrade_headers = {
+        "Upgrade": "websocket",
+        # "Connection": "Upgrade",
+        "Sec-WebSocket-Key": ws_key,
+        "Sec-WebSocket-Version": "13",
+    }
+    _, response = app.test_client.get("/ws", headers=invalid_upgrade_headers)
+    assert response.status == 426
+
+
 def test_ws_handler_async_for(
     app: Sanic,
     simple_ws_mimic_client: MimicClientType,