Skip to content
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

Merged
merged 51 commits into from Feb 4, 2024

Conversation

kristjanvalur
Copy link
Contributor

@kristjanvalur kristjanvalur commented Feb 18, 2023

This PR adds support for the "websocket.http.response" ASGI extension in the WebSocket class as well as in the
TestClient (see https://asgi.readthedocs.io/en/latest/extensions.html)

  • A new send_response() method is added to WebSocket, taking a Response object. This must only be called before accept().

    • If the extension is supported, the response is sent as per extension specification
    • Otherwise, a 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 a websocket.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 a close() before accept()

  • 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 and reason for the fallback case (when the extension isn't supported by the server). How exactly a server should construct 403 out of the code and reason 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.

@kristjanvalur kristjanvalur marked this pull request as ready for review February 18, 2023 21:44
@Kludex
Copy link
Sponsor Member

Kludex commented Feb 18, 2023

Copy link
Sponsor Member

@Kludex Kludex left a 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/testclient.py Outdated Show resolved Hide resolved
tests/test_staticfiles.py Outdated Show resolved Hide resolved
Comment on lines 208 to 229
else:
await self.close(code=1008, reason=f"HTTP Response {response.status_code}")
Copy link
Sponsor Member

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?

Copy link
Contributor Author

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.

Copy link
Sponsor Member

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.

Copy link
Sponsor Member

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 🤔

Copy link
Contributor Author

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:

  1. just close the connection(). This is always supported and is the original way of rejecting a connection. Server provides a 403 response.
  2. 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?

Copy link
Sponsor Member

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.

Copy link
Sponsor Member

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.

Copy link
Contributor Author

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.

starlette/websockets.py Outdated Show resolved Hide resolved
@kristjanvalur
Copy link
Contributor Author

Would you like to implement the support for Websocket Denial Response on Uvicorn?

Sure, at least, I can have a look.

starlette/websockets.py Outdated Show resolved Hide resolved
docs/websockets.md Outdated Show resolved Hide resolved
starlette/testclient.py Outdated Show resolved Hide resolved
starlette/websockets.py Outdated Show resolved Hide resolved
@kristjanvalur
Copy link
Contributor Author

kristjanvalur commented Mar 17, 2023

I'm re-adding close_code and close_reason for WebSocketReject, because this is a TestClient class, designed to capture the state when a connection is rejected, either by a close() or a send_response() before the connection is accepted.

The exception then contains the information as provided by the application.

Previously a close() in this situation would raise a WebSocketClose, now it raises a WebSocketReject, a special case with the same information, but allowing the test client application to distinguish this from a regular close.

@kristjanvalur
Copy link
Contributor Author

I've also made sure that the Testclient now delivers the websocket.disconnect to the app, when the app initiates a close, either by doing a websocket.close() while open, or while rejecting a connection.

@kristjanvalur
Copy link
Contributor Author

I guess the only thing here that needs deciding is if websocket.send_response() should default to websocket.close() if there is no extension. IMHO it should, because that's the only other possible thing that can be done (it is the standard, non-extended way of rejecting) and that relieves removes boilerplate logic from the app into Starlette.

But I've stated my case and will do this however you like :)

@kristjanvalur
Copy link
Contributor Author

Would you like to implement the support for Websocket Denial Response on Uvicorn?

Sure, at least, I can have a look.

encode/uvicorn#1916

@Kludex Kludex added this to the Version 1.x milestone Jul 5, 2023
@Kludex Kludex changed the title Support the "websocket.http.response" ASGI extension Support the WebSocket Denial Response ASGI extension Dec 17, 2023
Copy link
Sponsor Member

@Kludex Kludex left a 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.

Comment on lines 221 to 229
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}")
Copy link
Sponsor Member

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()?

Comment on lines 208 to 229
else:
await self.close(code=1008, reason=f"HTTP Response {response.status_code}")
Copy link
Sponsor Member

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.


### Rejecting the connection

Before the calling `websocket.accept()` it is possible to reject the connection,
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Before the calling `websocket.accept()` it is possible to reject the connection,
Before calling `websocket.accept()` it is possible to reject the connection,

docs/websockets.md Outdated Show resolved Hide resolved
Comment on lines 598 to 643
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"}
Copy link
Sponsor Member

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?

Copy link
Contributor Author

@kristjanvalur kristjanvalur left a 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/testclient.py Show resolved Hide resolved
@@ -62,8 +53,6 @@ async def receive(self) -> Message:
)
if message_type == "websocket.disconnect":
self.client_state = WebSocketState.DISCONNECTED
if "code" not in message:
Copy link
Contributor Author

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.


async def receive(self) -> Message:
"""
Receive ASGI websocket messages, ensuring valid state transitions.
"""
if self.app_disconnect_msg is not None:
Copy link
Contributor Author

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.

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}
Copy link
Contributor Author

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

Copy link
Sponsor Member

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.

Copy link
Contributor Author

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().

Copy link
Sponsor Member

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 Show resolved Hide resolved
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.")
Copy link
Contributor Author

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.

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

Copy link

@paulo-raca paulo-raca left a 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 ❤️

def __init__(
self,
status_code: int,
headers: typing.List[typing.Tuple[bytes, bytes]] = [],

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

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.")

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

@@ -181,6 +199,11 @@ async def close(
{"type": "websocket.close", "code": code, "reason": reason or ""}
)

async def send_response(self, response: Response) -> None:

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.

@@ -68,6 +68,18 @@ def __init__(self, session: "WebSocketTestSession") -> None:
self.session = session


class DenialResponse(Exception):

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?

Suggested change
class DenialResponse(Exception):
class WebsocketDenialResponse(Exception):

@Kludex Kludex mentioned this pull request Jan 8, 2024
1 task
@Kludex
Copy link
Sponsor Member

Kludex commented Jan 20, 2024

I've reverted my changes. I'll take your comments in consideration.

@kristjanvalur kristjanvalur force-pushed the kristjan/reject branch 2 times, most recently from 69efd9e to b70cdbd Compare February 3, 2024 15:47
Comment on lines 633 to 649
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"}
Copy link
Sponsor Member

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.

Copy link
Contributor Author

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.

Comment on lines 189 to 197
if not reject:
raise WebSocketDisconnect(
message.get("code", 1000), message.get("reason", "")
)
else:
raise WebSocketDisconnect(
code=message.get("code", 1000),
reason=message.get("reason"),
)
Copy link
Sponsor Member

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

starlette/testclient.py Outdated Show resolved Hide resolved
starlette/testclient.py Outdated Show resolved Hide resolved
@Kludex
Copy link
Sponsor Member

Kludex commented Feb 3, 2024

@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. 👍

@Kludex
Copy link
Sponsor Member

Kludex commented Feb 3, 2024

@paulo-raca Vlw pelo review! 🙏

English: Thanks for the review! 🙏

@Kludex Kludex mentioned this pull request Feb 3, 2024
1 task
@kristjanvalur
Copy link
Contributor Author

Thank you, your commits were fine, will do.

@kristjanvalur kristjanvalur merged commit 93e74a4 into encode:master Feb 4, 2024
5 checks passed
nixroxursox pushed a commit to nixroxursox/starlette that referenced this pull request Mar 18, 2024
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants