summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ui
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py46
1 files changed, 39 insertions, 7 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index d2c3429..21e4445 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -9,7 +9,7 @@ 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
@@ -37,6 +37,28 @@ 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).
+
+ "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
@@ -340,7 +362,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/audit", response_class=HTMLResponse)
async def audit_page():
- return _render_audit_page()
+ return _render_audit_page(state.get("ui_token", ""))
return app
@@ -356,6 +378,7 @@ def _fmt_size(n: int) -> str:
def _render_page(state: dict) -> str:
+ token_js = json.dumps(state.get("ui_token", ""))
status = state.get("status", "starting")
indexes = state.get("indexes", {})
groups_ctx = state.get("groups_ctx", {})
@@ -552,13 +575,14 @@ 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 '
@@ -582,8 +606,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">
@@ -619,7 +646,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">
@@ -648,10 +675,11 @@ 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();
@@ -677,6 +705,10 @@ async function load() {
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;