diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 79 |
1 files changed, 61 insertions, 18 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 31deb66..d2c3429 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -16,6 +16,7 @@ import base64 import json import logging import time +from html import escape from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query @@ -35,6 +36,33 @@ def create_ui_app(state: dict) -> FastAPI: redoc_url=None, ) + @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") @@ -353,12 +381,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")) @@ -382,14 +414,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> @@ -405,10 +437,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>' @@ -625,14 +657,25 @@ async function load() { 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; + })); } document.getElementById('eventFilter').onchange = load; document.getElementById('limitSelect').onchange = load; |