aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 14:08:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 14:08:32 +0200
commitcfc91e0a424163869c64d30e55d55a53f18a3dbf (patch)
tree04ed3bbec11690f62f1e2a738bf89f4b763332e5 /packages/meshbay-node
parentba45a3c94806f612fa62812e0b36d08b581a2e47 (diff)
downloadmeshbay-cfc91e0a424163869c64d30e55d55a53f18a3dbf.tar.gz
refactor(node): JSON-only control API, Node page absorbs the admin dashboard
Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/audit.py4
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py59
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py727
-rw-r--r--packages/meshbay-node/tests/test_audit.py21
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py6
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py62
7 files changed, 150 insertions, 731 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/audit.py b/packages/meshbay-node/src/meshbay_node/audit.py
index 6346f16..b382107 100644
--- a/packages/meshbay-node/src/meshbay_node/audit.py
+++ b/packages/meshbay-node/src/meshbay_node/audit.py
@@ -104,6 +104,7 @@ class AuditStore:
limit: int = 200,
user_id: str | None = None,
event: str | None = None,
+ offset: int = 0,
) -> list[AuditEntry]:
conditions = ["timestamp > ?"]
params: list = [since]
@@ -114,11 +115,12 @@ class AuditStore:
conditions.append("event = ?")
params.append(event)
params.append(limit)
+ params.append(max(0, offset))
where = " AND ".join(conditions)
cursor = await self._db.execute(
f"SELECT id, timestamp, user_id, username, ip, event, group_id, detail "
- f"FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT ?",
+ f"FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT ? OFFSET ?",
params,
)
rows = await cursor.fetchall()
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 0355615..b1ebd54 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -37,7 +37,7 @@ username = "myusername"
[node]
quic_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access
-ui_port = 18000 # local admin UI (127.0.0.1 only)
+ui_port = 18000 # local control API — JSON, 127.0.0.1 only, token-gated
# One-time codes. An invitation waits for someone to read their messages; an
# operator pairing code is typed during the SSH session that printed it.
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 2130440..056371a 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -12,14 +12,13 @@ Startup sequence:
8. Start QUIC chunk server (LAN / port-forwarded / hub-less direct access)
9. (Phase 11.5: the unauthenticated HTTP file API and the TCP+TLS server were removed)
10. Start hub WebSocket (signaling, revocations, WebRTC offers)
- 11. Start local web UI on node.ui_port (localhost only)
+ 11. Start local control API on node.ui_port (loopback only, token-gated)
12. Run until SIGINT/SIGTERM
Usage:
meshbay-node # interactive password prompt
meshbay-node --config /path # custom config
meshbay-node status # node state + public key (works while stopped)
- meshbay-node ui # print the local admin UI URL
meshbay-node gek-init # initialise the group key (no browser needed)
meshbay-node init # write example config + create keystore
meshbay-node --calibrate-argon2 # benchmark Argon2id, suggest parameters
@@ -201,19 +200,21 @@ class NodeDaemon:
)
log.info("Keys loaded: %s", keys.pk_ed25519_b64[:16])
- # 2. Start admin UI early (so operator can copy node key before hub login)
+ # 2. Start the local control API early (so the operator can read the
+ # node key before hub login). It is JSON-only, loopback-only, and both
+ # the CLI and the desktop client's Node page are its clients.
self._state["pk_node_ed25519"] = keys.pk_ed25519_b64
self._state["config"] = self._config
# Where it came from, so `group add` appends to the file this process
# actually read rather than guessing at the default.
self._state["config_path"] = str(self._config_path)
- # Per-run token for the local admin UI (11.5.3). Not a password: it keeps
+ # Per-run token for the control API (11.5.3). Not a password: it keeps
# other local processes and rebound browser pages out of an API that can
# re-initialise group keys.
ui_token = base64.urlsafe_b64encode(os.urandom(18)).decode().rstrip("=")
self._state["ui_token"] = ui_token
- # Persisted so `meshbay-node ui` can open the browser. Nobody should ever
- # have to copy a token out of a log or a terminal — that is not a workflow.
+ # Persisted so the CLI and the desktop client can read it — nobody
+ # should ever copy a token out of a log or a terminal.
self._config.data_dir.mkdir(parents=True, exist_ok=True)
self._ui_token_file = self._config.data_dir / "ui-token"
self._ui_token_file.write_text(ui_token)
@@ -228,7 +229,7 @@ class NodeDaemon:
)
ui_server = uvicorn.Server(ui_cfg)
self._tasks.append(asyncio.create_task(ui_server.serve()))
- log.info("Admin UI ready — open it with: meshbay-node ui")
+ log.info("Control API on 127.0.0.1:%d", self._config.node.ui_port)
# 3. Hub connection (Ed25519 auth — retries until node key is linked)
hub_cfg = HubConfig(
@@ -415,7 +416,7 @@ class NodeDaemon:
}
if not groups_ctx:
- log.warning("No groups configured yet — admin UI and hub "
+ log.warning("No groups configured yet — the control API and hub "
"connection stay up; attach a group to go live")
# 5. Chat stores (one SQLite DB per group)
@@ -608,7 +609,7 @@ class NodeDaemon:
# authentication, for private groups too — finding C1. Every client path now
# goes through the MNP handshake (JWT + group claim + GEK proof).
- # 10. Update admin UI state (UI already running from step 2)
+ # 10. Update control API state (already running from step 2)
self._state["groups_ctx"] = groups_ctx
self._state["audit_store"] = self._audit_store
self._state["bundle_store"] = self._bundle_store
@@ -908,17 +909,19 @@ class NodeDaemon:
except _httpx.HTTPStatusError as e:
body = e.response.text if hasattr(e.response, 'text') else ''
# Any 401 here needs a human at a browser, and the operator needs
- # this daemon alive to read its public key out of the local admin
- # UI. Exiting would take that UI down and strand them — which is
- # exactly what happened when a node was started before its owner
- # had registered.
+ # this daemon alive to read its public key (via `meshbay-node
+ # status` or the desktop client, both of which query the control
+ # API). Exiting would strand them — which is exactly what
+ # happened when a node was started before its owner had
+ # registered.
if e.response.status_code == 401:
if "No node key" in body:
self._state["status"] = "waiting_for_node_key"
log.warning(
- "Node key not linked. Open the admin UI, copy this "
- "node's key, and paste it in Settings > Link Node on "
- "%s. Retrying in 5s...",
+ "Node key not linked. Get it from `meshbay-node "
+ "status` and paste it in Settings > Link Node on %s "
+ "(the desktop client links it automatically). "
+ "Retrying in 5s...",
self._config.hub.url,
)
else:
@@ -1500,8 +1503,8 @@ def _daemon_api(cfg: Config, path: str, method: str = "GET",
The daemon owns the roster, the hub session and the live group contexts, so
the CLI asks it to act rather than opening its databases behind its back. It
- also means every operator action goes through the same authorization as the
- admin UI (the per-run session token, 11.5.3).
+ also means every operator action goes through the control API's per-run
+ session token (11.5.3), the same gate the desktop client's Node page passes.
"""
import json as _json
import urllib.error
@@ -1611,13 +1614,13 @@ def main() -> None:
parser = argparse.ArgumentParser(description="MeshBay Node daemon")
parser.add_argument("command", nargs="?",
- choices=["init", "reset", "status", "ui", "gek-init",
+ choices=["init", "reset", "status", "gek-init",
"gek", "operator", "member", "group", "file",
"video", "denylist", "stun", "reload",
"restart-daemon", "calibrate-argon2"],
help="init: provision config + keystore | reset: erase all "
"node state | status: node state and keys "
- "| ui: print the admin UI URL | operator pair: pair a "
+ "| operator pair: pair a "
"browser with this node | member list|invite|revoke|unpin "
"| group list|add|remove | gek init|rotate | file list|rm "
"| video rematch: re-resolve TMDB matches for a group's "
@@ -1655,7 +1658,7 @@ def main() -> None:
args = parser.parse_args()
# Query commands print a report; library logging would interleave with it.
- quiet = args.command in ("status", "ui", "gek-init", "gek", "operator",
+ quiet = args.command in ("status", "gek-init", "gek", "operator",
"member", "group", "file", "video", "denylist",
"stun", "reload", "restart-daemon", "reset")
logging.basicConfig(
@@ -1843,7 +1846,6 @@ def main() -> None:
print(f"groups {live.get('group_count', 0)}"
f" files {live.get('total_files', 0)}"
f" peers {live.get('webrtc_peers', 0)}")
- print(f"admin UI meshbay-node ui")
needs = live.get("needs", [])
if needs:
@@ -2331,19 +2333,6 @@ def main() -> None:
print(f"also written to {path}")
return
- if args.command == "ui":
- cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
- token_file = cfg.data_dir / "ui-token"
- if not token_file.exists():
- print("Node does not appear to be running — start it with: meshbay-node")
- sys.exit(1)
- print(f"http://127.0.0.1:{cfg.node.ui_port}"
- f"/?t={token_file.read_text().strip()}")
- print()
- print("The UI listens on loopback only. From another machine:")
- print(f" ssh -L {cfg.node.ui_port}:127.0.0.1:{cfg.node.ui_port} <this-host>")
- return
-
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
if not cfg.hub.username:
print("Error: hub.username not set in config. Run: meshbay-node init")
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 671597f..6fdc78f 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -1,34 +1,25 @@
"""
-MeshBay Node — local administration web UI (localhost:18000).
+MeshBay Node — local control API (loopback, default port 18000).
-FastAPI app providing:
- - Dashboard: node status, connected peers, group overview
- - Groups: file listing, shared directory info
- - Peers: connected WebRTC/QUIC clients
- - Audit log: IP + action log for legal compliance
- - API endpoints for all data (JSON)
+A JSON-only FastAPI app: node status, groups and roots, roster and denylist,
+node settings, connected peers, and the audit log. It is the single control
+plane for the node — the `meshbay-node` CLI and the desktop client's Node page
+are both clients of it. (Chat is served to browsers over MNP/WebRTC, not here.)
-Served only on 127.0.0.1 — not exposed to the network.
-Gated by a per-run session token (11.5.3) — printed at daemon startup.
+Served only on 127.0.0.1 — never network-exposed — and every request is gated
+by a per-run session token (11.5.3) written to `<data_dir>/ui-token`. There is
+no server-rendered UI: the Node page ships in the desktop client (see
+`docs/refactor-node-ui.md`).
"""
import asyncio
-import base64
-import json
import logging
-import time
-from html import escape
-from pathlib import Path
-from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.responses import JSONResponse
-from meshbay_node import __version__
-from meshbay_node import ops
-from meshbay_node.config import DEFAULT_CONFIG_PATH
+from meshbay_node import __version__, ops
from meshbay_node.indexer.indexer import DirectoryIndexer
-from meshbay_common.crypto import generate_gek, wrap_gek_aes
-from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
log = logging.getLogger(__name__)
@@ -50,6 +41,31 @@ def _op(coro):
return run()
+async def _display_names(state: dict) -> tuple[dict[str, str], dict[str, str]]:
+ """(user_id -> username, group_id -> name) for rendering ids a human reads.
+
+ Usernames come from the roster (the node's own record); group names from
+ node.toml. Both are best-effort — a missing entry just leaves the caller
+ with the raw id to shorten.
+ """
+ users: dict[str, str] = {}
+ roster = state.get("roster")
+ if roster:
+ try:
+ for ident in await roster.list_identities():
+ if ident.get("username"):
+ users[ident["user_id"]] = ident["username"]
+ except Exception:
+ pass
+ groups: dict[str, str] = {}
+ config = state.get("config")
+ if config:
+ for g in config.groups:
+ if getattr(g, "id", None):
+ groups[g.id] = g.name
+ return users, groups
+
+
def create_ui_app(state: dict) -> FastAPI:
app = FastAPI(
@@ -62,14 +78,14 @@ def create_ui_app(state: dict) -> FastAPI:
@app.middleware("http")
async def _require_session_token(request, call_next):
"""
- Gate the admin UI behind a per-run token (11.5.3).
+ Gate the control API 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.
+ waiting to be crossed. The token is written to `<data_dir>/ui-token` at
+ startup and accepted as ?t= or the X-MeshBay-Token header.
"""
from fastapi.responses import PlainTextResponse
@@ -84,25 +100,17 @@ def create_ui_app(state: dict) -> FastAPI:
@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.
+ Belt-and-braces for a loopback API that returns only JSON. Since the
+ server no longer renders any HTML (the dashboard was removed
+ 2026-09-01), the response has nothing an injected script could live in
+ — but a DNS-rebound page or a content-sniffing client that manages to
+ treat a body as a document still gets `default-src 'none'`, which
+ forbids every fetch, script, style and frame. `nosniff` stops the
+ sniffing in the first place.
"""
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'"
+ "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
@@ -126,7 +134,6 @@ def create_ui_app(state: dict) -> FastAPI:
if status == "running":
roster = state.get("roster")
if roster:
- from meshbay_node.roster import Roster
members = await roster.list_members()
operators = [m for m in members
if m["role"] == "operator" and m["status"] == "active"]
@@ -239,14 +246,18 @@ def create_ui_app(state: dict) -> FastAPI:
webrtc = state.get("webrtc")
if not webrtc:
return {"peers": []}
+ users, groups = await _display_names(state)
peers = []
for pid, session in list(webrtc._sessions.items()):
from meshbay_node.transport.webrtc_server import _get_remote_ip
+ uid = session._user_id or ""
+ gid = session._group_id or ""
peers.append({
"peer_id": pid,
- "user_id": session._user_id or "",
- "username": session._username or "",
- "group_id": session._group_id or "",
+ "user_id": uid,
+ "username": session._username or users.get(uid, ""),
+ "group_id": gid,
+ "group_name": groups.get(gid, ""),
"remote_ip": session._remote_ip or _get_remote_ip(session._pc),
"state": session._pc.connectionState,
})
@@ -256,60 +267,44 @@ def create_ui_app(state: dict) -> FastAPI:
async def api_audit(
since: float = 0,
limit: int = 200,
+ offset: int = 0,
user_id: str | None = Query(default=None),
event: str | None = Query(default=None),
):
audit = state.get("audit_store")
if not audit:
- return {"entries": []}
- entries = await audit.get_entries(
- since=since, limit=limit, user_id=user_id, event=event)
+ return {"entries": [], "offset": 0, "limit": limit, "has_more": False}
+ limit = max(1, min(limit, 1000))
+ offset = max(0, offset)
+ # Fetch one extra row to know whether a next page exists without a count.
+ rows = await audit.get_entries(
+ since=since, limit=limit + 1, offset=offset,
+ user_id=user_id, event=event)
+ has_more = len(rows) > limit
+ entries = rows[:limit]
+
+ # Legacy rows and pre-handshake events store user_id only; group_id is
+ # never a name. Resolve both for display — no migration, the roster and
+ # node.toml are the node's own records.
+ names, groups = await _display_names(state)
+
return {
+ "offset": offset,
+ "limit": limit,
+ "has_more": has_more,
"entries": [
{
"id": e.id,
"timestamp": e.timestamp,
"user_id": e.user_id,
- "username": e.username,
+ "username": e.username or names.get(e.user_id, ""),
"ip": e.ip,
"event": e.event,
"group_id": e.group_id,
+ "group_name": groups.get(e.group_id, ""),
"detail": e.detail,
}
for e in entries
- ]
- }
-
- @app.get("/api/config")
- async def api_config():
- config = state.get("config")
- if not config:
- return {}
- return {
- "hub_url": config.hub.url,
- "username": config.hub.username,
- "quic_port": config.node.quic_port,
- "ui_port": config.node.ui_port,
- "data_dir": str(config.data_dir),
- "settings": {
- "invite_ttl_hours": config.node.invite_ttl_hours,
- "pair_ttl_hours": config.node.pair_ttl_hours,
- "device_request_ttl_minutes": config.node.device_request_ttl_minutes,
- "max_concurrent_streams": config.node.max_concurrent_streams,
- "transcode_incompatible_video": config.node.transcode_incompatible_video,
- },
- "groups": [
- {
- "id": g.id,
- "name": g.name,
- "roots": [
- {"path": r.path, "name": r.name, "kind": r.kind,
- "upload": r.upload}
- for r in g.roots
- ],
- "visibility": g.visibility,
- }
- for g in config.groups
],
}
@@ -455,574 +450,4 @@ def create_ui_app(state: dict) -> FastAPI:
async def update_node_settings(payload: dict):
return await _op(lambda: ops.set_node_settings(state, payload))
- # ── Chat endpoints ───────────────────────────────────────────────────────
-
- _chat_subscribers: list[WebSocket] = []
-
- @app.get("/api/chat/history")
- async def chat_history(since: float = 0, limit: int = 100):
- chat_store = state.get("chat_store")
- if not chat_store:
- return {"messages": []}
- msgs = await chat_store.get_messages(since=since, limit=limit)
- return {
- "messages": [
- {
- "id": m.id,
- "sender_id": m.sender_id,
- "iteration": m.iteration,
- "timestamp": m.timestamp,
- "thread_id": m.thread_id,
- }
- for m in msgs
- ]
- }
-
- @app.websocket("/ws/chat")
- async def chat_websocket(ws: WebSocket):
- await ws.accept()
- _chat_subscribers.append(ws)
- try:
- while True:
- await ws.receive_text()
- except WebSocketDisconnect:
- pass
- finally:
- _chat_subscribers.remove(ws)
-
- async def broadcast_chat_to_ui(msg: dict) -> None:
- payload = json.dumps(msg)
- dead = []
- for ws in _chat_subscribers:
- try:
- await ws.send_text(payload)
- except Exception:
- dead.append(ws)
- for ws in dead:
- _chat_subscribers.remove(ws)
-
- app.broadcast_chat = broadcast_chat_to_ui
-
- # ── HTML UI ──────────────────────────────────────────────────────────────
-
- @app.get("/", response_class=HTMLResponse)
- async def root():
- # 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(),
- }
- index_cache = state.get("index_cache")
- cache_count = await index_cache.count() if index_cache else None
- return _render_page(state, roster_view, cache_count)
-
- @app.get("/audit", response_class=HTMLResponse)
- async def audit_page():
- return _render_audit_page(state.get("ui_token", ""))
-
return app
-
-
-def _fmt_size(n: int) -> str:
- if n < 1024:
- return f"{n} B"
- if n < 1024 * 1024:
- return f"{n / 1024:.1f} KB"
- if n < 1024 * 1024 * 1024:
- return f"{n / (1024 * 1024):.1f} MB"
- return f"{n / (1024 * 1024 * 1024):.2f} GB"
-
-
-def _render_node_settings(config) -> str:
- if not config:
- return ""
- nd = config.node
- transcode = "on" if nd.transcode_incompatible_video else "off"
- return f"""
- <table style="margin-top:10px">
- <thead><tr><th>Setting</th><th>Value</th></tr></thead>
- <tbody>
- <tr><td>Invitation TTL</td><td>{nd.invite_ttl_hours} hours</td></tr>
- <tr><td>Pairing code TTL</td><td>{nd.pair_ttl_hours} hours</td></tr>
- <tr><td>Device request TTL</td><td>{nd.device_request_ttl_minutes} minutes</td></tr>
- <tr><td>Max concurrent streams</td><td>{nd.max_concurrent_streams}</td></tr>
- <tr><td>Transcode incompatible video</td><td>{transcode}</td></tr>
- </tbody>
- </table>"""
-
-
-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 &lt;username&gt;</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 &lt;username&gt;</code>. They never pass
- through the hub.
- </p>"""
-
-
-def _render_page(state: dict, roster_view: dict | None = None,
- index_cache_count: int | 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", {})
- config = state.get("config")
- webrtc = state.get("webrtc")
- total_files = sum(idx.count for idx in indexes.values())
- peer_count = webrtc.active_peers if webrtc else 0
- status_color = {
- "running": "#22c55e", "error": "#ef4444",
- "waiting_for_node_key": "#f97316",
- }.get(status, "#f59e0b")
-
- # Groups section
- groups_html = ""
- for gid, ctx in groups_ctx.items():
- cfg = None
- if config:
- cfg = next((g for g in config.groups if g.id == gid), None)
- idx = ctx.get("index")
- name = cfg.name if cfg else gid[:8]
- roots = ctx.get("roots")
- # An unavailable root is shown as such rather than hidden: its files are
- # still listed and still in the index, and hiding the root would make a
- # frozen library look deleted — the exact confusion this is meant to
- # prevent.
- shared = ", ".join(
- f"{r.name} → {r.path}" + ("" if r.available else " [UNAVAILABLE]")
- for r in roots
- ) if roots else ""
- vis = cfg.visibility if cfg else "private"
- 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>{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"))
- gek_badge = (
- '<span class="badge" style="background:#22c55e">GEK active</span>'
- if has_gek
- else '<span class="badge" style="background:#ef4444">No GEK</span>'
- )
- gek_label = "Re-wrap GEK for all members" if has_gek else "Initialize GEK"
- gek_color = "#3b82f6" if has_gek else "#22c55e"
- gek_action = f"""
- <div style="margin:10px 0">
- <button onclick="initGEK('{gid}')"
- id="gek-btn-{gid[:8]}"
- style="padding:8px 16px;background:{gek_color};color:#fff;border:none;
- border-radius:6px;cursor:pointer;font-size:0.85em">
- {gek_label}
- </button>
- <span id="gek-status-{gid[:8]}" class="muted" style="margin-left:8px"></span>
- </div>"""
-
- groups_html += f"""
- <div class="card">
- <h3>{escape(str(name))}
- <span class="badge" style="background:#6366f1">{escape(str(vis))}</span>
- {gek_badge}
- </h3>
- <p><b>Directory:</b> <code>{escape(str(shared))}</code></p>
- <p><b>Files:</b> {fcount} &mdash; <b>Total:</b> {_fmt_size(total_size)}</p>
- {gek_action}
- <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>
- <tbody>{file_rows}</tbody>
- </table>
- </details>
- </div>"""
-
- # Peers section
- peers_html = ""
- if webrtc:
- for pid, session in list(webrtc._sessions.items()):
- 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>{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>'
-
- return f"""<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<title>MeshBay Node Admin</title>
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<style>
- :root {{
- --bg: #0f172a; --surface: #1e293b; --border: #334155;
- --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6;
- --green: #22c55e; --red: #ef4444; --yellow: #f59e0b;
- }}
- * {{ box-sizing: border-box; margin: 0; padding: 0; }}
- body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- background: var(--bg); color: var(--text); }}
- .container {{ max-width: 1000px; margin: 0 auto; padding: 20px; }}
- h1 {{ font-size: 1.5em; margin-bottom: 20px; }}
- h2 {{ font-size: 1.2em; margin: 24px 0 12px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }}
- h3 {{ font-size: 1em; margin-bottom: 8px; }}
- .badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px;
- color: #fff; font-size: 0.8em; font-weight: 600; vertical-align: middle; }}
- .stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
- gap: 12px; margin-bottom: 20px; }}
- .stat {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
- padding: 16px; text-align: center; }}
- .stat .value {{ font-size: 1.8em; font-weight: 700; color: var(--accent); }}
- .stat .label {{ font-size: 0.8em; color: var(--muted); margin-top: 4px; }}
- .card {{ background: var(--surface); border: 1px solid var(--border);
- border-radius: 8px; padding: 16px; margin-bottom: 12px; }}
- table {{ width: 100%; border-collapse: collapse; font-size: 0.85em; margin-top: 8px; }}
- th, td {{ padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }}
- th {{ color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase; }}
- code {{ background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }}
- .muted {{ color: var(--muted); font-size: 0.85em; }}
- details summary {{ cursor: pointer; color: var(--accent); font-size: 0.85em; margin-top: 8px; }}
- a {{ color: var(--accent); text-decoration: none; }}
- a:hover {{ text-decoration: underline; }}
- nav {{ display: flex; gap: 16px; margin-bottom: 20px; }}
- nav a {{ padding: 6px 12px; border-radius: 6px; background: var(--surface);
- border: 1px solid var(--border); }}
- nav a:hover {{ background: var(--border); text-decoration: none; }}
- .footer {{ margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--border);
- font-size: 0.8em; color: var(--muted); }}
-</style>
-</head>
-<body>
-<div class="container">
- <h1>MeshBay Node <span class="badge" style="background:{status_color}">{status}</span></h1>
-
- <nav>
- <a href="/">Dashboard</a>
- <a href="/audit">Audit Log</a>
- <a href="/api/status">API</a>
- </nav>
-
- <div class="stats">
- <div class="stat"><div class="value">{len(groups_ctx)}</div><div class="label">Groups</div></div>
- <div class="stat"><div class="value">{total_files}</div><div class="label">Files</div></div>
- <div class="stat"><div class="value">{peer_count}</div><div class="label">Connected Peers</div></div>
- <div class="stat">
- <div class="value" style="font-size:1em;word-break:break-all">{state.get("username", "—")}</div>
- <div class="label">User</div>
- </div>
- </div>
-
- <h2>Connected Peers</h2>
- <table>
- <thead><tr><th>User</th><th>IP</th><th>Group</th><th>State</th></tr></thead>
- <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", "—")}</p>
- <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p>
- {_render_node_settings(config)}
- </div>
-
- <h2>Maintenance</h2>
- <div class="card">
- <p><b>Index cache:</b> <span id="cacheCount">{
- index_cache_count if index_cache_count is not None else "—"
- }</span> path(s) remembered (size/mtime → hash, shared by every group)</p>
- <p class="muted">Removes rows whose path no longer belongs to any group's
- root, or whose file is genuinely gone from a root that is currently
- reachable. Never touches a root that is temporarily unavailable
- (unplugged drive) — that one still needs its full cache back the
- moment it returns.</p>
- <div style="margin:10px 0">
- <button onclick="pruneIndexCache()" id="prune-cache-btn"
- style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;
- border-radius:6px;cursor:pointer;font-size:0.85em">
- Prune stale entries
- </button>
- <span id="prune-cache-status" class="muted" style="margin-left:8px"></span>
- </div>
- </div>
-
- <h2>Link Node to Hub Account</h2>
- <div class="card">
- <p>To connect to your group from a browser, link this node to your hub account.
- Copy the key below and paste it in <b>Settings &gt; Link Node</b> on the hub.</p>
- <div style="margin:12px 0;display:flex;align-items:center;gap:8px">
- <code id="nodeKey" style="flex:1;padding:8px;word-break:break-all;background:var(--border);
- border-radius:4px;font-size:0.9em;user-select:all">{state.get("pk_node_ed25519", "—")}</code>
- <button onclick="navigator.clipboard.writeText(document.getElementById('nodeKey').textContent).then(()=>{{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)}})"
- style="padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:6px;
- cursor:pointer;font-size:0.85em;white-space:nowrap">Copy</button>
- </div>
- <p class="muted">This is the node's Ed25519 public key. It's safe to share — it identifies
- this node but cannot be used to impersonate it.</p>
- </div>
-
- <div class="footer">
- MeshBay Node v{__version__} &mdash; localhost only &mdash;
- <a href="/api/status">status</a> &middot;
- <a href="/api/groups">groups</a> &middot;
- <a href="/api/peers">peers</a> &middot;
- <a href="/api/audit">audit</a> &middot;
- <a href="/api/config">config</a>
- &mdash; auto-refresh 10s
- </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?t=' + TOKEN, {{ method: 'POST' }});
- const data = await resp.json();
- if (resp.ok) {{
- 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 {{
- if (status) status.textContent = data.error || 'Failed';
- if (status) status.style.color = '#ef4444';
- if (btn) btn.disabled = false;
- }}
- }} catch (e) {{
- if (status) status.textContent = 'Error: ' + e.message;
- if (status) status.style.color = '#ef4444';
- if (btn) btn.disabled = false;
- }}
-}}
-async function pruneIndexCache() {{
- const btn = document.getElementById('prune-cache-btn');
- const status = document.getElementById('prune-cache-status');
- if (btn) btn.disabled = true;
- if (status) {{ status.textContent = 'Pruning...'; status.style.color = ''; }}
- try {{
- const resp = await fetch('/api/index-cache/prune?t=' + TOKEN, {{ method: 'POST' }});
- const data = await resp.json();
- if (resp.ok) {{
- if (status) status.textContent = 'Removed ' + data.removed + ', kept ' + data.kept;
- if (status) status.style.color = '#22c55e';
- const count = document.getElementById('cacheCount');
- if (count) count.textContent = data.kept;
- }} else {{
- if (status) status.textContent = data.error || 'Failed';
- if (status) status.style.color = '#ef4444';
- }}
- }} catch (e) {{
- if (status) status.textContent = 'Error: ' + e.message;
- if (status) status.style.color = '#ef4444';
- }} finally {{
- if (btn) btn.disabled = false;
- }}
-}}
-setTimeout(()=>location.reload(), 10000);
-</script>
-</body>
-</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">
-<title>MeshBay Node — Audit Log</title>
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<style>
- :root {
- --bg: #0f172a; --surface: #1e293b; --border: #334155;
- --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6;
- }
- * { box-sizing: border-box; margin: 0; padding: 0; }
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- background: var(--bg); color: var(--text); }
- .container { max-width: 1100px; margin: 0 auto; padding: 20px; }
- h1 { font-size: 1.5em; margin-bottom: 16px; }
- nav { display: flex; gap: 16px; margin-bottom: 20px; }
- nav a { padding: 6px 12px; border-radius: 6px; background: var(--surface);
- border: 1px solid var(--border); color: var(--accent); text-decoration: none; }
- nav a:hover { background: var(--border); }
- .filters { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
- .filters select, .filters input {
- background: var(--surface); color: var(--text); border: 1px solid var(--border);
- padding: 6px 10px; border-radius: 6px; font-size: 0.85em;
- }
- table { width: 100%; border-collapse: collapse; font-size: 0.82em; }
- th, td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--border); }
- th { color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase;
- position: sticky; top: 0; background: var(--bg); }
- .muted { color: var(--muted); }
- #count { margin-bottom: 10px; font-size: 0.85em; color: var(--muted); }
-</style>
-</head>
-<body>
-<div class="container">
- <h1>Audit Log</h1>
- <nav><a id="navHome" href="/">Dashboard</a><a id="navAudit" href="/audit">Audit Log</a></nav>
-
- <div class="filters">
- <select id="eventFilter">
- <option value="">All events</option>
- <option value="handshake">handshake</option>
- <option value="file_download">file_download</option>
- <option value="file_upload">file_upload</option>
- <option value="file_delete">file_delete</option>
- <option value="stream_video">stream_video</option>
- <option value="chat_message">chat_message</option>
- <option value="disconnect">disconnect</option>
- <option value="auth_failed">auth_failed</option>
- </select>
- <input id="userFilter" placeholder="Filter by user..." />
- <select id="limitSelect">
- <option value="100">100 entries</option>
- <option value="500">500 entries</option>
- <option value="1000">1000 entries</option>
- </select>
- </div>
-
- <div id="count"></div>
- <table>
- <thead><tr><th>Time</th><th>Event</th><th>User</th><th>IP</th><th>Group</th><th>Detail</th></tr></thead>
- <tbody id="tbody"></tbody>
- </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 + (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';
- // 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;
-let debounceTimer;
-document.getElementById('userFilter').oninput = function() {
- clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- const val = this.value;
- const rows = document.querySelectorAll('#tbody tr');
- rows.forEach(r => {
- r.style.display = r.textContent.toLowerCase().includes(val.toLowerCase()) ? '' : 'none';
- });
- }, 200);
-};
-load();
-setInterval(load, 15000);
-</script>
-</body>
-</html>"""
diff --git a/packages/meshbay-node/tests/test_audit.py b/packages/meshbay-node/tests/test_audit.py
index b2ed15e..8755be2 100644
--- a/packages/meshbay-node/tests/test_audit.py
+++ b/packages/meshbay-node/tests/test_audit.py
@@ -53,6 +53,27 @@ async def test_filter_by_user(audit):
@pytest.mark.asyncio
+async def test_pagination_newest_first(audit):
+ for i in range(5):
+ await audit.log_event(user_id="u1", event="connect", detail=f"e{i}")
+ # distinct timestamps so ORDER BY is deterministic
+ await audit._db.execute(
+ "UPDATE audit_log SET timestamp = ? WHERE detail = ?",
+ (1000 + i, f"e{i}"))
+ await audit._db.commit()
+
+ page1 = await audit.get_entries(limit=2, offset=0)
+ page2 = await audit.get_entries(limit=2, offset=2)
+ page3 = await audit.get_entries(limit=2, offset=4)
+
+ assert [e.detail for e in page1] == ["e4", "e3"] # newest first
+ assert [e.detail for e in page2] == ["e2", "e1"]
+ assert [e.detail for e in page3] == ["e0"] # last, partial page
+ # offset past the end is empty, not an error
+ assert await audit.get_entries(limit=2, offset=99) == []
+
+
+@pytest.mark.asyncio
async def test_entry_count(audit):
assert await audit.entry_count() == 0
await audit.log_event(user_id="u1", event="connect")
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index d912b43..b676546 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -24,7 +24,6 @@ from meshbay_node import daemon as daemon_mod
# would otherwise stop for a confirmation nobody can type in a test.
VERBS = [
["status"],
- ["ui"],
["group", "list"],
["group", "add"], # missing --dir: usage, then exit
["gek", "init"],
@@ -135,6 +134,11 @@ def test_the_verb_list_here_matches_the_parser():
f"CLI verbs with no dispatch test: {sorted(untested)} — add them to "
f"VERBS above")
+ # The server-rendered admin UI (and its `ui` verb) were removed in
+ # docs/refactor-node-ui.md phase 5. The control API stays; the browser
+ # page does not.
+ assert "ui" not in declared, "the `ui` verb came back"
+
@pytest.mark.parametrize("argv,verb", [
(["reload"], "reload"),
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 725b800..10182d3 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -650,56 +650,34 @@ def test_node_admin_ui_requires_token():
assert client.get("/api/status").status_code == 403
assert client.get("/api/status?t=wrong").status_code == 403
- assert client.get("/api/config?t=wrong").status_code == 403
+ assert client.get("/api/groups?t=wrong").status_code == 403
assert client.get("/api/status?t=secret-token").status_code == 200
assert client.get(
"/api/status", headers={"X-MeshBay-Token": "secret-token"}
).status_code == 200
-def test_admin_ui_escapes_filenames(tmp_path):
+def test_node_control_api_serves_no_html():
"""
- H2: filenames are chosen by any group member and were rendered into the
- localhost admin UI unescaped, giving script execution against an
- unauthenticated admin API.
- """
- from meshbay_node.ui.app import _render_page
-
- payload = '<img src=x onerror="fetch(1)">'
- index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
- index.add_entry(IndexEntry(
- id="0" * 64, name=payload, path="", size=1, type="video", added_at=0,
- ))
-
- html = _render_page({
- "status": "running",
- "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(tmp_path)}},
- "indexes": {"g" * 32: index},
- })
-
- assert payload not in html, "filename rendered unescaped — stored XSS (H2)"
- assert "&lt;img" in html, "filename should appear escaped"
+ H2 was stored XSS in the server-rendered admin dashboard: a member-chosen
+ filename, or a hub-supplied username, landed in an HTML page on the
+ operator's machine unescaped. That dashboard is gone
+ (docs/refactor-node-ui.md phase 5) — the control API is JSON only, so there
+ is no server-side template to inject into. The Node page that replaced it
+ ships in the desktop client and escapes by default (Preact).
-
-def test_admin_ui_escapes_roster_usernames(tmp_path):
- """
- H2 again, for the roster: usernames originate at the hub and land on the
- operator's own admin page, which can re-key groups and read the audit log.
+ This locks the removal in: the HTML routes stay 404, and the render helpers
+ stay deleted so nothing reintroduces a template by importing one.
"""
- from meshbay_node.ui.app import _render_page
+ import meshbay_node.ui.app as ui_app
+ from fastapi.testclient import TestClient
- payload = '<img src=x onerror="fetch(1)">'
- html = _render_page(
- {"status": "running", "groups_ctx": {}, "indexes": {}},
- {
- "identities": {"u1": {"user_id": "u1", "username": payload,
- "pk_ed25519": "AAA", "pinned_at": "now",
- "pinned_via": "code"}},
- "members": [{"group_id": "", "user_id": "u1", "role": "operator",
- "status": "active"}],
- "invites": [],
- },
- )
+ app = ui_app.create_ui_app({"status": "running", "groups_ctx": {},
+ "indexes": {}, "ui_token": "t"})
+ client = TestClient(app)
+ for path in ("/", "/audit", "/dashboard"):
+ assert client.get(f"{path}?t=t").status_code == 404, path
- assert payload not in html, "username rendered unescaped — stored XSS (H2)"
- assert "&lt;img" in html
+ for gone in ("_render_page", "_render_audit_page", "_render_roster",
+ "_AUDIT_HTML"):
+ assert not hasattr(ui_app, gone), f"{gone} came back — HTML surface"