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 %} -
+