summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ui
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
commitc83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch)
treedea71c8e115742beaac5952c8c65481bbc130b07 /packages/meshbay-node/src/meshbay_node/ui
parentee6573c57f721db8550e34e1c1c79c5922c62a4b (diff)
parentd324792d68503109ab99616af6c85ee37045e169 (diff)
downloadmeshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they changed about what this project may claim. Phase 11.5 closed the gap between the documents and the code: the unauthenticated node HTTP API and the TCP transport deleted, one handshake shared by the remaining two transports, mutual authentication, structured admin transcripts, upload confinement, group isolation, revocation that reaches nodes. Six critical and seven high findings closed, bounded, or deferred by decision. The invite redesign closed H3 and M3 — the last open High. The hub was the key directory: an inviter fetched the invitee's key from it and wrapped the group key for whatever came back, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. That lookup is gone. The node holds the group key and wraps it itself, for a key its recipient proves possession of, bound to an account by a one-time code the hub never sees. M3 fell out of the same work: node authority comes from a local roster, never from the hub. Per-node identity cut what remains of C4 down to one operator. A single keypair used to be copied to every node its owner joined; each node now gets its own, so cracking the bundle on one machine yields a key that is a stranger everywhere else — and on that machine, one that unlocks nothing its holder did not already serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or publishing user keys at all. What this project may now say: the hub cannot read your content unless it ships you malicious client code. T3 remains, accepted (D1), and is what the native client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds against, which is the convention this branch exists to keep. Four defects were found by deploying it and using a browser, none by the test suite: a node going deaf on its hub socket, a token that predated group membership, a client reading values before they were assigned, and identity keys a browser held but never re-read. The lessons are recorded in CLAUDE.md. Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair, invite, join, download, stream, second browser, revoke — run against the live deployment on a wiped hub and node.
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py419
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 &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) -> 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} &mdash; <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", "—")} &mdash;
- <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;