summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md19
-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
5 files changed, 210 insertions, 27 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 4147fe7..68b9fa9 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1601,6 +1601,15 @@ share an active group with the target node**. Otherwise any authenticated user
could make a third party's machine allocate peer connections on demand (**H6**).
The address in a NAT-punch request must match the caller's source address.
+**A node registered for no group shares one with nobody**, and is refused rather
+than exempted. Written as "check membership if the node claims any group", the
+rule skipped itself — membership, group status and the public-group gate
+together — for precisely the node that AV1 made commonplace: the unconfigured
+one, hosting nothing, which is also the one least able to absorb the work. No
+legitimate connection is lost, because such a node refuses the handshake anyway
+(`group_id` is mandatory, **M1**, and a node holding no group key refuses,
+**NS8**); the refusal simply stops happening at the operator's expense.
+
`X-Forwarded-For` is honoured **only from a trusted proxy, rightmost hop** (**M7**),
through one helper so the behaviour is defined in one place — including for the
rate limiter, whose keying otherwise collapses to a single global bucket behind a
@@ -1624,6 +1633,14 @@ staff exempt. Public groups are the ones that cost other people something: they
appear in the directory and are brokered to strangers. The check is at creation
only, which is correct because the update endpoint refuses to change visibility.
+**Which nodes host a group is answered to its members.** For a public group that
+is everyone, which is what public means; for a private one it is the membership
+row and nothing else. Answering any authenticated account — as it did while only
+the public case was checked — hands whoever knows the group id the identities of
+the machines hosting it, and an ex-member knows that id for ever. Nothing needs
+it before joining: an open join writes the membership row first, and an
+invitation registers the invitee's when the code is created.
+
### 7.4 Instance policy
`hub_settings` is a key/value table an admin edits at runtime. It is **instance
@@ -2600,6 +2617,8 @@ had already been asked.
| **AV21** | **A lease is what the node granted, not what the client called it** (§5.5). `tr` was read as a boolean, so any non-empty string skipped the leaseless ceiling and every cap behind it, and a queued transfer was held back only by the honesty of the client waiting in the queue |
| **AV22** | **The node's own controls take no authority from a hub token** (§6.7). `node_status`, `node_settings_set`, `roster_read`, `denylist_read`, `denylist_clear` and `node_reload` were gated on the account id in the JWT, which is the hub's to choose — NS4 and M3 with the check written the other way round. The gate is a proved operator device, which a hub holding no user keys cannot produce |
| **AV23** | **An upload's owner is recorded when the upload ends and applied when the entry is created**, which are different moments (§5.4). Written against the index at the end of the upload it matched nothing, every time, and left every uploaded file owned by nobody — so no member could delete what they had sent |
+| **AV24** | **A node registered for no group is refused signaling, not exempted from it** (§7.2). The membership check was written as "if the node claims any group", so it skipped itself — membership, group status and the public-group gate together — for the node AV1 made commonplace: the unconfigured one, which is also the one least able to absorb the work |
+| **AV25** | **Which nodes host a group is answered to its members** (§7.3). Only the public case checked, so a private group told any authenticated account that knew its id which machines hosted it — and an ex-member knows that id for ever |
### 13.6 Chat design findings
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) ────────────────────────────────────────────────────