diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 419 |
1 files changed, 341 insertions, 78 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index b4885af..28654df 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -9,13 +9,14 @@ FastAPI app providing: - API endpoints for all data (JSON) Served only on 127.0.0.1 — not exposed to the network. -No authentication required (localhost only). +Gated by a per-run session token (11.5.3) — printed at daemon startup. """ import base64 import json import logging import time +from html import escape from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query @@ -23,6 +24,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ from meshbay_common.crypto import generate_gek, wrap_gek_aes +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR log = logging.getLogger(__name__) @@ -35,6 +37,55 @@ def create_ui_app(state: dict) -> FastAPI: redoc_url=None, ) + @app.middleware("http") + async def _require_session_token(request, call_next): + """ + Gate the admin UI behind a per-run token (11.5.3). + + "localhost only" is weaker than it sounds: any process on the machine can + reach it, and a page in the operator's browser can reach it too via DNS + rebinding. Since this API can re-initialise a group's GEK and read the + audit log, an unauthenticated loopback service is a privilege boundary + waiting to be crossed. The token is printed at startup and accepted as + ?t= or the X-MeshBay-Token header. + """ + from fastapi.responses import PlainTextResponse + + token = state.get("ui_token") + if token: + supplied = (request.query_params.get("t") + or request.headers.get("X-MeshBay-Token")) + if supplied != token: + return PlainTextResponse("Forbidden", status_code=403) + return await call_next(request) + + @app.middleware("http") + async def _security_headers(request, call_next): + """ + Defence in depth behind the escaping fixes for H2. This UI is unauthenticated + on loopback, so script execution here equals full control of the node admin API. + + Note what this does and does not do: the page relies on inline <script>, so + script-src must allow 'unsafe-inline' and CSP therefore does NOT prevent an + injected script from running. Escaping is the actual fix. What CSP buys is + containment — connect-src/img-src/form-action 'self'|'none' stop an injected + script from exfiltrating the audit log or config to an external host. + """ + response = await call_next(request) + response.headers["Content-Security-Policy"] = ( + "default-src 'none'; " + "style-src 'unsafe-inline'; " + "script-src 'unsafe-inline'; " + "connect-src 'self'; " + "img-src 'self' data:; " + "form-action 'none'; " + "frame-ancestors 'none'; " + "base-uri 'none'" + ) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + return response + # ── JSON API ───────────────────────────────────────────────────────────── @app.get("/api/status") @@ -48,7 +99,6 @@ def create_ui_app(state: dict) -> FastAPI: "status": state.get("status", "starting"), "hub_url": state.get("hub_url", ""), "username": state.get("username", ""), - "node_port": state.get("node_port", 0), "quic_port": state.get("quic_port", 0), "endpoint_hint": state.get("endpoint_hint"), "group_count": len(groups_ctx), @@ -154,9 +204,7 @@ def create_ui_app(state: dict) -> FastAPI: return { "hub_url": config.hub.url, "username": config.hub.username, - "node_port": config.node.port, "quic_port": config.node.quic_port, - "http_port": config.node.http_port, "ui_port": config.node.ui_port, "data_dir": str(config.data_dir), "groups": [ @@ -170,11 +218,159 @@ def create_ui_app(state: dict) -> FastAPI: ], } + # ── Operator pairing (localhost only) ────────────────────────────────── + + @app.post("/api/operator/pair") + async def operator_pair(): + """ + Issue a one-time code that pairs a browser as this node's operator. + + The code is the whole point: it binds the operator's browser identity key + to their account without asking the hub, which is what stops a hub from + naming itself node administrator (M3, and the same substitution as H3). + It is returned once and stored only as a hash. + """ + roster = state.get("roster") + user_id = state.get("node_user_id") + if not roster or not user_id: + return JSONResponse({"error": "Node not connected to hub yet"}, 503) + + config = state.get("config") + ttl = (config.node.pair_ttl_hours if config else 24) * 3600 + code = await roster.create_invite( + group_id="", # operator authority is node-wide + user_id=user_id, + role=ROLE_OPERATOR, + created_by="local-cli", + ttl=ttl, + username=(config.hub.username if config else ""), + ) + invites = await roster.list_invites() + expires = next((i["expires_at"] for i in invites + if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "") + return {"code": code, "expires_at": expires, "user_id": user_id} + + @app.get("/api/roster") + async def api_roster(group_id: str = ""): + roster = state.get("roster") + if not roster: + return {"identities": [], "members": [], "invites": []} + return { + "identities": await roster.list_identities(), + "members": await roster.list_members(group_id or None), + "invites": await roster.list_invites(), + } + + @app.post("/api/groups/{group_id}/invites") + async def create_invite(group_id: str, username: str): + """ + Issue an invitation code from the CLI, without a browser. + + The hub is asked for the account id and nothing else — never for a key. + A hub that answered with the wrong account would produce an invite whose + code it never learns, since the code goes to a human out of band. + """ + roster = state.get("roster") + groups_ctx = state.get("groups_ctx", {}) + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if group_id not in groups_ctx: + return JSONResponse({"error": "Group not hosted on this node"}, 404) + + hub = state.get("hub") + if not hub or not hub._session: + return JSONResponse({"error": "Hub not connected"}, 503) + try: + account = await hub.get_user_pubkeys(username) + except Exception as e: + return JSONResponse({"error": f"Unknown user {username!r}: {e}"}, 404) + + config = state.get("config") + ttl = (config.node.invite_ttl_hours if config else 168) * 3600 + code = await roster.create_invite( + group_id=group_id, + user_id=account["user_id"], + role=ROLE_MEMBER, + created_by="local-cli", + ttl=ttl, + username=username, + ) + invites = await roster.list_invites() + expires = next((i["expires_at"] for i in invites + if i["user_id"] == account["user_id"] + and i["group_id"] == group_id), "") + return {"code": code, "expires_at": expires, + "username": username, "user_id": account["user_id"]} + + @app.get("/api/resolve") + async def resolve_user(username: str): + """ + Map a username to an account id for the CLI. + + The roster answers first — it is the node's own record. The hub is the + fallback for identities pinned before invitations carried a name, and for + people admitted through an open-join group. Only an account id comes back; + no key is ever taken from here. + """ + roster = state.get("roster") + if roster: + for ident in await roster.list_identities(): + if ident["username"] == username: + return {"user_id": ident["user_id"], "source": "roster"} + hub = state.get("hub") + if hub and hub._session: + try: + account = await hub.get_user_pubkeys(username) + return {"user_id": account["user_id"], "source": "hub"} + except Exception: + pass + return JSONResponse({"error": f"Unknown user {username!r}"}, 404) + + @app.post("/api/members/{user_id}/revoke") + async def revoke_member(user_id: str, group_id: str): + """ + Stop serving the group key to someone. + + Takes effect on their next connection: the key is wrapped on demand, so + there is no stored bundle left behind that would outlive this. Rotating + the group key is still required — they hold the current one. + """ + roster = state.get("roster") + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if not await roster.set_status(group_id, user_id, "revoked"): + return JSONResponse({"error": "No such member in that group"}, 404) + log.info("Member revoked: user=%s group=%s", user_id[:8], group_id[:8]) + return {"status": "revoked", "user_id": user_id, "group_id": group_id, + "reminder": "rotate the group key: meshbay-node gek-init"} + + @app.post("/api/members/{user_id}/unpin") + async def unpin_member(user_id: str): + """Forget a pinned identity, so the person can pair again with a new key.""" + roster = state.get("roster") + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if not await roster.unpin(user_id): + return JSONResponse({"error": "No such pinned identity"}, 404) + log.info("Identity unpinned: user=%s", user_id[:8]) + return {"status": "unpinned", "user_id": user_id} + # ── GEK initialization (operator only, localhost) ────────────────────── @app.post("/api/groups/{group_id}/gek") async def init_gek(group_id: str): - """Generate GEK, wrap for all group members, store, and activate.""" + """ + Generate the group key and activate it. + + It used to be wrapped here for every member, using public keys fetched from + the hub — which is H3 with the node as the victim instead of the inviter: a + hub answering with its own key was handed the group key by the node itself. + + Nothing is pre-wrapped for members now. Each member's copy is produced when + they connect, for a key they proved they hold (`join_request`). Only the + node's own copy is stored, so the daemon can reload the key across restarts + without the operator's browser. + """ groups_ctx = state.get("groups_ctx", {}) if group_id not in groups_ctx: return JSONResponse({"error": "Group not hosted on this node"}, 404) @@ -187,48 +383,15 @@ def create_ui_app(state: dict) -> FastAPI: if not bundle_store: return JSONResponse({"error": "Bundle store not available"}, 503) - await hub.ensure_fresh_token() - session = hub._session - members_resp = await hub._http.get( - f"/v1/groups/{group_id}/members", - headers=session.auth_headers, - ) - if not members_resp.is_success: - return JSONResponse( - {"error": f"Failed to fetch members: {members_resp.status_code}"}, 502) - members = members_resp.json().get("members", []) - if not members: - return JSONResponse({"error": "No members in group"}, 400) - existing_gek = groups_ctx[group_id].get("gek") gek = existing_gek or generate_gek() + errors: list[str] = [] - wrapped_count = 0 - errors = [] - for member in members: - username = member["username"] - user_id = member["user_id"] - try: - pk_data = await hub.get_user_pubkeys(username) - pk_x_raw = base64.b64decode(pk_data["pk_x25519"]) - bundle = wrap_gek_aes(gek, pk_x_raw) - await bundle_store.store( - group_id, user_id, - bundle["pk_eph_b64"], bundle["nonce_b64"], bundle["wrapped_b64"], - ) - wrapped_count += 1 - log.info("GEK wrapped for %s (%s)", username, user_id[:8]) - except Exception as e: - errors.append(f"{username}: {e}") - log.warning("Failed to wrap GEK for %s: %s", username, e) + roster = state.get("roster") + authorized = len(await roster.list_members(group_id)) if roster else 0 - if wrapped_count == 0: - return JSONResponse( - {"error": "Failed to wrap GEK for any member", "details": errors}, 500) - - # Also store a copy wrapped for the node keystore X25519 key - # so the daemon can reload GEK on restart without the operator's browser keys - config = state.get("config") + # Store a copy wrapped for the node keystore X25519 key so the daemon can + # reload the GEK on restart without the operator's browser keys. node_user_id = hub._session.user_id if hub._session else None pk_x_node_raw = state.get("pk_x25519_raw") if pk_x_node_raw and node_user_id: @@ -239,13 +402,14 @@ def create_ui_app(state: dict) -> FastAPI: node_bundle["pk_eph_b64"], node_bundle["nonce_b64"], node_bundle["wrapped_b64"], ) - log.info("GEK also wrapped for node keystore (daemon reload)") + log.info("GEK wrapped for node keystore (daemon reload)") except Exception as e: + errors.append(f"node keystore: {e}") log.warning("Failed to wrap GEK for node keystore: %s", e) groups_ctx[group_id]["gek"] = gek - log.info("GEK initialized for group %s — wrapped for %d/%d members", - group_id[:8], wrapped_count, len(members)) + log.info("GEK initialized for group %s — %d authorized member(s) will " + "receive it on connect", group_id[:8], authorized) webrtc = state.get("webrtc") if webrtc and "groups" in webrtc._ctx and group_id in webrtc._ctx["groups"]: @@ -254,8 +418,7 @@ def create_ui_app(state: dict) -> FastAPI: return { "status": "ok", "group_id": group_id, - "wrapped_count": wrapped_count, - "total_members": len(members), + "authorized_members": authorized, "errors": errors, } @@ -311,11 +474,21 @@ def create_ui_app(state: dict) -> FastAPI: @app.get("/", response_class=HTMLResponse) async def root(): - return _render_page(state) + # Roster reads are async and the page renderer is not, so gather here. + roster = state.get("roster") + roster_view = None + if roster: + identities = {i["user_id"]: i for i in await roster.list_identities()} + roster_view = { + "identities": identities, + "members": await roster.list_members(), + "invites": await roster.list_invites(), + } + return _render_page(state, roster_view) @app.get("/audit", response_class=HTMLResponse) async def audit_page(): - return _render_audit_page() + return _render_audit_page(state.get("ui_token", "")) return app @@ -330,7 +503,71 @@ def _fmt_size(n: int) -> str: return f"{n / (1024 * 1024 * 1024):.2f} GB" -def _render_page(state: dict) -> str: +def _render_roster(roster_view: dict | None) -> str: + """ + Who this node recognises, and which keys are theirs. + + Every value here is escaped: usernames come from the hub and pass through the + roster, so they are attacker-influenced text on the operator's own admin page + (the H2 rule applies to them exactly as it does to filenames). + """ + if roster_view is None: + return '<p class="muted">Roster unavailable</p>' + + identities = roster_view["identities"] + rows = "" + for m in roster_view["members"]: + ident = identities.get(m["user_id"], {}) + scope = escape(m["group_id"][:8]) if m["group_id"] else "node-wide" + status_color = "#22c55e" if m["status"] == "active" else "#ef4444" + rows += ( + f"<tr><td>{escape(str(ident.get('username') or m['user_id']))}</td>" + f"<td>{escape(str(m['role']))}</td>" + f"<td><span class='badge' style='background:{status_color}'>" + f"{escape(str(m['status']))}</span></td>" + f"<td>{scope}</td>" + f"<td><code>{escape(str(ident.get('pk_ed25519', ''))[:16])}…</code></td>" + f"<td>{escape(str(ident.get('pinned_at', '?')))} " + f"({escape(str(ident.get('pinned_via', '?')))})</td></tr>" + ) + if not rows: + rows = ('<tr><td colspan="6" class="muted">Nobody admitted yet — ' + 'run <code>meshbay-node member invite <username></code></td></tr>') + + invite_rows = "" + for i in roster_view["invites"]: + invite_rows += ( + f"<tr><td><code>{escape(str(i['user_id'])[:16])}</code></td>" + f"<td>{escape(str(i['group_id'][:8] or 'node-wide'))}</td>" + f"<td>{escape(str(i['role']))}</td>" + f"<td>{escape(str(i['expires_at']))}</td></tr>" + ) + invites_html = "" + if invite_rows: + invites_html = f""" + <details style="margin-top:10px"><summary>Pending invitations</summary> + <table> + <thead><tr><th>Account</th><th>Group</th><th>Role</th><th>Expires</th></tr></thead> + <tbody>{invite_rows}</tbody> + </table> + </details>""" + + return f""" + <table> + <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Scope</th> + <th>Identity key</th><th>Pinned</th></tr></thead> + <tbody>{rows}</tbody> + </table> + {invites_html} + <p class="muted" style="margin-top:8px"> + Codes are issued from the CLI: <code>meshbay-node operator pair</code>, + <code>meshbay-node member invite <username></code>. They never pass + through the hub. + </p>""" + + +def _render_page(state: dict, roster_view: dict | None = None) -> str: + token_js = json.dumps(state.get("ui_token", "")) status = state.get("status", "starting") indexes = state.get("indexes", {}) groups_ctx = state.get("groups_ctx", {}) @@ -356,12 +593,16 @@ def _render_page(state: dict) -> str: fcount = idx.count if idx else 0 total_size = sum(e.size for e in idx.entries) if idx else 0 + # Everything interpolated below is attacker-controlled: filenames come from + # uploads by any group member. Rendering them raw was a stored XSS into the + # unauthenticated localhost admin UI, i.e. full control of the node admin API + # from the operator's browser (finding H2). file_rows = "" if idx: for e in sorted(idx.entries, key=lambda x: x.name): file_rows += ( - f"<tr><td>{e.name}</td><td>{e.type}</td>" - f"<td>{_fmt_size(e.size)}</td><td>{e.path or '/'}</td></tr>" + f"<tr><td>{escape(e.name)}</td><td>{escape(e.type)}</td>" + f"<td>{_fmt_size(e.size)}</td><td>{escape(e.path or '/')}</td></tr>" ) has_gek = bool(ctx.get("gek")) @@ -385,14 +626,14 @@ def _render_page(state: dict) -> str: groups_html += f""" <div class="card"> - <h3>{name} - <span class="badge" style="background:#6366f1">{vis}</span> + <h3>{escape(str(name))} + <span class="badge" style="background:#6366f1">{escape(str(vis))}</span> {gek_badge} </h3> - <p><b>Directory:</b> <code>{shared}</code></p> + <p><b>Directory:</b> <code>{escape(str(shared))}</code></p> <p><b>Files:</b> {fcount} — <b>Total:</b> {_fmt_size(total_size)}</p> {gek_action} - <p class="muted">ID: {gid}</p> + <p class="muted">ID: {escape(gid)}</p> <details><summary>File list</summary> <table> <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead> @@ -408,10 +649,10 @@ def _render_page(state: dict) -> str: from meshbay_node.transport.webrtc_server import _get_remote_ip ip = session._remote_ip or _get_remote_ip(session._pc) peers_html += ( - f"<tr><td>{session._username or session._user_id or '—'}</td>" - f"<td>{ip or '—'}</td>" - f"<td>{session._group_id[:8] if session._group_id else '—'}</td>" - f"<td>{session._pc.connectionState}</td></tr>" + f"<tr><td>{escape(session._username or session._user_id or '—')}</td>" + f"<td>{escape(ip or '—')}</td>" + f"<td>{escape(session._group_id[:8] if session._group_id else '—')}</td>" + f"<td>{escape(session._pc.connectionState)}</td></tr>" ) if not peers_html: peers_html = '<tr><td colspan="4" class="muted">No connected peers</td></tr>' @@ -487,14 +728,16 @@ def _render_page(state: dict) -> str: <tbody>{peers_html}</tbody> </table> + <h2>Roster</h2> + {_render_roster(roster_view)} + <h2>Groups</h2> {groups_html or '<p class="muted">No groups configured</p>'} <h2>Node Configuration</h2> <div class="card"> <p><b>Hub:</b> {state.get("hub_url", "—")}</p> - <p><b>QUIC port:</b> {state.get("quic_port", "—")} — - <b>TCP port:</b> {state.get("node_port", "—")}</p> + <p><b>QUIC port:</b> {state.get("quic_port", "—")}</p> <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p> </div> @@ -524,17 +767,18 @@ def _render_page(state: dict) -> str: </div> </div> <script> +const TOKEN = {token_js}; async function initGEK(groupId) {{ const btn = document.getElementById('gek-btn-' + groupId.slice(0,8)); const status = document.getElementById('gek-status-' + groupId.slice(0,8)); if (btn) btn.disabled = true; if (status) status.textContent = 'Initializing...'; try {{ - const resp = await fetch('/api/groups/' + groupId + '/gek', {{ method: 'POST' }}); + const resp = await fetch('/api/groups/' + groupId + '/gek?t=' + TOKEN, {{ method: 'POST' }}); const data = await resp.json(); if (resp.ok) {{ - if (status) status.textContent = 'GEK initialized — wrapped for ' - + data.wrapped_count + '/' + data.total_members + ' members'; + if (status) status.textContent = 'GEK initialized — ' + + data.authorized_members + ' authorized member(s) get it on connect'; if (status) status.style.color = '#22c55e'; setTimeout(() => location.reload(), 2000); }} else {{ @@ -554,8 +798,11 @@ setTimeout(()=>location.reload(), 10000); </html>""" -def _render_audit_page() -> str: - return """<!DOCTYPE html> +def _render_audit_page(token: str = "") -> str: + return _AUDIT_HTML.replace("__TOKEN__", json.dumps(token)) + + +_AUDIT_HTML = """<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> @@ -591,7 +838,7 @@ def _render_audit_page() -> str: <body> <div class="container"> <h1>Audit Log</h1> - <nav><a href="/">Dashboard</a><a href="/audit">Audit Log</a></nav> + <nav><a id="navHome" href="/">Dashboard</a><a id="navAudit" href="/audit">Audit Log</a></nav> <div class="filters"> <select id="eventFilter"> @@ -620,23 +867,39 @@ def _render_audit_page() -> str: </table> </div> <script> +const TOKEN = __TOKEN__; async function load() { const ev = document.getElementById('eventFilter').value; const limit = document.getElementById('limitSelect').value; - let url = '/api/audit?limit=' + limit; + let url = '/api/audit?limit=' + limit + (TOKEN ? '&t=' + TOKEN : ''); if (ev) url += '&event=' + ev; const r = await fetch(url); const data = await r.json(); const tbody = document.getElementById('tbody'); document.getElementById('count').textContent = data.entries.length + ' entries'; - tbody.innerHTML = data.entries.map(e => { - const t = new Date(e.timestamp * 1000).toLocaleString(); - return '<tr><td>' + t + '</td><td>' + e.event + '</td><td>' - + (e.username || e.user_id.slice(0,8)) + '</td><td>' - + (e.ip || '—') + '</td><td>' - + (e.group_id ? e.group_id.slice(0,8) : '—') + '</td><td>' - + (e.detail || '') + '</td></tr>'; - }).join(''); + // textContent, not innerHTML: e.detail carries filenames chosen by group members + // (finding H2). Building this row with string concatenation was a stored XSS. + tbody.replaceChildren(...data.entries.map(e => { + const tr = document.createElement('tr'); + const cells = [ + new Date(e.timestamp * 1000).toLocaleString(), + e.event, + e.username || (e.user_id || '').slice(0, 8), + e.ip || '—', + e.group_id ? e.group_id.slice(0, 8) : '—', + e.detail || '', + ]; + for (const value of cells) { + const td = document.createElement('td'); + td.textContent = value; + tr.appendChild(td); + } + return tr; + })); +} +for (const [id, href] of [['navHome','/'],['navAudit','/audit']]) { + const el = document.getElementById(id); + if (el && TOKEN) el.href = href + '?t=' + TOKEN; } document.getElementById('eventFilter').onchange = load; document.getElementById('limitSelect').onchange = load; |