Skip to content

WebSocket on_message crashes / silently drops on malformed JSON shapes (null, bare array, unknown type) #805

Description

@paddymul

Summary

DataStreamHandler.on_message in buckaroo/server/websocket_handler.py:38 does msg = json.loads(message) then immediately msg.get("type") without checking msg is a dict. JSON shapes other than a dict (null, [1,2,3], scalars) hit AttributeError on the .get call. Tornado swallows the exception and closes the underlying stream; the WS is dead for the rest of the connection.

Adjacent: unknown type values (type: 42, missing type, empty {}) are silently dropped — no response, no log, client times out waiting.

Findings from WS fuzz (smorg/post-785-playground)

input result
"not json" ✓ structured {"type": "error", "error_code": "invalid_json"} returned
null 💥 WS closed; subsequent reads fail
[1, 2, 3] ⚠ silently dropped (AttributeError, but stream survived this time)
{} ⏱ silently dropped (no branch matches, return)
{"type": 42} ⏱ silently dropped
{"type": {"a": 1}} ⏱ silently dropped
{"payload": "x"} (no type) ⏱ silently dropped
{"type": "buckaroo_invented_command"} ⏱ silently dropped

The state_change handler does handle malformed new_state shapes (returns {"type": "error", "error_code": "state_change_error"}) — but the prior layer (on_message itself) lets bare types through to that handler unchecked.

Why it matters

  • bare_null: a single ws.send("null") from any client (or a buggy serialiser that produces null for None) kills the entire WS session permanently. Easy hostile DoS — close the WS by sending one literal null.
  • silent drops: malformed clients have no way to debug why their messages produce no response. The state_change-on-lazy-mode bug (Lazy mode silently drops buckaroo_state_change → WS client hangs forever #793) is a structurally identical issue.
  • inconsistent surface: some malformed inputs trigger errors, some are silently dropped, one kills the connection. Hard to write a stable client.

Suggested fix

A small guard at the top of on_message:

def on_message(self, message):
    try:
        msg = json.loads(message)
    except (json.JSONDecodeError, TypeError):
        self.write_message(json.dumps({"type": "error",
            "error_code": "invalid_json", "message": "Invalid JSON"}))
        return
    if not isinstance(msg, dict):
        self.write_message(json.dumps({"type": "error",
            "error_code": "invalid_message_shape",
            "message": f"WS messages must be JSON objects; got {type(msg).__name__}"}))
        return

    msg_type = msg.get("type")
    if msg_type == "infinite_request":
        ...
    elif msg_type == "buckaroo_state_change":
        ...
    else:
        # Pre-fix this fell through silently.
        self.write_message(json.dumps({"type": "error",
            "error_code": "unknown_message_type",
            "message": f"Unknown WS message type: {msg_type!r}"}))

Test plan

  • Failing-test commit: a WS test that sends each of the 8 malformed shapes above and asserts the server returns a structured error frame (not a crash, not a silent drop, not a hang).
  • Fix commit: the guards above.
  • Regression: existing test_load_expr.py tests pass; the WS-level happy paths are unchanged.

Found via

WS protocol fuzz suite on smorg/post-785-playground stress run.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions