Repository: sanic-org/sanic

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_


Discussion:
I plan to take a look at it if no one else started working on it.
~~Sanic server return a 500 error in this case. Should we make it be 400 instead? (I think so, but would like to double check)~~
Sorry misread the issue, and we should make it 400

FYI, this code snippet I got can reproduce the issue.
```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!")
```



Resolving patch:
diff --git a/sanic/server/protocols/websocket_protocol.py b/sanic/server/protocols/websocket_protocol.py
--- 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 @@
     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 @@ def close_if_idle(self):
         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 @@ async def websocket_handshake(
                 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
--- 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 @@ async def ws_echo_handler(request: Request, ws: Websocket):
     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,