-
-
Notifications
You must be signed in to change notification settings - Fork 953
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support the WebSocket Denial Response ASGI extension #2041
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the RuntimeError
makes more sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test needs to be fixed, I guess?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -62,8 +53,6 @@ async def receive(self) -> Message: | |||
) | |||
if message_type == "websocket.disconnect": | |||
self.client_state = WebSocketState.DISCONNECTED | |||
if "code" not in message: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, further reading of standard indicates that this code must be application level only.
starlette/websockets.py
Outdated
|
||
async def receive(self) -> Message: | ||
""" | ||
Receive ASGI websocket messages, ensuring valid state transitions. | ||
""" | ||
if self.app_disconnect_msg is not None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is unrelated to the PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, this should fallback to close()
if the extension isn't supported
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for picking this up ❤️
starlette/testclient.py
Outdated
def __init__( | ||
self, | ||
status_code: int, | ||
headers: typing.List[typing.Tuple[bytes, bytes]] = [], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, this should fallback to close()
if the extension isn't supported
starlette/websockets.py
Outdated
@@ -181,6 +199,11 @@ async def close( | |||
{"type": "websocket.close", "code": code, "reason": reason or ""} | |||
) | |||
|
|||
async def send_response(self, response: Response) -> None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -68,6 +68,18 @@ def __init__(self, session: "WebSocketTestSession") -> None: | |||
self.session = session | |||
|
|||
|
|||
class DenialResponse(Exception): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this the same exception? The default empty string proves something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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. |
* supply asgi_extensions to TestClient * Add WebSocket.send_response() * Add response support for WebSocket testclient * fix test for filesystem line-endings * lintint * support websocket.http.response extension by default * Improve coverate * Apply suggestions from code review Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com> * Undo unrelated change * fix incorrect error message * Update starlette/websockets.py Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com> * formatting * Re-introduce close-code and close-reason to WebSocketReject * Make sure the "websocket.connect" message is received in tests * Deliver a websocket.disconnect message to the app even if it closes/rejects itself. * Add test for filling out missing `websocket.disconnect` code * Add rejection headers. Expand tests. * Fix types, headers in message are `bytes` tuples. * Minimal WebSocket Denial Response implementation * Revert "Minimal WebSocket Denial Response implementation" This reverts commit 7af10dd. * Rename to send_denial_response and update documentation * Remove the app_disconnect_msg. This can be added later in a separate PR * Remove status code 1005 from this PR * Assume that the application has tested for the extension before sending websocket.http.response.start * Rename WebSocketReject to WebSocketDenialResponse * Remove code and status from WebSocketDenialResponse. Just send a regular WebSocketDisconnect even when connection is rejected with close() * Raise an exception if attempting to send a http response and server does not support it. * WebSocketDenialClose and WebSocketDenialResponse These are both instances of WebSocketDenial. * Update starlette/testclient.py Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com> * Revert "WebSocketDenialClose and WebSocketDenialResponse" This reverts commit 71b76e3. * Rename parameters, member variables * Use httpx.Response as the base for WebSocketDenialResponse. * Apply suggestions from code review Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com> * Update sanity check message * Remove un-needed function * Expand error message test regex * Add type hings to test methods * Add doc string to test. * Fix mypy complaining about mismatching parent methods. * nitpick & remove test * Simplify the documentation * Update starlette/testclient.py * Update starlette/testclient.py * Remove an unnecessary test * there is no special "close because of rejection" in the testclient anymore. --------- Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
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
WebSocket
class as well as in theTestClient
(see https://asgi.readthedocs.io/en/latest/extensions.html)A new
send_response()
method is added toWebSocket
, taking aResponse
object. 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.WebSocketReject
exception 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
code
andreason
for the fallback case (when the extension isn't supported by the server). How exactly a server should construct 403 out of thecode
andreason
isn'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.