diff options
Diffstat (limited to 'packages/meshbay-hub')
3 files changed, 136 insertions, 52 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index f8cae8a..2c0b8db 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -441,6 +441,7 @@ async def notify_incoming( body: IncomingRequest, request: Request, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): """ Signal a node that a client wants to connect (NAT punch coordination). @@ -450,8 +451,18 @@ async def notify_incoming( arbitrary node emit UDP packets to an address of their choosing — a small reflection primitive using someone else's machine. The probe target must now be the caller's own source address. + + Like the offer relay, the caller must share an active group with the node — + checked **before** anything reveals whether the node is connected, so this is + not a liveness oracle a stranger can poll, and a stranger cannot make a node + punch on their behalf. """ from meshbay_hub.api.netutil import client_ip + from meshbay_hub.api.signaling import require_shared_active_group + + # First, and before anything reveals whether the node is connected: a + # stranger cannot poll this for a node's liveness, nor make it punch. + await require_shared_active_group(db, node_id, current_user.id) caller_ip = client_ip(request) if body.peer_ip != caller_ip: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index 60d5e20..fc40204 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -102,6 +102,51 @@ def _take_offer(user_id: str, node_id: str, now: float) -> float | None: return None +async def require_shared_active_group(db: AsyncSession, node_id: str, user_id: str) -> None: + """The caller must share an **active** group with this node, or the node must + host an open group while public groups are on (that path *is* what a public + group means, so it follows the instance switch). Raises 403 with a uniform + message otherwise — the same answer whether the node is a member's or a + stranger's, and whether it is connected or not, so it is not a liveness + oracle for a non-member. + + Membership is read from the connected-node registry, so a node hosting no + group shares one with nobody (AV24, AV1): the empty claim is "no groups", not + "all of its owner's". Both the WebRTC offer relay and the NAT-punch signal + call this, so they gate the same way (H6). + """ + from meshbay_hub.api.revocation import _node_groups + node_group_ids = set(_node_groups.get(node_id, [])) + if not node_group_ids: + raise HTTPException(status_code=403, + detail="Not a member of any group on this node") + shared = [gid for (gid,) in (await db.execute( + select(GroupMember.group_id).where( + GroupMember.user_id == user_id, + GroupMember.group_id.in_(node_group_ids), + ))).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") + return + 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}") + + @router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse) # Per address and per node, and only a coarse guard in front of authentication: # the account's budget above is the limit that means something. 600 because an @@ -143,58 +188,10 @@ async def webrtc_offer( if not ws: raise HTTPException(status_code=404, detail="Node not connected") - # The caller must share at least one active group with the target node, - # OR the node must host at least one open-join group (public groups admit - # anyone — the node's MNP handshake handles authorization). - # - # That second path is exactly what "public groups" means, so it is gated by - # the instance switch: with public groups off, a non-member is not brokered a - # 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, [])) - # 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}") + # The caller must share an active group with the node (or the node must host + # an open group when public groups are on). One implementation, shared with + # the NAT-punch signal (`notify_incoming`), so both gate the same way. + await require_shared_active_group(db, node_id, current_user.id) # Both refusals say when to come back, and transport.js does: a 429 here is # the hub being busy, never the node being down. diff --git a/packages/meshbay-hub/tests/test_incoming_membership.py b/packages/meshbay-hub/tests/test_incoming_membership.py new file mode 100644 index 0000000..708e327 --- /dev/null +++ b/packages/meshbay-hub/tests/test_incoming_membership.py @@ -0,0 +1,76 @@ +"""The NAT-punch signal is not a liveness oracle, and only a member reaches it. + +`POST /v1/nodes/{id}/incoming` used to check nothing but the caller's own +address, then reveal whether the node was connected (404 vs 504) and, with QUIC +on, make it punch. Any authenticated account could poll it for a node's liveness +and make a stranger's node emit a UDP probe. It now requires a shared active +group with the node first — the same gate the offer relay uses — checked before +anything depends on the node's connection state, so a non-member gets one uniform +403 whether the node is connected or not. +""" + +import pytest +from test_availability_between_members import ( + _add_member, + _announce_node, + _make_group, + _make_user, +) + + +def _incoming(client, node_id, user, peer_ip="1.2.3.4", peer_port=5000): + return client.post(f"/v1/nodes/{node_id}/incoming", + json={"peer_ip": peer_ip, "peer_port": peer_port}, + headers={"Authorization": f"Bearer {user['token']}"}) + + +@pytest.mark.asyncio +async def test_a_non_member_is_refused_whether_the_node_is_connected_or_not(client): + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "inc_owner") + stranger = await _make_user(client, "inc_stranger") + group_id = await _make_group(client, owner, "inc-group") + node_id = await _announce_node(client, owner) + + # Node NOT in the connected registry — the stranger gets the membership 403 + # (not the connection 404), so the answer says nothing about whether the node + # is up. The detail is what distinguishes it from the peer_ip refusal that a + # request without the gate would give. + r_off = await _incoming(client, node_id, stranger) + assert r_off.status_code == 403 + assert "member" in r_off.json()["detail"] + + # Node connected and serving the group — the stranger, not a member, still gets + # the membership 403, and never reaches the punch or the connection-state answer. + rev._connected_nodes[node_id] = object() + rev._node_groups[node_id] = [group_id] + try: + r_on = await _incoming(client, node_id, stranger) + assert r_on.status_code == 403 + assert "member" in r_on.json()["detail"] + finally: + rev._connected_nodes.pop(node_id, None) + rev._node_groups.pop(node_id, None) + + +@pytest.mark.asyncio +async def test_a_member_passes_the_membership_gate(client): + """A member is not turned away by the gate. (It then reaches the connection + check — 404 here, since no real node socket is registered — never 403.)""" + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "inc2_owner") + member = await _make_user(client, "inc2_member") + group_id = await _make_group(client, owner, "inc2-group") + await _add_member(client, owner, group_id, member) + node_id = await _announce_node(client, owner) + + rev._node_groups[node_id] = [group_id] # registered/hosted, but no live socket + try: + r = await _incoming(client, node_id, member) + # Past the membership gate: the refusal, if any, is about the connection + # or the peer address, never "not a member of any group on this node". + assert r.status_code != 403 or "member" not in r.json().get("detail", "") + finally: + rev._node_groups.pop(node_id, None) |