From c6505677fd121265ab5cc52276ec9d0c1c73b6c9 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 25 Sep 2026 13:43:47 +0200 Subject: fix(hub): refuse node-scoped tokens on the admin and moderator API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node's authority and a hub role are different notions: what a node may do is decided by its operator's roster pin on the node (NS4), while admin and moderator are hub roles on a person's account, exercised from a browser with a user-scoped token. The scope refusal was wired only onto require_user_scope (group mutation), so require_admin and require_moderator accepted a scope:"node" daemon token whenever the underlying account also held a hub role. On a deployment where the operator is a hub admin and runs a node, the daemon's in-memory token was therefore a full hub-admin credential — able to revoke accounts and groups (signed, broadcast to every node), change instance policy, and read the IP audit log. Factor the refusal into _reject_node_scope(payload) and call it from require_user_scope, require_moderator and require_admin alike, so a node-scoped token is turned away with 403 on every privileged route. test_node_scope_not_admin.py holds seven refusals, each asserting the same account's user-scoped token still gets in; verified red against the pre-fix deps.py and green after. Co-Authored-By: Claude Opus 4.8 --- packages/meshbay-hub/src/meshbay_hub/api/deps.py | 38 ++++- .../meshbay-hub/tests/test_node_scope_not_admin.py | 159 +++++++++++++++++++++ 2 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_node_scope_not_admin.py diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index 501be9d..42f4101 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -59,16 +59,34 @@ async def get_current_user( return user -async def require_user_scope( - payload: dict = Depends(_decode_token), - current_user: User = Depends(get_current_user), -) -> User: - """Reject node-scoped tokens — only browser (user-scope) can mutate groups.""" +def _reject_node_scope(payload: dict) -> None: + """Refuse a node-scoped daemon token on a route meant for a person. + + A node's authority and a hub role are **different notions**. What a node may + do is decided by its operator's roster pin on the node itself (NS4) and by + the node scope's deliberately narrow reach; being an admin or a moderator is + a hub role attached to a person's account. A node daemon authenticates with + the node key and receives a `scope:"node"` token so that the machine can + register, signal and host — never so that it can act as its operator on the + hub. When the operator's account happens to also hold a hub role, that role + is the *person's*, exercised from a browser with a user-scoped token, and + must not be reachable by a token the daemon holds in memory. So the scope + gate lives in one place and fronts every privileged dependency, not only + group mutation. + """ if payload.get("scope") == "node": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Node-scoped token cannot perform this operation — use browser", ) + + +async def require_user_scope( + payload: dict = Depends(_decode_token), + current_user: User = Depends(get_current_user), +) -> User: + """Reject node-scoped tokens — only browser (user-scope) can mutate groups.""" + _reject_node_scope(payload) return current_user @@ -84,8 +102,13 @@ def user_is_moderator(user: User) -> bool: async def require_moderator( + payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: + # A node-scoped daemon token is refused here even for a moderator's own + # account: the hub moderation surface (suspending accounts, reading the IP + # audit log, listing nodes) is the person's, not the machine's. + _reject_node_scope(payload) if not user_is_moderator(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Moderator access required") @@ -93,8 +116,13 @@ async def require_moderator( async def require_admin( + payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: + # Likewise: revoking accounts and groups (signed, broadcast to every node) + # and changing instance policy are administrative acts a person performs + # from a browser, never something a node token may reach. + _reject_node_scope(payload) if not user_is_admin(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") diff --git a/packages/meshbay-hub/tests/test_node_scope_not_admin.py b/packages/meshbay-hub/tests/test_node_scope_not_admin.py new file mode 100644 index 0000000..58cabb1 --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_scope_not_admin.py @@ -0,0 +1,159 @@ +"""A node-scoped daemon token must never reach the hub admin/moderator surface. + +A node's authority and a hub role are different notions (docs/MESHBAY_DESIGN.md +§7.1, NS4): what a node may do is decided by its operator's roster pin on the +node and by the deliberately narrow node scope; being an admin or moderator is a +hub role on a *person's* account, exercised from a browser with a user-scoped +token. When the operator's account also holds a hub role — which is the case on +the reference deployment, where the operator is an admin and runs a node — the +`scope:"node"` token the daemon keeps in memory used to pass `require_admin` and +`require_moderator`, because those checked only the role and not the scope. The +scope gate was wired onto `require_user_scope` (group mutation) alone. + +These are refusals: each asserts the node token is turned away with 403, and the +same account's user token is let through, so the guard is proven to bite on the +scope and not on the account. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_hub.api.deps import set_admin_usernames + + +def _gen_ed25519(): + sk = Ed25519PrivateKey.generate() + pk_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return sk, base64.b64encode(pk_raw).decode() + + +async def _register(client, username): + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", + "auth_key": "k" * 44}) + assert r.status_code == 201 + + +async def _user_login(client, username): + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + assert r.status_code == 200 + return r.json()["access_token"] + + +async def _node_token(client, username, user_token): + """Link a node key for `username` and return a scope:"node" token for it.""" + sk_node, pk_node = _gen_ed25519() + r = await client.put("/v1/users/me/node_key", + json={"pk_node_ed25519": pk_node}, + headers={"Authorization": f"Bearer {user_token}"}) + assert r.status_code == 200 + ts = int(time.time()) + sig = sk_node.sign(f"meshbay:node_auth:{username}:{ts}".encode()) + r = await client.post("/v1/nodes/auth", json={ + "username": username, "timestamp": ts, + "signature": base64.b64encode(sig).decode()}) + assert r.status_code == 200 + data = r.json() + # Confirm we really are holding a node-scoped token. + import jwt as _jwt + assert _jwt.decode(data["access_token"], options={"verify_signature": False} + )["scope"] == "node" + return data["access_token"] + + +async def _admin_with_node(client, username="op_admin_test"): + """An admin account that also runs a node — the reference-deployment case. + + Returns (user_token, node_token) for the same account. + """ + await _register(client, username) + set_admin_usernames([username]) + user_token = await _user_login(client, username) + node_token = await _node_token(client, username, user_token) + return user_token, node_token + + +def _bearer(token): + return {"Authorization": f"Bearer {token}"} + + +# ── require_admin ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_node_token_cannot_reach_admin_stats(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/stats", headers=_bearer(node_token)) + ).status_code == 403 + # The same account, from a browser, is admin and gets in. + assert (await client.get("/v1/admin/stats", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_revoke(client): + """The sharpest one: revoke is signed and broadcast to every node.""" + _, node_token = await _admin_with_node(client) + r = await client.post("/v1/admin/revoke", headers=_bearer(node_token), + json={"target": "group", "target_id": "whatever"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_change_instance_policy(client): + _, node_token = await _admin_with_node(client) + r = await client.patch("/v1/admin/settings", headers=_bearer(node_token), + json={"allow_public_groups": True}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_delete_account(client): + _, node_token = await _admin_with_node(client) + r = await client.delete("/v1/admin/users/some-id", headers=_bearer(node_token)) + assert r.status_code == 403 + + +# ── require_moderator (a wider set of accounts, including the IP audit log) ─── + +@pytest.mark.asyncio +async def test_node_token_cannot_read_ip_audit_log(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/logs", headers=_bearer(node_token)) + ).status_code == 403 + assert (await client.get("/v1/admin/logs", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_list_nodes(client): + _, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/nodes", headers=_bearer(node_token)) + ).status_code == 403 + + +@pytest.mark.asyncio +async def test_a_moderators_node_token_is_also_refused(client): + """A moderator (not admin) who runs a node: the moderation surface is still + the person's, not the machine's.""" + # An admin promotes a second account to moderator, which then links a node. + admin_user_token, _ = await _admin_with_node(client, "boss_admin_test") + await _register(client, "mod_test") + mod_user_token = await _user_login(client, "mod_test") + # promote + import jwt as _jwt + mod_id = _jwt.decode(mod_user_token, options={"verify_signature": False})["sub"] + r = await client.patch(f"/v1/admin/users/{mod_id}", json={"role": "moderator"}, + headers=_bearer(admin_user_token)) + assert r.status_code == 200 + mod_node_token = await _node_token(client, "mod_test", mod_user_token) + # user-scoped moderator token gets in; node-scoped one does not + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_user_token)) + ).status_code == 200 + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_node_token)) + ).status_code == 403 -- cgit v1.2.3