Skip to content

Commit 5d7d075

Browse files
Return a clean 403 for unauthenticated WebSocket connections
Bokeh's WSHandler.open is wrapped with @authenticated, which redirects unauthenticated requests to the login page. A WebSocket upgrade cannot be redirected, so after the handshake this raised an uncaught RuntimeError ('Method not supported for Web Sockets'). Reject unauthenticated WebSocket upgrades with a clean 403 in prepare() (which runs before the handshake) instead. Refs #8634. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b4ddd08 commit 5d7d075

2 files changed

Lines changed: 39 additions & 0 deletions

File tree

panel/io/server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,21 @@ async def get(self, *args, **kwargs) -> None:
751751

752752
class WSHandler(BkWSHandler):
753753

754+
async def prepare(self):
755+
await super().prepare()
756+
# A WebSocket upgrade cannot be redirected to a login page, so the
757+
# @authenticated check on Bokeh's open() (which runs after the
758+
# handshake) raises an uncaught RuntimeError ("Method not supported for
759+
# Web Sockets") for unauthenticated connections. Reject the handshake
760+
# cleanly with a 403 here instead, before it is performed.
761+
if self.current_user is None:
762+
logger.debug(
763+
"Rejecting unauthenticated WebSocket connection to %r with 403.",
764+
self.request.path
765+
)
766+
self.set_status(403)
767+
self.finish()
768+
754769
def open(self, *args, **kwargs):
755770
_set_request_route_context(self, *args, suffix='/ws', **kwargs)
756771
return super().open()

panel/tests/test_server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,30 @@ def test_server_doc_page_is_not_cached():
167167
assert r.status_code == 200
168168
assert r.headers.get('Cache-Control') == 'no-store'
169169

170+
def test_unauthenticated_websocket_returns_403(port):
171+
# An unauthenticated WebSocket upgrade cannot be redirected to a login page,
172+
# so it must be rejected with a clean 403 rather than raising server-side.
173+
import asyncio
174+
175+
import websockets
176+
177+
html = Markdown('# Title')
178+
serve_and_wait(
179+
html, port=port, basic_auth="secret-password", cookie_secret="cookie-secret"
180+
)
181+
182+
async def _connect():
183+
url = f"ws://localhost:{port}/ws"
184+
try:
185+
async with websockets.connect(
186+
url, subprotocols=["bokeh", "dummy-token"], open_timeout=10
187+
):
188+
return None # handshake unexpectedly succeeded
189+
except websockets.InvalidStatus as e:
190+
return e.response.status_code
191+
192+
assert asyncio.run(_connect()) == 403
193+
170194
def test_server_static_dirs_index():
171195
html = Markdown('# Title')
172196

0 commit comments

Comments
 (0)