summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py69
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py123
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py12
4 files changed, 191 insertions, 27 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index b903fcf..72ba194 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -124,7 +124,19 @@ async def group_online_nodes(
# But when the hub has public groups switched off, an existing one keeps
# working only for the people already in it: no non-member gets handed a
# node to connect to. Members always have a row here, so they are unaffected.
- if group.visibility == "public" and not await hub_settings.public_groups_allowed(db):
+ #
+ # A **private** group hands its node list to members and to nobody else. It
+ # used to answer any authenticated account that knew the id — which an
+ # ex-member knows for ever — with the ids and public keys of the machines
+ # hosting it. That is the "registered hub user with no membership" of §2.1
+ # reaching an endpoint that did not check membership, and §7.4 already
+ # states the property for the public case: a non-member is handed no node.
+ #
+ # Nothing legitimate needs this before joining. An open join writes the
+ # membership row first (`POST /{id}/join`), and an invitation registers the
+ # invitee's membership when the code is created — so by the time either asks
+ # for a node, the row exists.
+ if group.visibility != "public" or not await hub_settings.public_groups_allowed(db):
if not await db.get(GroupMember, (group_id, current_user.id)):
raise HTTPException(status_code=403, detail="Not a member of this group")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index b4be3f2..bc03b45 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -103,32 +103,49 @@ async def webrtc_offer(
# connection to a node just because it happens to host an open group. Members
# of that group are unaffected — they match `shared` below.
node_group_ids = set(_node_groups.get(node_id, []))
- if node_group_ids:
- result = await db.execute(
- select(GroupMember.group_id).where(
- GroupMember.user_id == current_user.id,
- GroupMember.group_id.in_(node_group_ids),
- ))
- shared = [gid for (gid,) in result.all()]
- if not shared:
- has_open = None
- if await hub_settings.public_groups_allowed(db):
- has_open = (await db.execute(
- select(Group.id).where(
- Group.id.in_(node_group_ids),
- Group.join_policy == "open",
- Group.status == "active",
- ))).first()
- if not has_open:
- raise HTTPException(status_code=403, detail="Not a member of any group on this node")
- else:
- statuses = set((await db.execute(
- select(Group.status).where(Group.id.in_(shared)))).scalars().all())
- if "active" not in statuses:
- # Report the strongest state present — "revoked" is the signed,
- # node-enforced one; "suspended" is the reversible hub flag.
- state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
- raise HTTPException(status_code=403, detail=f"Group is {state}")
+ # A node registered for no group shares no group with anybody, which is this
+ # check's own answer — and `if node_group_ids:` used to skip the whole thing,
+ # membership, group status and the public-group gate together. Since AV1 made
+ # an empty claim mean "no groups" rather than "all of my owner's", that is
+ # the *normal* registration of a node hosting nothing: exactly the
+ # unconfigured node left running that took a group down on 2026-09-11. So the
+ # machine least able to defend itself was the one any authenticated account
+ # could make allocate a peer connection and gather ICE, which is H6 restored
+ # in the one case AV1 made common.
+ #
+ # Nothing legitimate is lost by refusing here: a browser cannot complete a
+ # handshake with such a node anyway — `group_id` is mandatory (M1) and a node
+ # holding no group key refuses outright (NS8) — so this only declines work
+ # the node would decline one step later, at its own expense.
+ if not node_group_ids:
+ raise HTTPException(status_code=403,
+ detail="Not a member of any group on this node")
+
+ result = await db.execute(
+ select(GroupMember.group_id).where(
+ GroupMember.user_id == current_user.id,
+ GroupMember.group_id.in_(node_group_ids),
+ ))
+ shared = [gid for (gid,) in result.all()]
+ if not shared:
+ has_open = None
+ if await hub_settings.public_groups_allowed(db):
+ has_open = (await db.execute(
+ select(Group.id).where(
+ Group.id.in_(node_group_ids),
+ Group.join_policy == "open",
+ Group.status == "active",
+ ))).first()
+ if not has_open:
+ raise HTTPException(status_code=403, detail="Not a member of any group on this node")
+ else:
+ statuses = set((await db.execute(
+ select(Group.status).where(Group.id.in_(shared)))).scalars().all())
+ if "active" not in statuses:
+ # Report the strongest state present — "revoked" is the signed,
+ # node-enforced one; "suspended" is the reversible hub flag.
+ state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
+ raise HTTPException(status_code=403, detail=f"Group is {state}")
if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER:
raise HTTPException(status_code=429, detail="Too many pending connections")
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index 282169d..bfbfcc0 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -518,3 +518,126 @@ async def test_a_list_cannot_be_asked_for_the_whole_table(client):
r = await client.get("/v1/notifications?limit=20", headers=headers)
assert r.status_code == 200, r.text
+
+
+# ── A node that hosts nothing is not a free target ──────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_node_hosting_nothing_is_not_brokered_to_a_stranger(client):
+ """
+ Signaling read its membership check as `if node_group_ids:` — so when the
+ set was empty, the membership check, the group-status check and the
+ public-group gate were all skipped and the offer was relayed.
+
+ Since AV1, an empty claim is the *normal* registration of a node that hosts
+ nothing: the unconfigured node left running, which is the machine in this
+ register's founding incident. Each offer makes it allocate an
+ `RTCPeerConnection` and gather ICE, which is finding H6 restored in exactly
+ the case AV1 made common — and the stranger paying nothing for it.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "av_sig_owner")
+ stranger = await _make_user(client, "av_sig_stranger")
+ node_id = await _announce_node(client, owner)
+
+ class _FakeWS:
+ def __init__(self):
+ self.sent = []
+
+ async def send_text(self, text):
+ self.sent.append(text)
+
+ ws = _FakeWS()
+ rev._connected_nodes[node_id] = ws
+ rev._node_groups[node_id] = [] # hosts nothing, as in the incident
+ try:
+ r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
+ json={"sdp": "v=0\r\noffer", "ice_candidates": []},
+ headers={"Authorization": f"Bearer {stranger['token']}"})
+ assert r.status_code == 403, r.text
+ assert not ws.sent, (
+ "the node was made to negotiate for someone with no group on it")
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_a_member_still_reaches_the_node_they_share_a_group_with(client):
+ """The other half, so the test above is about the claim and not about
+ refusing everyone."""
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "av_sig_owner2")
+ member = await _make_user(client, "av_sig_member2")
+ group_id = await _make_group(client, owner, "shared-one")
+ await _add_member(client, owner, group_id, member)
+ node_id = await _announce_node(client, owner)
+
+ answered = []
+
+ class _AnsweringWS:
+ async def send_text(self, text):
+ import json as _json
+ from meshbay_hub.api.signaling import handle_webrtc_answer
+ msg = _json.loads(text)
+ answered.append(msg)
+ handle_webrtc_answer({"peer_id": msg["peer_id"], "sdp": "v=0\r\nanswer",
+ "ice_candidates": []}, node_id)
+
+ rev._connected_nodes[node_id] = _AnsweringWS()
+ rev._node_groups[node_id] = [group_id]
+ try:
+ r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
+ json={"sdp": "v=0\r\noffer", "ice_candidates": []},
+ headers={"Authorization": f"Bearer {member['token']}"})
+ assert r.status_code == 200, r.text
+ assert answered
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+# ── A private group's hosts are not public knowledge ────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_private_groups_node_list_is_for_its_members(client):
+ """
+ `GET /v1/groups/{id}/nodes` checked membership only for a public group with
+ public groups switched off. A private group answered any authenticated
+ account that knew the id — which an ex-member knows for ever — with the ids
+ and public keys of the machines hosting it.
+
+ §7.4 already states the property for the public case: a non-member is handed
+ no node to connect to. This is that sentence, for the groups the whole
+ design optimises for.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "av_nodes_owner")
+ member = await _make_user(client, "av_nodes_member")
+ stranger = await _make_user(client, "av_nodes_stranger")
+ group_id = await _make_group(client, owner, "private-hosts")
+ await _add_member(client, owner, group_id, member)
+ node_id = await _announce_node(client, owner)
+
+ rev._node_groups[node_id] = [group_id]
+ rev._connected_nodes[node_id] = object()
+ try:
+ for who in (owner, member):
+ r = await client.get(
+ f"/v1/groups/{group_id}/nodes",
+ headers={"Authorization": f"Bearer {who['token']}"})
+ assert r.status_code == 200, r.text
+ assert [n["node_id"] for n in r.json()["nodes"]] == [node_id]
+
+ r = await client.get(
+ f"/v1/groups/{group_id}/nodes",
+ headers={"Authorization": f"Bearer {stranger['token']}"})
+ assert r.status_code == 403, (
+ "a stranger who knows the group id learned which machines host it: "
+ + r.text)
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index 5bb6af9..73f7fea 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -682,6 +682,17 @@ async def test_webrtc_signaling_roundtrip(client, app):
fake_ws = FakeWS(node_id)
_connected_nodes[node_id] = fake_ws
+ # The caller must share an active group with the node, which is what this
+ # test used to get away without: a node registered for no group skipped the
+ # membership check entirely, so this exercised the relay through the hole
+ # rather than through the door. Registering the group is what a real node
+ # does on its socket.
+ from meshbay_hub.api.revocation import _node_groups
+ r = await client.post("/v1/groups", json={"name": "sig-group"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 201, r.text
+ _node_groups[node_id] = [r.json()["group_id"]]
+
try:
r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
json={"sdp": "v=0\r\noffer-sdp", "ice_candidates": []},
@@ -693,6 +704,7 @@ async def test_webrtc_signaling_roundtrip(client, app):
assert "peer_id" in data
finally:
_connected_nodes.pop(node_id, None)
+ _node_groups.pop(node_id, None)
# ── IP log cleanup (8.9) ────────────────────────────────────────────────────