aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 10:43:40 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 10:43:40 +0200
commitab4657789eaca1d88b54e5d5123a0bc71a95e6ce (patch)
tree4588be861beecf7e54d480b8fd30f54afd791fe4 /packages/meshbay-hub/src
parent3ce051e134a432417fcaca4e8b5775d98f614a31 (diff)
downloadmeshbay-ab4657789eaca1d88b54e5d5123a0bc71a95e6ce.tar.gz
fix(hub): authenticate node WebSocket registration
Phase 11.5 — finding C2 (see second-review.md). /v1/nodes/ws took node_id and group_ids straight from the client's first message with no ownership check: node_id = msg.get("node_id") or decoded.get("sub", "unknown") _connected_nodes[node_id] = ws Any registered user could connect with an ordinary browser token, claim a victim node's id and overwrite its entry. Every WebRTC offer for that node was then relayed to the attacker, who answered with their own SDP — full node impersonation. The DTLS channel binding does not help, because the attacker is the endpoint rather than a relay: the browser sends its GEK proof to the attacker, who ignores it and replies handshake_ack. The attacker received the victim's encrypted keypair bundle, chat and uploads, and could serve a forged index. Registration now requires scope == "node", verifies Node.user_id against the token subject, checks the account is active, and refuses to displace a live registration instead of silently overwriting it. group_ids are intersected with the operator's actual membership: a node may narrow the set to what it hosts but cannot widen it, so it cannot advertise itself as an online source for arbitrary groups. Authorization uses a short-lived session rather than Depends(get_db): a node WebSocket lives for hours and a request-scoped dependency would pin a PostgreSQL connection for its whole lifetime. BEHAVIOUR: a node hosting a group whose hub membership was never recorded for the operator's account will stop appearing in GET /v1/groups/{id}/nodes. Adds tests/test_node_ws_auth.py (7 tests). The node WebSocket had no test coverage at all, which is why this went unnoticed. Tests: 109 node, 139 hub+common. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py97
1 files changed, 84 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index c9c59c9..0f1ddba 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -42,7 +42,7 @@ import jwt
from meshbay_hub.auth import hub_public_key_pem, decode_access_token
from meshbay_hub.api.deps import get_current_user, require_admin
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import Group, IPLog, User
+from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
log = logging.getLogger(__name__)
@@ -128,38 +128,109 @@ async def _handle_chat_notify(group_id: str, sender_name: str, sender_user_id: s
log.warning("Chat notify failed: %s", e)
+async def _reject(ws: WebSocket, detail: str, code: int) -> None:
+ await ws.send_text(json.dumps({"type": "error", "detail": detail}))
+ await ws.close(code=code)
+
+
+async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tuple:
+ """
+ Resolve a node WS registration against the database.
+
+ Returns (node_id, group_ids) on success, or (None, error_detail) on refusal.
+ Uses a short-lived session on purpose: a node WebSocket lives for hours, and a
+ request-scoped dependency would pin a PostgreSQL connection for its whole
+ lifetime, exhausting the pool once a handful of nodes connect.
+ """
+ from meshbay_hub.db.engine import get_session_factory
+
+ try:
+ decoded = decode_access_token(token)
+ except Exception as e:
+ return None, str(e)
+
+ if decoded.get("scope") != "node":
+ return None, "Node-scoped token required"
+
+ user_id = decoded.get("sub", "")
+ if not claimed_id:
+ return None, "node_id required"
+
+ async with get_session_factory()() as db:
+ node = await db.get(Node, claimed_id)
+ if node is None or node.user_id != user_id:
+ log.warning("Rejected WS registration for node %s by user %s",
+ claimed_id[:8], (user_id or "?")[:8])
+ return None, "node_id does not belong to this account"
+
+ user = await db.get(User, user_id)
+ if user is None or user.status != "active":
+ return None, "Account not active"
+
+ # Groups come from the database. The node may narrow the set to what it
+ # actually hosts, but it cannot widen it to groups it is not a member of —
+ # otherwise it could advertise itself as a source for any group on the hub.
+ result = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == user_id))
+ authorized = {gid for (gid,) in result.all()}
+
+ claimed = set(claimed_groups or authorized)
+ return claimed_id, sorted(authorized & claimed)
+
+
@router.websocket("/v1/nodes/ws")
async def node_websocket(ws: WebSocket):
"""
Persistent WebSocket connection for nodes.
- Nodes authenticate with a JWT bearer in the first message.
- Hub sends revocation tokens as JSON messages.
+
+ Finding C2: this used to take `node_id` and `group_ids` straight from the
+ client's first message, with no check that the authenticated user owned that
+ node. Any registered user could connect with an ordinary browser token, claim a
+ victim node's id, and overwrite its entry in `_connected_nodes`. Every WebRTC
+ offer for that node was then relayed to the attacker, who answered with their
+ own SDP — a full node impersonation, and the DTLS channel binding does not help
+ because the attacker is the endpoint rather than a relay. The attacker received
+ the victim's encrypted keypair bundle, their chat, and their uploads.
+
+ Identity now comes from the token and the database, never from the message.
"""
await ws.accept()
node_id: str | None = None
try:
- # Auth: expect {"type": "auth", "token": "<jwt>"}
+ # Auth: expect {"type": "auth", "token": "<jwt>", "node_id": "..."}
raw = await ws.receive_text()
msg = json.loads(raw)
if msg.get("type") != "auth" or "token" not in msg:
- await ws.send_text(json.dumps({"type": "error", "detail": "Send auth first"}))
- await ws.close(code=4001)
+ await _reject(ws, "Send auth first", 4001)
return
try:
decoded = decode_access_token(msg["token"])
except Exception as e:
- await ws.send_text(json.dumps({"type": "error", "detail": str(e)}))
- await ws.close(code=4001)
+ await _reject(ws, str(e), 4001)
+ return
+
+ claimed_id = msg.get("node_id") or ""
+
+ # Refuse to displace a live registration rather than silently overwriting it.
+ if claimed_id and claimed_id in _connected_nodes:
+ await _reject(ws, "Node already connected", 4009)
+ return
+
+ resolved_id, result = await _authorize_node_ws(
+ msg["token"], claimed_id, msg.get("group_ids"))
+ if resolved_id is None:
+ await _reject(ws, result, 4003)
return
+ group_ids = result
- node_id = msg.get("node_id") or decoded.get("sub", "unknown")
+ user_id = decoded.get("sub", "")
+ node_id = resolved_id
_connected_nodes[node_id] = ws
- group_ids = msg.get("group_ids", [])
- if group_ids:
- _node_groups[node_id] = group_ids
- log.info("Node WS connected: %s (groups=%d)", node_id[:8], len(group_ids))
+ _node_groups[node_id] = group_ids
+ log.info("Node WS connected: %s (user=%s, groups=%d)",
+ node_id[:8], user_id[:8], len(group_ids))
await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id}))
# Message loop — handle ping, punch_ready, etc.