mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 14:22:22 +00:00
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 <hickey@kinetic-compute.com> * 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 <body> 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 <hickey@kinetic-compute.com>
This commit is contained in:
1 parent
c5cdd63d7e
commit
dd546a12ed
9 files changed
+404
-48
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+121
-18
@@ -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:
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body data-is-admin="{{ 'true' if (not auth_enabled or is_admin) else 'false' }}">
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
@@ -437,13 +437,14 @@
|
||||
<i class="fas fa-project-diagram"></i> Mesh Graph
|
||||
</a>
|
||||
</li>
|
||||
{% if multibyte_monitor_enabled %}
|
||||
{% if multibyte_monitor_enabled and (not auth_enabled or is_admin) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.path == '/multibyte-rollout' }}" href="/multibyte-rollout">
|
||||
<i class="fas fa-code-branch"></i> Multibyte
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if not auth_enabled or is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.path == '/logs' }}" href="/logs">
|
||||
<i class="fas fa-file-alt"></i> Logs
|
||||
@@ -502,10 +503,24 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<!-- Dark Mode Toggle and Connection Status -->
|
||||
<div class="navbar-nav d-flex align-items-center">
|
||||
{% if auth_enabled %}
|
||||
<div class="nav-item me-3">
|
||||
{% if is_admin %}
|
||||
<a href="/logout" class="btn btn-sm btn-outline-light">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/login" class="btn btn-sm btn-outline-light">
|
||||
<i class="fas fa-sign-in-alt"></i> Admin Login
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="nav-item me-3">
|
||||
<button class="dark-mode-toggle" id="dark-mode-toggle" aria-label="Toggle dark mode"
|
||||
title="Toggle dark mode">
|
||||
@@ -551,6 +566,7 @@
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% if not auth_enabled or is_admin %}
|
||||
<div class="d-flex gap-2 align-items-center flex-shrink-0">
|
||||
<span class="small opacity-75">After power cycling:</span>
|
||||
<button id="zombie-recover-btn" class="btn btn-warning btn-sm fw-semibold"
|
||||
@@ -558,6 +574,7 @@
|
||||
<i class="fas fa-power-off me-1"></i>Restart Bot Processing
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,6 +595,7 @@
|
||||
'' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% if not auth_enabled or is_admin %}
|
||||
<div class="d-flex gap-2 align-items-center flex-shrink-0">
|
||||
<span class="small opacity-75">Once radio is fixed:</span>
|
||||
<button id="offline-clear-btn" class="btn btn-dark btn-sm fw-semibold"
|
||||
@@ -585,6 +603,7 @@
|
||||
<i class="fas fa-check me-1"></i>Clear Offline Flag
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -736,6 +755,10 @@
|
||||
|
||||
<!-- Base JavaScript -->
|
||||
<script nonce="{{ g.csp_nonce }}">
|
||||
// Display hint only — server enforces authenticated_admin. Read from a
|
||||
// data attribute so the inline script stays plain JS for ESLint.
|
||||
const IS_ADMIN = document.body.dataset.isAdmin === 'true';
|
||||
|
||||
// Global connection management
|
||||
class ModernConnectionManager {
|
||||
constructor() {
|
||||
@@ -744,8 +767,14 @@
|
||||
this.lastActivity = null;
|
||||
this.pingInterval = null;
|
||||
|
||||
this.initializeSocket();
|
||||
this.startPingInterval();
|
||||
// Live Socket.IO stays admin-only when a password is configured
|
||||
// (packet/message streams can include private mesh traffic).
|
||||
if (IS_ADMIN) {
|
||||
this.initializeSocket();
|
||||
this.startPingInterval();
|
||||
} else {
|
||||
this.updateConnectionStatus('Login for live updates', 'disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
initializeSocket() {
|
||||
|
||||
@@ -417,6 +417,7 @@
|
||||
<button type="button" class="btn btn-primary flex-shrink-0" id="refresh-contacts">
|
||||
<i class="fas fa-sync"></i><span class="d-none d-sm-inline"> Refresh</span>
|
||||
</button>
|
||||
{% if not auth_enabled or is_admin %}
|
||||
<div class="dropdown flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary contacts-overflow-btn"
|
||||
data-bs-toggle="dropdown" aria-expanded="false"
|
||||
@@ -454,14 +455,17 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-md-none w-100 d-flex flex-nowrap align-items-center gap-2 mb-3 contacts-mobile-sort-row">
|
||||
{% if not auth_enabled or is_admin %}
|
||||
<div class="form-check mb-0">
|
||||
<input type="checkbox" class="form-check-input" id="contacts-select-all-mobile" title="Select all on page">
|
||||
<label class="form-check-label small" for="contacts-select-all-mobile">Select all on page</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-grow-1 min-w-0" style="min-width: 10rem;">
|
||||
<label class="visually-hidden" for="contacts-mobile-sort">Sort by</label>
|
||||
<select class="form-select form-select-sm" id="contacts-mobile-sort" title="Sort contacts">
|
||||
@@ -492,9 +496,11 @@
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
{% if not auth_enabled or is_admin %}
|
||||
<th class="text-center" style="width: 2.5em;">
|
||||
<input type="checkbox" class="form-check-input" id="contacts-select-all" title="Select all on page">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="sortable" data-sort="username">
|
||||
Name <i class="fas fa-sort sort-icon" aria-hidden="true"></i>
|
||||
</th>
|
||||
@@ -602,6 +608,9 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script nonce="{{ g.csp_nonce }}">
|
||||
// IS_ADMIN comes from base.html (display hint only; mutations are enforced
|
||||
// server-side via authenticated_admin). Redeclaring it here is a SyntaxError.
|
||||
|
||||
class ModernContactsManager {
|
||||
constructor() {
|
||||
this.contactsData = { tracking_data: [], server_stats: {} };
|
||||
@@ -931,13 +940,23 @@ class ModernContactsManager {
|
||||
const checked = this.selectedContactIds.has(contact.user_id) ? 'checked' : '';
|
||||
const hasGeo = !!(contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0);
|
||||
const starLabel = contact.is_starred ? 'Unstar contact' : 'Star contact';
|
||||
const geoMenuItem = hasGeo
|
||||
const geoMenuItem = IS_ADMIN && hasGeo
|
||||
? `<li><button type="button" class="dropdown-item" data-contact-action="geocode" data-contact-index="${contactIndex}"><i class="fas fa-map-marker-alt me-2 text-primary"></i>Geocode location</button></li>`
|
||||
: '';
|
||||
const deleteMenuItem = IS_ADMIN
|
||||
? `<li><hr class="dropdown-divider"></li>
|
||||
<li><button type="button" class="dropdown-item text-danger" data-contact-action="delete" data-contact-index="${contactIndex}"><i class="fas fa-trash me-2"></i>Delete contact</button></li>`
|
||||
: '';
|
||||
const starMenuItem = IS_ADMIN
|
||||
? `<li><button type="button" class="dropdown-item" data-contact-action="star" data-contact-index="${contactIndex}"><i class="fas fa-star me-2 text-warning"></i>${starLabel}</button></li>`
|
||||
: '';
|
||||
const checkboxHtml = IS_ADMIN
|
||||
? `<input type="checkbox" class="form-check-input contact-row-checkbox mt-1 flex-shrink-0" data-contact-index="${contactIndex}" ${checked}>`
|
||||
: '';
|
||||
return `
|
||||
<div class="contact-mobile-card">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<input type="checkbox" class="form-check-input contact-row-checkbox mt-1 flex-shrink-0" data-contact-index="${contactIndex}" ${checked}>
|
||||
${checkboxHtml}
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div class="min-w-0">
|
||||
@@ -968,11 +987,10 @@ class ModernContactsManager {
|
||||
<i class="fas fa-ellipsis-vertical" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><button type="button" class="dropdown-item" data-contact-action="star" data-contact-index="${contactIndex}"><i class="fas fa-star me-2 text-warning"></i>${starLabel}</button></li>
|
||||
${starMenuItem}
|
||||
<li><button type="button" class="dropdown-item" data-contact-action="advert" data-contact-index="${contactIndex}"><i class="fas fa-info-circle me-2 text-info"></i>View advertisement data</button></li>
|
||||
${geoMenuItem}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button type="button" class="dropdown-item text-danger" data-contact-action="delete" data-contact-index="${contactIndex}"><i class="fas fa-trash me-2"></i>Delete contact</button></li>
|
||||
${deleteMenuItem}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -992,7 +1010,7 @@ class ModernContactsManager {
|
||||
'No contacts data available';
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 12;
|
||||
cell.colSpan = IS_ADMIN ? 12 : 11;
|
||||
cell.className = 'text-center text-muted';
|
||||
cell.textContent = message;
|
||||
row.appendChild(cell);
|
||||
@@ -1011,9 +1029,9 @@ class ModernContactsManager {
|
||||
if (!this.mobileLayout.matches) {
|
||||
tbody.innerHTML = this.filteredData.map((contact, contactIndex) => `
|
||||
<tr>
|
||||
<td class="text-center align-middle">
|
||||
${IS_ADMIN ? `<td class="text-center align-middle">
|
||||
<input type="checkbox" class="form-check-input contact-row-checkbox" data-contact-index="${contactIndex}" ${this.selectedContactIds.has(contact.user_id) ? 'checked' : ''}>
|
||||
</td>
|
||||
</td>` : ''}
|
||||
<td>
|
||||
<strong>${this.escapeHtml(contact.username || 'Unknown')}</strong>
|
||||
<br>
|
||||
@@ -1030,21 +1048,21 @@ class ModernContactsManager {
|
||||
<td><span class="badge bg-success">${Number(contact.advert_count) || 0}</span></td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm ${contact.is_starred ? 'btn-warning' : 'btn-outline-warning'}" data-contact-action="star" data-contact-index="${contactIndex}" title="${contact.is_starred ? 'Unstar contact' : 'Star contact'}">
|
||||
${IS_ADMIN ? `<button class="btn btn-sm ${contact.is_starred ? 'btn-warning' : 'btn-outline-warning'}" data-contact-action="star" data-contact-index="${contactIndex}" title="${contact.is_starred ? 'Unstar contact' : 'Star contact'}">
|
||||
<i class="fas fa-star"></i>
|
||||
</button>
|
||||
</button>` : ''}
|
||||
<button class="btn btn-sm btn-outline-info" data-contact-action="advert" data-contact-index="${contactIndex}" title="View Advertisement Data">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</button>
|
||||
${contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0 ?
|
||||
${IS_ADMIN && contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0 ?
|
||||
`<button class="btn btn-sm btn-outline-primary" data-contact-action="geocode" data-contact-index="${contactIndex}" title="Geocode Location">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</button>` :
|
||||
</button>` :
|
||||
''
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-danger" data-contact-action="delete" data-contact-index="${contactIndex}" title="Delete Contact">
|
||||
${IS_ADMIN ? `<button class="btn btn-sm btn-outline-danger" data-contact-action="delete" data-contact-index="${contactIndex}" title="Delete Contact">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3591,8 +3591,12 @@
|
||||
});
|
||||
|
||||
function setupSocketIO() {
|
||||
const socket = window.connectionManager.socket;
|
||||
|
||||
const socket = window.connectionManager && window.connectionManager.socket;
|
||||
if (!socket) {
|
||||
// Anonymous viewers use REST polling only; live mesh socket is admin-only.
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('subscribe_mesh');
|
||||
|
||||
function onMeshUpdate(reloadNodes) {
|
||||
|
||||
@@ -818,9 +818,10 @@
|
||||
console.warn('MeshCore decoder unavailable:', e);
|
||||
});
|
||||
|
||||
// Reuse the shared socket from base.html's ModernConnectionManager
|
||||
const socket = window.connectionManager.socket;
|
||||
|
||||
// Reuse the shared socket from base.html's ModernConnectionManager.
|
||||
// When auth is enabled, anonymous users have no socket (live streams stay admin-only).
|
||||
const socket = window.connectionManager && window.connectionManager.socket;
|
||||
|
||||
// Connection status
|
||||
let commandConnected = false;
|
||||
let packetConnected = false;
|
||||
@@ -1022,7 +1023,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Socket event handlers
|
||||
// Socket event handlers (admin session only — anonymous has no live firehose)
|
||||
if (!socket) {
|
||||
updateStatus('command-status', 'Admin login required', 'warning');
|
||||
updateStatus('packet-status', 'Admin login required', 'warning');
|
||||
updateStatus('message-status', 'Admin login required', 'warning');
|
||||
} else {
|
||||
socket.on('connect', function() {
|
||||
updateStatus('command-status', 'Connected', 'success');
|
||||
updateStatus('packet-status', 'Connected', 'success');
|
||||
@@ -1074,6 +1080,7 @@
|
||||
socket.on('message_data', function(data) {
|
||||
addMessageEntry(data);
|
||||
});
|
||||
} // end admin-only socket handlers
|
||||
|
||||
// ── Live Channel Messages ──────────────────────────────────────────────
|
||||
|
||||
@@ -1294,6 +1301,7 @@
|
||||
let pingInterval = null;
|
||||
|
||||
function startPingInterval() {
|
||||
if (!socket) return;
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
pingInterval = setInterval(function() {
|
||||
if (socket.connected) {
|
||||
@@ -1309,11 +1317,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
socket.on('pong', function() {
|
||||
// Connection is alive, update status
|
||||
updateStatus('command-status', 'Connected', 'success');
|
||||
updateStatus('packet-status', 'Connected', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function updateStatus(elementId, text, type) {
|
||||
|
||||
@@ -38,6 +38,8 @@ function loadManagerClass() {
|
||||
const context = vm.createContext({
|
||||
AbortController,
|
||||
URLSearchParams,
|
||||
// Template injects IS_ADMIN for display; tests assume admin UI (legacy).
|
||||
IS_ADMIN: true,
|
||||
Chart: function Chart() {},
|
||||
bootstrap: {
|
||||
Dropdown: class Dropdown {},
|
||||
|
||||
+180
-6
@@ -1178,13 +1178,15 @@ class TestAuthRoutes:
|
||||
resp = auth_client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unauthenticated_index_redirects_to_login(self, auth_client):
|
||||
resp = auth_client.get("/")
|
||||
def test_unauthenticated_admin_page_redirects_to_login(self, auth_client):
|
||||
"""Admin pages should redirect to login when not authenticated."""
|
||||
resp = auth_client.get("/logs")
|
||||
assert resp.status_code == 302
|
||||
assert "login" in resp.headers["Location"]
|
||||
|
||||
def test_unauthenticated_api_returns_401(self, auth_client):
|
||||
resp = auth_client.get("/api/health")
|
||||
def test_unauthenticated_admin_api_returns_401(self, auth_client):
|
||||
"""Admin API endpoints should return 401 when not authenticated."""
|
||||
resp = auth_client.get("/api/config/notifications")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_wrong_password(self, auth_client):
|
||||
@@ -1213,8 +1215,8 @@ class TestAuthRoutes:
|
||||
auth_client.post("/login", data={"password": "secret123"})
|
||||
resp = auth_client.get("/logout", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
# After logout, index should redirect to login again
|
||||
resp2 = auth_client.get("/")
|
||||
# After logout, admin pages should redirect to login again
|
||||
resp2 = auth_client.get("/logs")
|
||||
assert resp2.status_code == 302
|
||||
|
||||
def test_login_no_password_configured_redirects_to_index(self, client):
|
||||
@@ -1224,6 +1226,178 @@ class TestAuthRoutes:
|
||||
assert resp.headers["Location"].endswith("/")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Role-based access control (public vs admin pages)
|
||||
# ===========================================================================
|
||||
|
||||
class TestRoleBasedAccess:
|
||||
"""Public allowlist vs fail-closed admin when web_viewer_password is set."""
|
||||
|
||||
def test_public_pages_accessible_without_auth(self, auth_client):
|
||||
public_pages = ["/", "/realtime", "/mesh", "/contacts"]
|
||||
for page in public_pages:
|
||||
resp = auth_client.get(page)
|
||||
assert resp.status_code == 200, f"{page} should be accessible without auth"
|
||||
|
||||
def test_admin_pages_require_auth(self, auth_client):
|
||||
admin_pages = ["/logs", "/config", "/plugins", "/radio", "/greeter", "/feeds", "/schedule"]
|
||||
for page in admin_pages:
|
||||
resp = auth_client.get(page, follow_redirects=False)
|
||||
assert resp.status_code == 302, f"{page} should redirect to login"
|
||||
assert "login" in resp.headers["Location"], f"{page} should redirect to login"
|
||||
|
||||
def test_trailing_slash_admin_path_not_bypass(self, auth_client):
|
||||
resp = auth_client.get("/config/", follow_redirects=False)
|
||||
assert resp.status_code in (301, 302, 308, 401)
|
||||
if resp.status_code in (301, 302, 308):
|
||||
assert "login" in resp.headers.get("Location", "") or resp.headers["Location"].rstrip("/").endswith("/config")
|
||||
|
||||
def test_admin_pages_accessible_after_login(self, auth_client):
|
||||
auth_client.post("/login", data={"password": "secret123"})
|
||||
admin_pages = ["/logs", "/config", "/plugins", "/radio", "/schedule"]
|
||||
for page in admin_pages:
|
||||
resp = auth_client.get(page)
|
||||
assert resp.status_code == 200, f"{page} should be accessible after login"
|
||||
|
||||
def test_admin_api_endpoints_require_auth(self, auth_client):
|
||||
admin_apis = [
|
||||
"/api/config/notifications",
|
||||
"/api/config/logging",
|
||||
"/api/maintenance/status",
|
||||
"/api/radio/status",
|
||||
"/api/channels",
|
||||
"/api/recent_commands",
|
||||
"/api/logs",
|
||||
]
|
||||
for api in admin_apis:
|
||||
resp = auth_client.get(api)
|
||||
# /api/logs may 404 as a route — still must not be 200 without auth
|
||||
assert resp.status_code in (401, 404), f"{api} should not be open without auth"
|
||||
|
||||
def test_contact_mutations_require_auth(self, auth_client):
|
||||
for path in (
|
||||
"/api/delete-contact",
|
||||
"/api/toggle-star-contact",
|
||||
"/api/geocode-contact",
|
||||
"/api/contacts/purge",
|
||||
"/api/radio/reboot",
|
||||
"/api/dashboard/refresh",
|
||||
):
|
||||
resp = auth_client.post(
|
||||
path,
|
||||
json={},
|
||||
content_type="application/json",
|
||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||
)
|
||||
assert resp.status_code == 401, f"{path} must reject anonymous mutations"
|
||||
|
||||
def test_admin_api_endpoints_accessible_after_login(self, auth_client):
|
||||
auth_client.post("/login", data={"password": "secret123"})
|
||||
resp = auth_client.get("/api/config/notifications")
|
||||
assert resp.status_code == 200, "Config API should be accessible after login"
|
||||
|
||||
def test_logout_redirects_to_index_not_login(self, auth_client):
|
||||
auth_client.post("/login", data={"password": "secret123"})
|
||||
resp = auth_client.get("/logout", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/")
|
||||
|
||||
def test_public_pages_remain_accessible_no_password(self, client):
|
||||
"""When no password is configured, keep legacy open mode."""
|
||||
all_pages = ["/", "/realtime", "/mesh", "/contacts", "/logs", "/config"]
|
||||
for page in all_pages:
|
||||
resp = client.get(page)
|
||||
assert resp.status_code == 200, f"{page} should be accessible when auth is disabled"
|
||||
|
||||
def test_public_api_payloads_have_no_secrets(self, auth_client):
|
||||
"""Opened public GETs must not leak channel keys or config secrets."""
|
||||
for api in ("/api/health", "/api/contacts", "/api/mesh/stats", "/api/dashboard/summary"):
|
||||
resp = auth_client.get(api)
|
||||
assert resp.status_code in (200, 503), f"{api} should be public-readable"
|
||||
body = resp.get_json()
|
||||
assert body is not None
|
||||
blob = str(body).lower()
|
||||
for needle in ("smtp_password", "channel_key", "key_hex", "private_key", "web_viewer_password"):
|
||||
assert needle not in blob, f"{api} leaked {needle}"
|
||||
|
||||
def test_channels_api_stays_admin_only(self, auth_client):
|
||||
"""Channel list includes key_hex — must never be anonymous."""
|
||||
resp = auth_client.get("/api/channels")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_session_cookie_flags(self, auth_viewer):
|
||||
assert auth_viewer.app.config["SESSION_COOKIE_HTTPONLY"] is True
|
||||
assert auth_viewer.app.config["SESSION_COOKIE_SAMESITE"] == "Lax"
|
||||
|
||||
def test_is_admin_declared_once_per_page(self, auth_client, client):
|
||||
"""base.html declares IS_ADMIN; a second top-level const is a SyntaxError."""
|
||||
for c in (auth_client, client):
|
||||
for page in ("/", "/realtime", "/mesh", "/contacts"):
|
||||
html = c.get(page).get_data(as_text=True)
|
||||
assert html.count("const IS_ADMIN") == 1, f"{page} redeclares IS_ADMIN"
|
||||
|
||||
def test_banner_admin_buttons_hidden_from_anonymous(self, auth_client):
|
||||
html = auth_client.get("/").get_data(as_text=True)
|
||||
assert 'id="zombie-recover-btn"' not in html
|
||||
assert 'id="offline-clear-btn"' not in html
|
||||
|
||||
def test_banner_admin_buttons_shown_to_admin(self, auth_client):
|
||||
auth_client.post("/login", data={"password": "secret123"})
|
||||
html = auth_client.get("/").get_data(as_text=True)
|
||||
assert 'id="zombie-recover-btn"' in html
|
||||
assert 'id="offline-clear-btn"' in html
|
||||
|
||||
def test_head_on_public_paths_matches_get(self, auth_client):
|
||||
for path in ("/", "/contacts", "/api/health", "/api/contacts"):
|
||||
resp = auth_client.head(path)
|
||||
assert resp.status_code in (200, 503), f"HEAD {path} should be public"
|
||||
|
||||
def test_head_on_admin_paths_still_requires_auth(self, auth_client):
|
||||
assert auth_client.head("/api/channels").status_code == 401
|
||||
assert auth_client.head("/logs").status_code == 302
|
||||
|
||||
@pytest.mark.parametrize("target", [
|
||||
"//attacker.example",
|
||||
"///attacker.example",
|
||||
"/\\attacker.example",
|
||||
"/\t/attacker.example",
|
||||
"https://attacker.example/",
|
||||
])
|
||||
def test_login_next_rejects_offsite_targets(self, auth_client, target):
|
||||
resp = auth_client.post(
|
||||
"/login", query_string={"next": target}, data={"password": "secret123"}
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == "/"
|
||||
|
||||
def test_login_next_keeps_local_path(self, auth_client):
|
||||
resp = auth_client.post(
|
||||
"/login", query_string={"next": "/logs?lines=50"}, data={"password": "secret123"}
|
||||
)
|
||||
assert resp.headers["Location"] == "/logs?lines=50"
|
||||
|
||||
def test_logout_disconnects_live_sockets(self, auth_viewer, auth_client):
|
||||
auth_client.post("/login", data={"password": "secret123"})
|
||||
sock = auth_viewer.socketio.test_client(auth_viewer.app, flask_test_client=auth_client)
|
||||
assert sock.is_connected()
|
||||
# A second admin login in another browser must stay connected. (No
|
||||
# `with` here: a preserved request context clashes with the socket client.)
|
||||
other = auth_viewer.app.test_client()
|
||||
other.post("/login", data={"password": "secret123"})
|
||||
other_sock = auth_viewer.socketio.test_client(auth_viewer.app, flask_test_client=other)
|
||||
assert other_sock.is_connected()
|
||||
|
||||
auth_client.get("/logout")
|
||||
|
||||
assert not sock.is_connected()
|
||||
assert other_sock.is_connected()
|
||||
other_sock.disconnect()
|
||||
|
||||
def test_anonymous_socket_rejected(self, auth_viewer, auth_client):
|
||||
sock = auth_viewer.socketio.test_client(auth_viewer.app, flask_test_client=auth_client)
|
||||
assert not sock.is_connected()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Open-access routes (no auth required even with password enabled)
|
||||
# ===========================================================================
|
||||
|
||||
Reference in new issue
Block a user