Support the WebSocket Denial Response ASGI extension#2041
Support the WebSocket Denial Response ASGI extension#2041kristjanvalur merged 51 commits intoKludex:masterfrom
Conversation
Kludex
left a comment
There was a problem hiding this comment.
Would you like to implement the support for Websocket Denial Response on Uvicorn?
starlette/websockets.py
Outdated
| else: | ||
| await self.close(code=1008, reason=f"HTTP Response {response.status_code}") |
There was a problem hiding this comment.
Maybe we can raise a runtime exception with "The server doesn't support the Websocket Denial Response extension." instead?
Why do you think we should have a fallback?
There was a problem hiding this comment.
My thinking was that the application shouldn't have to have code dealing with server peculiarities. The application could simply always create an error response, and leave it to Starlette to decide what to do with it. If the server supports it, then fine. If not, Starlette automatically does the next best thing, simply close the socket, which is specified to create a 403 response.
The fallback code and message are arbitrary, because ASGI does not specify what should happen to these codes in case of a 403. maybe the server adds that to the 403 payload, maybe not. I haven't looked at what uvicorn does.
There was a problem hiding this comment.
If this is the path we want to go, then let's remove the code and reason. Both doesn't make sense, since the handshake didn't happen, and the server actually should ignore both code and reason.
There was a problem hiding this comment.
I'm still not sure if we want this... If you are using send_response, I'd expect that working out, and if it doesn't, as a developer I'd like to see an exception 🤔
There was a problem hiding this comment.
But that exception appearing or not depends on which server you choose to run your app. The response here is informative only. Starlette is ASGI toolkit aimed at simplifying development.
As I see it, here are the options:
- just close the connection(). This is always supported and is the original way of rejecting a connection. Server provides a 403 response.
- Close the response, but proide additional information for those interested.
2a) Server supports the extension, and a response body is delivered along with the message.
2b) Server does not support the response and body is ignored.
It is th case 2b) which worries you. IMHO, the response body is informative only. Depending on web server a response body can or can not be delivered. If the information is somehow protocol critical (not recommended), then the application can always check for the presence of the extension.
Ok, how about this: Instead of an exception, how about a warning? This will get flagged to the developer but it won't crash your application if an extension isn't supported. And we won't have to push the burden on the developer to always check the state for the presence of the extension before using a close call with an extended api?
There was a problem hiding this comment.
I think the RuntimeError makes more sense.
There was a problem hiding this comment.
I understand what you are saying, but I don't think the premise is right.
If you start using send_denial_response, and you don't have an error, you'll believe it's working as expected, when it actually is not.
There was a problem hiding this comment.
My thinking is that the denial response is an informative event. It augments the plain close event, possibly with
some nicely formatted data, information about what is missing, etc. But it is not essential to the operation of the service.
I understand it as "I'm not accepting this connection. Here is some additional information to the client, if I can send it and if he wants to receive it, but otherwise, I'm done with the connection."
This is why I suggested that maybe a warning were appropriate.
But I don't feel strongly about this anymore and am happy to do it either way, so having made my point, I can make it an exception.
Sure, at least, I can have a look. |
|
I'm re-adding The exception then contains the information as provided by the application. Previously a |
463564a to
8810e35
Compare
|
I've also made sure that the Testclient now delivers the |
0c2f9b0 to
3e39873
Compare
|
I guess the only thing here that needs deciding is if But I've stated my case and will do this however you like :) |
|
2882d32 to
4a42c3c
Compare
Kludex
left a comment
There was a problem hiding this comment.
@kristjanvalur Can you review my changes here, please?
@paulo-raca if you want to review as well...
There's still a test, and documentation to be fixed.
starlette/websockets.py
Outdated
| async def send_response(self, response: Response) -> None: | ||
| if self._have_response_extension(): | ||
| await response(self.scope, self.receive, self.send) | ||
| else: | ||
| await self.close(code=1008, reason=f"HTTP Response {response.status_code}") |
There was a problem hiding this comment.
Instead of this API, does it make sense to allow return Response()?
starlette/websockets.py
Outdated
| else: | ||
| await self.close(code=1008, reason=f"HTTP Response {response.status_code}") |
There was a problem hiding this comment.
I think the RuntimeError makes more sense.
docs/websockets.md
Outdated
|
|
||
| ### Rejecting the connection | ||
|
|
||
| Before the calling `websocket.accept()` it is possible to reject the connection, |
There was a problem hiding this comment.
| Before the calling `websocket.accept()` it is possible to reject the connection, | |
| Before calling `websocket.accept()` it is possible to reject the connection, |
tests/test_websockets.py
Outdated
| def test_send_disconnect_no_code(test_client_factory: Callable[..., TestClient]): | ||
| close_msg: Message = {} | ||
|
|
||
| async def app(scope: Scope, receive: Receive, send: Send) -> None: | ||
| nonlocal close_msg | ||
| websocket = WebSocket(scope, receive=receive, send=send) | ||
| await websocket.accept() | ||
| close_msg = await websocket.receive() | ||
|
|
||
| client = test_client_factory(app) | ||
| with client.websocket_connect("/") as websocket: | ||
| websocket.send({"type": "websocket.disconnect"}) | ||
|
|
||
| assert close_msg == {"type": "websocket.disconnect"} |
There was a problem hiding this comment.
This test needs to be fixed, I guess?
kristjanvalur
left a comment
There was a problem hiding this comment.
Well, this is a substantial rewrite of my PR so I don't think you need my approval for any of it.
I didn't review the tests changes for that reason.
The changes, as I see them, are
- in the TestClient where you removed the ability to test for specific types of close events
- Removal of protocol verification in the
send()command - Removal of the return of the local disconnect event from
receive(). - Requiring application to add boilerplate code if it wants to terminate with a response.
Fine by me, but like I said, it is almost not my PR anymore :)
starlette/websockets.py
Outdated
| ) | ||
| if message_type == "websocket.disconnect": | ||
| self.client_state = WebSocketState.DISCONNECTED | ||
| if "code" not in message: |
There was a problem hiding this comment.
yes, further reading of standard indicates that this code must be application level only.
starlette/websockets.py
Outdated
| """ | ||
| Receive ASGI websocket messages, ensuring valid state transitions. | ||
| """ | ||
| if self.app_disconnect_msg is not None: |
There was a problem hiding this comment.
Not sure why you are removing this. If I read asgiref correctly, a receive on a disconnected connection should return the close message which caused the disconnection, even if it was a local message.
starlette/websockets.py
Outdated
| if message_type == "websocket.close": | ||
| self.application_state = WebSocketState.DISCONNECTED | ||
| # no close frame is sent, then the default is 1006 | ||
| self.app_disconnect_msg = {"type": "websocket.disconnect", "code": 1006} |
There was a problem hiding this comment.
see above. we are creating a close message to be read by the application, not to send over the wire. 1006 is appropriate code in this case
There was a problem hiding this comment.
Strictly yes, although the PR was an overhaul of the disconnect mechanism and it seemed prudent to fill in the cracks, and it fitted well with the testing added. But I can remove the app_disconnect_msg system from this pr, and so an app won't receive() any local close().
There was a problem hiding this comment.
If you can do that, it would be cool. Sorry for the back and forth here...
starlette/websockets.py
Outdated
| await self.close(code=1008, reason=f"HTTP Response {response.status_code}") | ||
| if "websocket.http.response" in self.scope.get("extensions", {}): | ||
| return await response(self.scope, self.receive, self.send) | ||
| raise RuntimeError("The server doesn't support the WebSocket Denial extension.") |
There was a problem hiding this comment.
For the record, I disagree here. You are forcing the creation of boilerplate in the application. When the server does not support the extension, a close() is the only available option. This requires the application to either always check for the presence of the extension, or have a try_catch mechanism built in. IMHO, sensible middleware should simply do the right thing, possibly issuing a warning. Middleware like Starlette is supposed to make the interaction with ASGI simpler, and remove such considerations from the application developer.
There was a problem hiding this comment.
I agree, this should fallback to close() if the extension isn't supported
starlette/testclient.py
Outdated
| def __init__( | ||
| self, | ||
| status_code: int, | ||
| headers: typing.List[typing.Tuple[bytes, bytes]] = [], |
There was a problem hiding this comment.
Seems clumsy -- Why not Dict[str, str] (or starlette.datastructures.Headers)?
In fact, this should probably contain a httpx.Response -- this would allow reusing code more easily
starlette/websockets.py
Outdated
| await self.close(code=1008, reason=f"HTTP Response {response.status_code}") | ||
| if "websocket.http.response" in self.scope.get("extensions", {}): | ||
| return await response(self.scope, self.receive, self.send) | ||
| raise RuntimeError("The server doesn't support the WebSocket Denial extension.") |
There was a problem hiding this comment.
I agree, this should fallback to close() if the extension isn't supported
starlette/websockets.py
Outdated
| {"type": "websocket.close", "code": code, "reason": reason or ""} | ||
| ) | ||
|
|
||
| async def send_response(self, response: Response) -> None: |
There was a problem hiding this comment.
nit: I would call it send_denial_response, as it is a better match to the specs and makes it clear that it is a failure scenario.
starlette/testclient.py
Outdated
| self.session = session | ||
|
|
||
|
|
||
| class DenialResponse(Exception): |
There was a problem hiding this comment.
nit: maybe make it clear it is websocket-only exception?
| class DenialResponse(Exception): | |
| class WebsocketDenialResponse(Exception): |
|
I've reverted my changes. I'll take your comments in consideration. |
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
69efd9e to
b70cdbd
Compare
b70cdbd to
4f27aba
Compare
tests/test_websockets.py
Outdated
| def test_send_disconnect_no_code(test_client_factory: Callable[..., TestClient]): | ||
| """Test that a client close message with a missing status code is accepted, | ||
| and verify the message passed to the application.""" | ||
|
|
||
| close_msg: Message = {} | ||
|
|
||
| async def app(scope: Scope, receive: Receive, send: Send) -> None: | ||
| nonlocal close_msg | ||
| websocket = WebSocket(scope, receive=receive, send=send) | ||
| await websocket.accept() | ||
| close_msg = await websocket.receive() | ||
|
|
||
| client = test_client_factory(app) | ||
| with client.websocket_connect("/") as websocket: | ||
| websocket.send({"type": "websocket.disconnect"}) | ||
|
|
||
| assert close_msg == {"type": "websocket.disconnect"} |
There was a problem hiding this comment.
@kristjanvalur This test doesn't make sense to me. Also, this is more a test for the TestClient than the WebSocket, so it should go in test_testclient.py.
I'll remove it, but if you want, feel free to open a new PR adding the code on the TestClient.
There was a problem hiding this comment.
sure, I'll remove it.
starlette/testclient.py
Outdated
| if not reject: | ||
| raise WebSocketDisconnect( | ||
| message.get("code", 1000), message.get("reason", "") | ||
| ) | ||
| else: | ||
| raise WebSocketDisconnect( | ||
| code=message.get("code", 1000), | ||
| reason=message.get("reason"), | ||
| ) |
There was a problem hiding this comment.
Isn't this the same exception? The default empty string proves something?
There was a problem hiding this comment.
it is, previously we used to raise a different exception there, to indicate a special "kind" of close. Since the distinction is now gone, I can simplify.
There was a problem hiding this comment.
Ok, I see you already did all that, so never mind.
|
@kristjanvalur I've invited you to the organization. Thanks for the help on the extension! 🙏 Please check if you are happy with my last commits, and if so, feel free to merge it. 👍 |
|
@paulo-raca Vlw pelo review! 🙏 English: Thanks for the review! 🙏 |
|
Thank you, your commits were fine, will do. |
|
This may not have been the most intuitive API. 🤔 Maybe raising |
|
As an alternative to calling Any reason why this comment comes up now? Are there any conflicts with current development? |
No. In contrast to raising an exception, like what I intend with this: #2725 Also, don't get me wrong, this PR was great. I'm just unsure about the API on the
I was adding this to a presentation I'm going to do, and then I noticed that we don't support raising HTTPException before the |
|
I see. Well, exceptions are super. We probably should have considered exceptions, but my thinking was that |
This PR adds support for the "websocket.http.response" ASGI extension in the
WebSocketclass as well as in theTestClient(see https://asgi.readthedocs.io/en/latest/extensions.html)A new
send_response()method is added toWebSocket, taking aResponseobject. This must only be called beforeaccept().websocket.close(1008)is performed which an ASGI server should turn into a 403 response.A new
starlette.testlient.WebSocketRejectexception is added which represents a disconnect before awebsocket.accept()call. In a real HTTP client, it would correspond to a failure response to the Upgrade request.Added documentation about rejecting a WebSocket connection, either with
send_response()or aclose()beforeaccept()Initially raised as discussion #...
This PR was spurred by a perceived need to standardize better how to do authentication and connection rejection in ASGI frameworks. In particular, FastAPI seems a bit wobbly there.
the API presented is a suggestion. We could use
close_response()for example,.We could also provide a
codeandreasonfor the fallback case (when the extension isn't supported by the server). How exactly a server should construct 403 out of thecodeandreasonisn't specified by ASGI Different servers might or might not add that information to the response Body so providing that would be a pure convenience.