aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/revocation.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py45
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))