aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 09:47:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 16:36:54 +0200
commit4cce50f09a73739387d5058a6f8183ebac65ae2c (patch)
tree30e9d72280295d913983910a956b0d3306696ec8 /packages/meshbay-hub/src/meshbay_hub/api
parent9cc2909cb4a360c81b471ceab1d9578a7655a88e (diff)
downloadmeshbay-4cce50f09a73739387d5058a6f8183ebac65ae2c.tar.gz
fix: an empty group claim is a claim on nothing
A node that hosts no groups sends no `group_ids` on its hub socket, and the hub resolved the claim with `set(claimed_groups or authorized)` — so "I host nothing" arrived as "I host every group this account belongs to", other members' included. Such a node can serve none of them: it holds no GEK, and its own handshake refuses them with "Group not hosted on this node". `/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is in-memory, so which node a client was sent to depended on who reconnected first after a hub restart. GroupPage took `nodes[0]` with no fallback. On 2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's unconfigured node won the race, and a group stopped opening for everyone in it with its only real host online throughout. Any member could take one of their groups down, by accident, by leaving an empty node running. Four changes, because no one of them is sufficient: - the hub never widens an absent claim, and `update_groups` goes through the same ceiling as registration — it assigned its list verbatim, so the bound that makes C2 hold at authentication was one message wide - the node states the empty set rather than omitting the field - the refusal carries `not_hosted`, so a client can tell "try the next node" from "you, here, must do something first" - GroupPage walks the list instead of indexing into it The three lines involved date from 13, 20 and 23 August and each is defensible alone. The defect is in the seam, which is where the last two also were: a falsy empty collection must never mean "unspecified". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-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))