Refuse websocket upgrades with HTTP statuses, not close codes
A close() before accept() carries the HTTP status of the refused
upgrade, not a WebSocket close code: RSGI (granian) uses the value
verbatim as the response status, so 4401 panicked on the invalid status
(InvalidStatusCode) and — granian being built with panic = "abort" —
aborted the worker and took the whole app down. ASGI servers answer 403
regardless, and browsers report a refused handshake as an abnormal close
(1006), so the 4401/4403/4404 codes never reached clients anyway.
Reject 401/404/403 to mirror GET /api/games/{id}, and classify a failed
first connection in the game page through the REST API so players still
see "session expired" / "game no longer available" instead of a retry
loop. Add a regression test asserting every pre-accept rejection uses a
status in the HTTP range.
This commit is contained in:
7 files changed
+162
-35
No files matched your search
+5
-2
@@ -233,8 +233,11 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
|||||||
## WebSocket protocol
|
## WebSocket protocol
|
||||||
|
|
||||||
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
||||||
players are accepted; unauthenticated, unknown-game and non-player
|
players are accepted; the upgrade is refused before the handshake
|
||||||
connections are closed with codes `4401`, `4404` and `4403` respectively.
|
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
|
||||||
|
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
|
||||||
|
a refused handshake as an abnormal close — there is no close code to
|
||||||
|
read.)
|
||||||
|
|
||||||
Server → client messages are JSON objects with a `type`:
|
Server → client messages are JSON objects with a `type`:
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
|||||||
return {"type": "error", "code": code, "message": message}
|
return {"type": "error", "code": code, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
async def _reject(ws: WebSocket, status: int) -> None:
|
||||||
|
"""Refuse a websocket upgrade with the HTTP ``status``.
|
||||||
|
|
||||||
|
Rejections happen before :meth:`WebSocket.accept`, so ``close()``
|
||||||
|
carries the HTTP status of the refused upgrade rather than a
|
||||||
|
WebSocket close code: ASGI servers answer ``403`` per the spec, and
|
||||||
|
RSGI (granian) uses the value verbatim as the response status.
|
||||||
|
Passing a 4xxx close code here would panic granian's RSGI layer on
|
||||||
|
the invalid status — and since granian is built with
|
||||||
|
``panic = "abort"``, that kills the worker and takes the whole app
|
||||||
|
down.
|
||||||
|
"""
|
||||||
|
await ws.close(status)
|
||||||
|
|
||||||
|
|
||||||
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
||||||
engine = platform.registry.require(session.game_type)
|
engine = platform.registry.require(session.game_type)
|
||||||
return {
|
return {
|
||||||
@@ -89,17 +104,17 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
user = auth.get_ws_user(ws)
|
user = auth.get_ws_user(ws)
|
||||||
if user is None:
|
if user is None:
|
||||||
log.debug("websocket %s rejected: no authenticated user", game_id)
|
log.debug("websocket %s rejected: no authenticated user", game_id)
|
||||||
await ws.close(4401)
|
await _reject(ws, 401)
|
||||||
return
|
return
|
||||||
|
|
||||||
session = await platform.game_store.load(game_id)
|
session = await platform.game_store.load(game_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
log.debug("websocket rejected: unknown game %s", game_id)
|
log.debug("websocket rejected: unknown game %s", game_id)
|
||||||
await ws.close(4404)
|
await _reject(ws, 404)
|
||||||
return
|
return
|
||||||
if not session.seated(user.sub):
|
if not session.seated(user.sub):
|
||||||
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
||||||
await ws.close(4403)
|
await _reject(ws, 403)
|
||||||
return
|
return
|
||||||
|
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
|
|||||||
@@ -2,12 +2,46 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from httpx_ws import WebSocketDisconnect, aconnect_ws
|
from httpx_ws import WebSocketDisconnect, aconnect_ws
|
||||||
from httpx_ws.transport import ASGIWebSocketTransport
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
|
from kaya.core import WebSocket, WebSocketMessage
|
||||||
|
|
||||||
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingSocket(WebSocket):
|
||||||
|
"""A socket that only records pre-accept rejections."""
|
||||||
|
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.query_string = ""
|
||||||
|
self.scheme = "ws"
|
||||||
|
self.headers: Mapping[str, Sequence[str]] = {}
|
||||||
|
self.client = ("127.0.0.1", 12345)
|
||||||
|
self.server = ("127.0.0.1", 80)
|
||||||
|
self.close_codes: list[int] = []
|
||||||
|
|
||||||
|
async def accept(self, headers: Any = None) -> None:
|
||||||
|
raise AssertionError("a rejected socket must not be accepted")
|
||||||
|
|
||||||
|
async def receive(self) -> WebSocketMessage:
|
||||||
|
raise AssertionError("a rejected socket must not receive")
|
||||||
|
|
||||||
|
async def send_text(self, data: str) -> None:
|
||||||
|
raise AssertionError("a rejected socket must not send")
|
||||||
|
|
||||||
|
async def send_bytes(self, data: bytes) -> None:
|
||||||
|
raise AssertionError("a rejected socket must not send")
|
||||||
|
|
||||||
|
async def close(self, code: int = 1000) -> None:
|
||||||
|
self.close_codes.append(code)
|
||||||
|
|
||||||
|
async def __anext__(self) -> WebSocketMessage:
|
||||||
|
raise AssertionError("a rejected socket must not receive")
|
||||||
|
|
||||||
|
|
||||||
class WebSocketTest(unittest.TestCase):
|
class WebSocketTest(unittest.TestCase):
|
||||||
@@ -124,7 +158,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
||||||
pass
|
pass
|
||||||
self.assertEqual(4404, caught.exception.code)
|
self.assertEqual(404, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_non_player_is_closed(self) -> None:
|
async def test_non_player_is_closed(self) -> None:
|
||||||
@@ -140,7 +174,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||||
pass
|
pass
|
||||||
self.assertEqual(4403, caught.exception.code)
|
self.assertEqual(403, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_unauthenticated_is_closed(self) -> None:
|
async def test_unauthenticated_is_closed(self) -> None:
|
||||||
@@ -151,7 +185,45 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||||
pass
|
pass
|
||||||
self.assertEqual(4401, caught.exception.code)
|
self.assertEqual(401, caught.exception.code)
|
||||||
|
|
||||||
|
|
||||||
|
class RejectionStatusTest(unittest.TestCase):
|
||||||
|
"""Pre-accept rejections must carry HTTP statuses, not close codes.
|
||||||
|
|
||||||
|
Granian's RSGI layer uses the value passed to ``close()`` before
|
||||||
|
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
|
||||||
|
close code like 4401 is not a valid status and panics the worker
|
||||||
|
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
|
||||||
|
dies). Drive the rejections through the app with a recording socket
|
||||||
|
so the invariant is checked without a real server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
session = GameSession(
|
||||||
|
id="seated-game",
|
||||||
|
game_type="dummy",
|
||||||
|
join_code="SEAT01",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||||
|
)
|
||||||
|
engine = platform.registry.require("dummy")
|
||||||
|
engine.create(session, {})
|
||||||
|
await platform.game_store.save(session)
|
||||||
|
cases = [
|
||||||
|
("unauthenticated", [], "/ws/games/whatever", 401),
|
||||||
|
("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404),
|
||||||
|
("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403),
|
||||||
|
]
|
||||||
|
for label, users, path, expected in cases:
|
||||||
|
ws = RecordingSocket(path)
|
||||||
|
with ws_users(users):
|
||||||
|
await app.handle_websocket(ws)
|
||||||
|
self.assertEqual([expected], ws.close_codes, label)
|
||||||
|
for code in ws.close_codes:
|
||||||
|
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
||||||
pass
|
pass
|
||||||
self.assertEqual(4404, caught.exception.code)
|
self.assertEqual(404, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_non_player_is_closed(self) -> None:
|
async def test_non_player_is_closed(self) -> None:
|
||||||
@@ -146,7 +146,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||||
pass
|
pass
|
||||||
self.assertEqual(4403, caught.exception.code)
|
self.assertEqual(403, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_unauthenticated_is_closed(self) -> None:
|
async def test_unauthenticated_is_closed(self) -> None:
|
||||||
@@ -156,7 +156,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||||
pass
|
pass
|
||||||
self.assertEqual(4401, caught.exception.code)
|
self.assertEqual(401, caught.exception.code)
|
||||||
|
|
||||||
|
|
||||||
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||||
|
|||||||
+26
-9
@@ -87,16 +87,33 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
|||||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
resp.json().await.map(Some).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Why a game connection failed before the first snapshot arrived.
|
||||||
pub async fn game_state(game_id: &str) -> Result<GameView, String> {
|
///
|
||||||
let resp = Request::get(&format!("/api/games/{game_id}"))
|
/// A refused WebSocket handshake is invisible to the browser: the close
|
||||||
.send()
|
/// event reports an abnormal closure (1006) with no HTTP status, so the
|
||||||
.await
|
/// game page asks the REST API to tell a deliberate rejection from a
|
||||||
.map_err(|e| e.to_string())?;
|
/// transient network problem.
|
||||||
if !resp.ok() {
|
pub enum GameEntry {
|
||||||
return Err(server_error(resp.status()));
|
/// Not logged in (`401` from `GET /api/games/{id}`).
|
||||||
|
Unauthenticated,
|
||||||
|
/// Unknown game (`404`) or the user is not seated (`403`).
|
||||||
|
Unavailable,
|
||||||
|
/// The API could not be asked (or answered unexpectedly): treat the
|
||||||
|
/// connection loss as transient and retry.
|
||||||
|
Transient,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a failed game connection via `GET /api/games/{game_id}`,
|
||||||
|
/// mirroring the statuses used to refuse the websocket upgrade.
|
||||||
|
pub async fn probe_game(game_id: &str) -> GameEntry {
|
||||||
|
match Request::get(&format!("/api/games/{game_id}")).send().await {
|
||||||
|
Ok(resp) => match resp.status() {
|
||||||
|
401 => GameEntry::Unauthenticated,
|
||||||
|
403 | 404 => GameEntry::Unavailable,
|
||||||
|
_ => GameEntry::Transient,
|
||||||
|
},
|
||||||
|
Err(_) => GameEntry::Transient,
|
||||||
}
|
}
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||||
|
|||||||
+30
-13
@@ -4,7 +4,9 @@ use std::cell::RefCell;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
use crate::components::card::{card_back, card_img};
|
use crate::components::card::{card_back, card_img};
|
||||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
@@ -135,23 +137,38 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
|||||||
let on_close = {
|
let on_close = {
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
let alive = alive.clone();
|
let alive = alive.clone();
|
||||||
move |code: Option<u16>| {
|
move |_code: Option<u16>| {
|
||||||
if !alive.get() {
|
if !alive.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.closed.set(true);
|
ctx.closed.set(true);
|
||||||
match code {
|
if ctx.game.get_clone().is_none() && ctx.attempts.get() == 0 {
|
||||||
Some(4401) => {
|
// The connection ended before any snapshot arrived. A
|
||||||
ctx.fatal.set(true);
|
// refused handshake looks exactly like a network drop to
|
||||||
ctx.error
|
// the browser (close code 1006, no HTTP status), so ask
|
||||||
.set(Some("Session expired — please log in again.".to_string()));
|
// the API whether the server deliberately rejected us —
|
||||||
}
|
// in which case retrying is pointless — or the loss was
|
||||||
Some(4403) | Some(4404) => {
|
// transient. Only on the first failure: later attempts
|
||||||
ctx.fatal.set(true);
|
// are governed by the retry backoff.
|
||||||
ctx.error
|
let id = id.clone();
|
||||||
.set(Some("This game is no longer available.".to_string()));
|
let alive = alive.clone();
|
||||||
}
|
spawn_local(async move {
|
||||||
_ => schedule_retry(id.clone(), ctx, alive.clone()),
|
match api::probe_game(&id).await {
|
||||||
|
api::GameEntry::Unauthenticated => {
|
||||||
|
ctx.fatal.set(true);
|
||||||
|
ctx.error
|
||||||
|
.set(Some("Session expired — please log in again.".to_string()));
|
||||||
|
}
|
||||||
|
api::GameEntry::Unavailable => {
|
||||||
|
ctx.fatal.set(true);
|
||||||
|
ctx.error
|
||||||
|
.set(Some("This game is no longer available.".to_string()));
|
||||||
|
}
|
||||||
|
api::GameEntry::Transient => schedule_retry(id, ctx, alive),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
schedule_retry(id.clone(), ctx, alive.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-2
@@ -55,8 +55,11 @@ impl GameSocket {
|
|||||||
/// could not be created.
|
/// could not be created.
|
||||||
///
|
///
|
||||||
/// `on_close` fires exactly once when the connection ends; it receives the
|
/// `on_close` fires exactly once when the connection ends; it receives the
|
||||||
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
|
/// server close code when one was sent (e.g. 1000 for a normal closure) or
|
||||||
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
|
/// `None` for an abnormal loss. A *refused* handshake is not distinguishable
|
||||||
|
/// here: browsers report it as an abnormal closure (1006) and never expose
|
||||||
|
/// the HTTP status the server answered with, so callers must classify the
|
||||||
|
/// failure out of band.
|
||||||
pub fn connect(
|
pub fn connect(
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
on_message: impl Fn(ServerMessage) + 'static,
|
on_message: impl Fn(ServerMessage) + 'static,
|
||||||
|
|||||||
Reference in new issue
Block a user