diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/revocation.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/revocation.py | 45 |
1 files changed, 42 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index 003e396..7c37a1e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -171,6 +171,41 @@ async def _reject(ws: WebSocket, detail: str, code: int) -> None: await ws.close(code=code) +async def _authorized_groups(user_id: str) -> set[str]: + """The groups this account belongs to — the ceiling on what its nodes may claim. + + Read fresh rather than captured once at registration: a node WebSocket lives + for hours, and a group joined in the meantime has to become claimable through + `update_groups` without reconnecting. + """ + from meshbay_hub.db.engine import get_session_factory + + async with get_session_factory()() as db: + result = await db.execute( + select(GroupMember.group_id).where(GroupMember.user_id == user_id)) + return {gid for (gid,) in result.all()} + + +def _claimable(claimed_groups, authorized: set[str]) -> list[str]: + """What a node actually gets registered for. Two rules. + + A node may only *narrow* the set: `authorized` is the ceiling, or a node + could advertise itself as a source for any group on the hub (finding C2). + + And an empty claim means **no groups**, never "all of them". This used to + read `set(claimed_groups or authorized)`, so a node hosting nothing — which + sends no `group_ids` at all — was registered as a host for every group its + owner belonged to, other people's included. Such a node cannot serve any of + them: it holds no GEK, and its own handshake refuses them with "Group not + hosted on this node". But `/v1/groups/{id}/nodes` returns nodes in + registration order, so once one of them won the reconnection race after a + hub restart it became `nodes[0]` and captured the group's entire client + traffic. Any member could take a group down for everyone, by accident, + merely by leaving an unconfigured node running. + """ + return sorted(authorized & set(claimed_groups or ())) + + async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tuple: """ Resolve a node WS registration against the database. @@ -212,8 +247,7 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup 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) + return claimed_id, _claimable(claimed_groups, authorized) @router.websocket("/v1/nodes/ws") @@ -286,7 +320,12 @@ async def node_websocket(ws: WebSocket): from meshbay_hub.api.signaling import handle_webrtc_answer handle_webrtc_answer(msg) elif msg.get("type") == "update_groups": - new_gids = msg.get("group_ids", []) + # Through the same gate as the registration above. This used to + # assign the message's list verbatim, so the ceiling that makes + # C2 hold at authentication could be stepped over one message + # later: a node had only to reload to claim any group on the hub. + new_gids = _claimable(msg.get("group_ids"), + await _authorized_groups(user_id)) _node_groups[node_id] = new_gids await _mark_hosted(new_gids) log.info("Node %s updated groups: %d", node_id[:8], len(new_gids)) |