diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-13 21:18:23 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-13 21:18:23 +0200 |
| commit | dd7d9c80baafe08287516fd4d486223b5199b102 (patch) | |
| tree | 5a9fd048b86d6d8794caa6d82ae4d1d7cdf4d327 | |
| parent | 729d28030746bb8b3688f85833c228acd304be61 (diff) | |
| download | meshbay-dd7d9c80baafe08287516fd4d486223b5199b102.tar.gz | |
fix(hub): a node that hosts nothing is not a free target, and a private group's hosts are its members'
Two checks that were not performed, in the same shape: something true of the
public case, written so it did not apply to the rest.
**Signaling.** The membership check read `if node_group_ids:`, so an empty set
skipped it — membership, group status and the public-group gate together — and
the offer was relayed. 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: the unconfigured node left running, the machine in this register's
founding incident, and the one least able to absorb the work. Each offer makes
it allocate an RTCPeerConnection and gather ICE, 30 a minute, which is H6
restored in exactly the case AV1 made common. It is refused now. Nothing
legitimate is lost: such a node refuses the handshake anyway — `group_id` is
mandatory (M1) and a node with no group key refuses (NS8) — so this declines
work the node would decline one step later at its own expense.
**The node list.** `GET /v1/groups/{id}/nodes` checked membership only for a
public group with public groups switched off. A private one 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 stated the
property for the public case. Membership is required now unless the group is
public and public groups are on. Nothing needs the list before joining: an open
join writes the membership row first, and an invitation registers the invitee's
when the code is created, both checked before making the change.
`test_webrtc_signaling_roundtrip` had to be repaired, and is the finding
restated: it registered a node in `_connected_nodes` and never in
`_node_groups`, so it drove the relay through the hole rather than through the
door — the check it routed around was the defect. It now registers the group a
real node registers.
§7.2 and §7.3 gain the rules, the register AV24 and AV25, and
test_availability_between_members.py two cases that fail on the property
against the previous source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 19 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 14 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/signaling.py | 69 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_availability_between_members.py | 123 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 12 |
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) ──────────────────────────────────────────────────── |