From dd546a12ed630191b86ef200f067b66555c4f3bd Mon Sep 17 00:00:00 2001 From: Adam Gessaman Date: Wed, 23 Sep 2026 20:47:58 -0700 Subject: [PATCH] feat(web_viewer): split anonymous read access from an admin session (#300) * feat(web_viewer): split anonymous read access from an admin session When web_viewer_password is set, Dashboard, Contacts, and Mesh Graph are readable without logging in, along with the read-only APIs they need. Radio, Config, Logs, every mutating API, /api/channels, and the live Socket.IO streams require an admin session, since those can expose channel keys and decrypted traffic. The Realtime page still renders publicly but shows "Admin login required" instead of connecting. Anything not on the public allowlist fails closed. Login now compares HMAC digests with hmac.compare_digest and clears the session before setting authenticated_admin. Logout clears the whole session and returns to the dashboard. Co-authored-by: Gerard Hickey * fix(web_viewer): stop Contacts redeclaring IS_ADMIN and hide banner admin buttons contacts.html declared its own top-level const IS_ADMIN, but base.html already declares one in an earlier script on the same page. Classic scripts share one global lexical scope, so the redeclaration threw a SyntaxError and the whole Contacts script never ran, leaving the table on its loading placeholder in every mode. Contacts now uses the global from base.html. The zombie-recover and clear-offline banner buttons were still rendered for anonymous viewers even though their /api/admin/* endpoints now return 401. They are gated on the same auth_enabled/is_admin check as the other admin controls. * fix(web_viewer): drop sockets on logout, block off-site login redirects, allow HEAD on public paths Logging out cleared the HTTP session but left that login's Socket.IO connections open, and a socket keeps the session it connected with, so a /realtime tab kept streaming packets, decrypted messages, and logs after logout. Each login now gets an admin_login_id, sockets record it on connect, and logout disconnects the sockets opened under that login. Other admin logins stay connected. The login next check passed ///host and /\host because urlparse sees no netloc in either, but browsers resolve both as another origin. Next targets are now rejected if they start with //, contain a backslash, or contain a tab or newline. This check predates the admin split. Public pages and GET APIs only accepted literal GET, so HEAD (and OPTIONS) on them returned 401 and made uptime checks report the viewer as down. They now accept HEAD and OPTIONS too; admin paths are unchanged. * docs(changelog): note the login redirect fix * fix(web_viewer): pass IS_ADMIN through a body data attribute so ESLint can parse base.html The frontend lint job failed with "Parsing error: Unexpected token {" because base.html's inline script assigned IS_ADMIN from a raw Jinja expression, which isn't valid JS until rendered. The flag now lives in data-is-admin on and the script reads it from document.body.dataset. I also dropped the unused AUTH_ENABLED constant and declared IS_ADMIN as a readonly ESLint global, since contacts.html reads it from base.html. --------- Co-authored-by: Gerard Hickey --- CHANGELOG.md | 14 ++ eslint.config.mjs | 2 + modules/web_viewer/app.py | 139 +++++++++++++-- modules/web_viewer/templates/base.html | 37 +++- modules/web_viewer/templates/contacts.html | 46 +++-- modules/web_viewer/templates/mesh.html | 8 +- modules/web_viewer/templates/realtime.html | 18 +- tests/js/contacts_manager.test.mjs | 2 + tests/test_web_viewer.py | 186 ++++++++++++++++++++- 9 files changed, 404 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db8570..f1c1987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ semantic versioning. ## [Unreleased] +### Added + +- Web viewer admin role when `web_viewer_password` is set: Dashboard, Contacts, + and Mesh Graph are readable anonymously; Radio, Config, Logs, and all + mutating APIs require an admin session. The Realtime page still renders + publicly, but live Socket.IO streams stay admin-only because they (and + `/api/channels`) can expose decrypted traffic and channel keys. + +### Fixed + +- The web viewer login no longer redirects off-site after sign-in. The `next` + check let `///host` and `/\host` through, which browsers treat as another + origin. + ### Changed - The footer's MeshCore link now points at the official site, meshcore.io. diff --git a/eslint.config.mjs b/eslint.config.mjs index 2cd8309..4fca84d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,8 @@ export default [ L: "readonly", escapeHtml: "writable", socket: "writable", + // Declared in base.html; page scripts that extend it read it. + IS_ADMIN: "readonly", }, }, settings: { diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index f5a6587..6267e00 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -5,6 +5,7 @@ Bot montoring web interface using Flask-SocketIO 5.x """ import configparser +import hmac import json import logging import os @@ -591,6 +592,9 @@ class BotDataViewer: radio_offline = False radio_offline_since = None bot_initializing = False + auth_enabled = bool(self.web_viewer_password) + # Session bit only — missing password is not an admin session. + is_admin = bool(session.get('authenticated_admin')) return { 'greeter_enabled': greeter_enabled, 'feed_manager_enabled': feed_manager_enabled, @@ -602,6 +606,8 @@ class BotDataViewer: 'radio_offline': radio_offline, 'radio_offline_since': radio_offline_since, 'bot_initializing': bot_initializing, + 'auth_enabled': auth_enabled, + 'is_admin': is_admin, } except Exception as e: self.logger.exception("Template context processor failed: %s", e) @@ -616,6 +622,8 @@ class BotDataViewer: 'radio_zombie_since': None, 'radio_offline': False, 'radio_offline_since': None, + 'auth_enabled': bool(getattr(self, 'web_viewer_password', '')), + 'is_admin': False, } def _init_databases(self): @@ -1211,13 +1219,61 @@ class BotDataViewer: error_message='Something went wrong on our end. The error has been logged.', ), 500) - # Authentication middleware (BUG-001) + # Authentication middleware (BUG-001). + # Fail closed when a password is configured: only the public allowlist + # below is reachable without authenticated_admin. Everything else + # (config, logs, radio, mutations, channel keys, sockets) stays admin. _EXEMPT_PATHS = frozenset([ '/login', '/logout', '/apple-touch-icon.png', '/favicon-32x32.png', '/favicon-16x16.png', '/site.webmanifest', '/favicon.ico', + # Bot→viewer ingest uses X-Stream-Token, not the admin session. + '/api/stream_data', ]) + # Issue #240 public HTML surface (Realtime page renders; live socket stays admin). + _PUBLIC_PAGE_PATHS = frozenset([ + '/', '/realtime', '/contacts', '/mesh', + ]) + + # Anonymous-safe GET APIs: mesh-visible / aggregate data only. No channel + # keys, config, logs, backups, or private message firehose. + _PUBLIC_API_GET_PATHS = frozenset([ + '/api/health', + '/api/banner-status', + '/api/stats', + '/api/dashboard/summary', + '/api/dashboard/series', + '/api/dashboard/top', + '/api/dashboard/windows', + '/api/contacts', + '/api/contact-detail', + '/api/mesh/nodes', + '/api/mesh/edges', + '/api/mesh/stats', + ]) + + # Read-only POST helpers used by public Contacts / Mesh info panels. + _PUBLIC_API_POST_PATHS = frozenset([ + '/api/decode-path', + '/api/mesh/resolve-path', + ]) + + def _is_local_redirect(url: str) -> bool: + # Browsers read '//host' and '/\host' as another origin, and strip + # tabs/newlines before parsing, so '/\t/host' becomes '//host' too. + if not url.startswith('/') or any(c in url for c in '\\\t\r\n'): + return False + if url.startswith('//'): + return False + parsed = urlparse(url) + return not (parsed.scheme or parsed.netloc) + + def _normalize_request_path(path: str) -> str: + if path != '/' and path.endswith('/'): + path = path.rstrip('/') + return path or '/' + @self.app.before_request def create_csp_nonce(): """Create a per-response nonce for templates migrated off inline-script CSP.""" @@ -1225,16 +1281,25 @@ class BotDataViewer: @self.app.before_request def require_auth(): + """Enforce admin auth except for the explicit public allowlist.""" if not self.web_viewer_password: - return # Auth disabled — no password configured - if request.path in _EXEMPT_PATHS or request.path.startswith('/static/'): + return # Auth disabled — no password configured (legacy open mode) + path = _normalize_request_path(request.path) + if path in _EXEMPT_PATHS or path.startswith('/static/'): return - if session.get('authenticated'): + if session.get('authenticated_admin'): return - if request.path.startswith('/api/'): - return make_response(jsonify({'error': 'Authentication required'}), 401) - next_url = request.path - return redirect(url_for('login', next=next_url)) + # HEAD and OPTIONS are answered by Flask from the GET route with no + # body, so they are as safe as the GET they shadow. + if request.method in ('GET', 'HEAD', 'OPTIONS') and ( + path in _PUBLIC_PAGE_PATHS or path in _PUBLIC_API_GET_PATHS + ): + return + if request.method in ('POST', 'OPTIONS') and path in _PUBLIC_API_POST_PATHS: + return + if path.startswith('/api/'): + return make_response(jsonify({'error': 'Admin authentication required'}), 401) + return redirect(url_for('login', next=path)) @self.app.before_request def csrf_protection(): @@ -1332,16 +1397,31 @@ class BotDataViewer: @self.app.route('/login', methods=['GET', 'POST']) def login(): - """Login page for web viewer authentication""" + """Login page for admin authentication""" if not self.web_viewer_password: return redirect(url_for('index')) if request.method == 'POST': password = request.form.get('password', '') - if password == self.web_viewer_password: - session['authenticated'] = True + # Hash both sides so compare_digest always sees equal-length + # digests (avoids TypeError / length short-circuit on ==). + expected = hmac.new( + b'web-viewer-login', + self.web_viewer_password.encode('utf-8'), + 'sha256', + ).digest() + provided = hmac.new( + b'web-viewer-login', + password.encode('utf-8'), + 'sha256', + ).digest() + if hmac.compare_digest(provided, expected): + session.clear() + session['authenticated_admin'] = True + # Ties this login's Socket.IO connections together so logout + # can drop them (a socket keeps its connect-time session). + session['admin_login_id'] = secrets.token_urlsafe(16) next_url = request.args.get('next', '/') - parsed = urlparse(next_url) - if parsed.scheme or parsed.netloc or not next_url.startswith('/'): + if not _is_local_redirect(next_url): next_url = '/' return redirect(next_url) return render_template('login.html', error='Invalid password') @@ -1349,9 +1429,12 @@ class BotDataViewer: @self.app.route('/logout') def logout(): - """Logout and clear session""" - session.pop('authenticated', None) - return redirect(url_for('login')) + """Logout, clear session, and drop this login's live sockets""" + login_id = session.get('admin_login_id') + session.clear() + if login_id: + self._disconnect_login_sockets(login_id) + return redirect(url_for('index')) @self.app.route('/') def index(): @@ -5269,8 +5352,10 @@ class BotDataViewer: self.logger.warning("Connect event received but client_id is None") return False - # Reject unauthenticated SocketIO connections when auth is enabled (BUG-001) - if self.web_viewer_password and not session.get('authenticated'): + # Reject unauthenticated SocketIO connections when auth is enabled. + # Live packet/message/log streams stay admin-only — they can carry + # decrypted channel traffic and operator logs (deviation from #240). + if self.web_viewer_password and not session.get('authenticated_admin'): self.logger.warning(f"Rejected unauthenticated SocketIO connection from {client_id}") with suppress(Exception): disconnect() @@ -5290,6 +5375,7 @@ class BotDataViewer: # Track client self.connected_clients[client_id] = { + 'admin_login_id': session.get('admin_login_id'), 'connected_at': time.time(), 'last_activity': time.time(), 'subscribed_commands': False, @@ -5482,6 +5568,23 @@ class BotDataViewer: # If we can't emit, just log it self.logger.error(f"Error emitting error message: {emit_error}") + def _disconnect_login_sockets(self, login_id): + """Disconnect every Socket.IO client opened under the given admin login.""" + with self._clients_lock: + sids = [ + sid for sid, info in self.connected_clients.items() + if info.get('admin_login_id') == login_id + ] + for sid in sids: + try: + self.socketio.server.disconnect(sid, namespace='/') + except Exception as e: + self.logger.warning(f"Could not disconnect socket {sid} on logout: {e}") + with self._clients_lock: + self.connected_clients.pop(sid, None) + if sids: + self.logger.info(f"Logout disconnected {len(sids)} live socket(s)") + def _handle_command_data(self, command_data): """Handle incoming command data from bot""" try: diff --git a/modules/web_viewer/templates/base.html b/modules/web_viewer/templates/base.html index 84b99fd..e1947b0 100644 --- a/modules/web_viewer/templates/base.html +++ b/modules/web_viewer/templates/base.html @@ -403,7 +403,7 @@ {% block extra_css %}{% endblock %} - +