From c8ea41c1e9f97555e998093449fa9dff251a6a1f Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 6 Jul 2026 22:28:59 -0500 Subject: [PATCH] feat(websocket): implement authentication for WebSocket mutators and update async utility functions --- meshchatx/meshchat.py | 38 ++++++- meshchatx/src/backend/async_utils.py | 32 ++++++ meshchatx/src/backend/identity_context.py | 12 +- meshchatx/src/backend/integrity_manager.py | 18 +++ .../src/backend/websocket_config_guard.py | 43 +++++++- meshchatx/src/frontend/components/App.vue | 7 +- .../components/settings/SettingsPage.vue | 9 -- .../public/meshchatx-docs/meshchatx.md | 13 ++- tests/backend/test_async_utils_critical.py | 25 +++++ tests/backend/test_integrity_startup_block.py | 55 ++++++++++ tests/backend/test_lxmf_flood_protection.py | 69 ++++++++++++ tests/backend/test_websocket_config_guard.py | 17 ++- .../backend/test_websocket_config_security.py | 103 ++++++++++++++++-- 13 files changed, 414 insertions(+), 27 deletions(-) create mode 100644 tests/backend/test_integrity_startup_block.py create mode 100644 tests/backend/test_lxmf_flood_protection.py diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index f351189a..9bc42b12 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -165,7 +165,10 @@ from meshchatx.src.backend.reticulum_config_guard import ( repair_unparseable_reticulum_config, reticulum_config_has_required_sections, ) -from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update +from meshchatx.src.backend.websocket_config_guard import ( + sanitize_websocket_config_update, + websocket_type_requires_auth, +) from meshchatx.src.backend.landlock_sandbox import ( apply_landlock_sandbox, landlock_auto_enabled, @@ -445,6 +448,7 @@ class ReticulumMeshChat: self.current_context: IdentityContext | None = None self._propagation_sync_metrics: dict[str, dict] = {} + AsyncUtils.ensure_background_loop() self.setup_identity(identity) self.web_audio_bridge = WebAudioBridge(None, None) @@ -15616,6 +15620,23 @@ class ReticulumMeshChat: def flush_all_archived_pages(self): return self.nomadnet_manager.flush_all_archived_pages() + async def _websocket_session_authorized(self, client) -> bool: + if not self.auth_enabled: + return True + request = getattr(client, "request", None) + if request is None: + return False + try: + session = await get_session(request) + except Exception: + return False + identity_hash = self.identity.hash.hex() if self.identity else None + return bool( + session.get("authenticated", False) + and identity_hash + and session.get("identity_hash") == identity_hash + ) + # handle data received from websocket client async def on_websocket_data_received(self, client, data): # get type from client data @@ -15626,6 +15647,21 @@ class ReticulumMeshChat: if not _type: return + if websocket_type_requires_auth(_type): + if not await self._websocket_session_authorized(client): + logger.warning("Rejected unauthorized WebSocket mutator: %s", _type) + AsyncUtils.run_async( + client.send_str( + json.dumps( + { + "type": "error", + "message": "Authentication required", + }, + ), + ), + ) + return + # handle ping if _type == "ping": AsyncUtils.run_async( diff --git a/meshchatx/src/backend/async_utils.py b/meshchatx/src/backend/async_utils.py index 40205b1f..aea19280 100644 --- a/meshchatx/src/backend/async_utils.py +++ b/meshchatx/src/backend/async_utils.py @@ -1,10 +1,13 @@ # SPDX-License-Identifier: 0BSD AND MIT import asyncio +import logging import threading from collections.abc import Coroutine from typing import Any, ClassVar +_logger = logging.getLogger("meshchatx.async") + class AsyncUtils: main_loop: asyncio.AbstractEventLoop | None = None @@ -13,6 +16,35 @@ class AsyncUtils: _futures_lock = threading.Lock() _FUTURES_SWEEP_THRESHOLD = 32 _COROUTINES_MAX = 256 + _background_loop: asyncio.AbstractEventLoop | None = None + _background_thread: threading.Thread | None = None + _background_ready = threading.Event() + + @staticmethod + def ensure_background_loop() -> None: + """Start a daemon event loop for pre-web-server async work.""" + if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running(): + return + if AsyncUtils._background_thread and AsyncUtils._background_thread.is_alive(): + return + + loop = asyncio.new_event_loop() + AsyncUtils._background_ready.clear() + + def runner() -> None: + AsyncUtils.set_main_loop(loop) + AsyncUtils._background_ready.set() + loop.run_forever() + + AsyncUtils._background_loop = loop + AsyncUtils._background_thread = threading.Thread( + target=runner, + name="meshchatx-async", + daemon=True, + ) + AsyncUtils._background_thread.start() + if not AsyncUtils._background_ready.wait(timeout=5): + _logger.warning("Background asyncio loop did not become ready within 5s") @staticmethod def set_main_loop(loop: asyncio.AbstractEventLoop): diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 36dd5234..c44a81a3 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -18,7 +18,12 @@ from meshchatx.src.backend.database import Database from meshchatx.src.backend.docs_manager import DocsManager from meshchatx.src.backend.repository_server_manager import RepositoryServerManager from meshchatx.src.backend.forwarding_manager import ForwardingManager -from meshchatx.src.backend.integrity_manager import IntegrityManager +from meshchatx.src.backend.async_utils import AsyncUtils +from meshchatx.src.backend.integrity_manager import ( + CriticalIntegrityError, + IntegrityManager, + select_critical_integrity_issues, +) from meshchatx.src.backend.map_manager import MapManager from meshchatx.src.backend.meshchat_utils import create_lxmf_router from meshchatx.src.backend.message_handler import MessageHandler @@ -153,6 +158,11 @@ class IdentityContext: if not hasattr(self.app, "integrity_issues"): self.app.integrity_issues = [] self.app.integrity_issues.extend(issues) + critical = select_critical_integrity_issues(issues) + if critical: + raise CriticalIntegrityError( + "Critical integrity failure: " + "; ".join(critical), + ) try: self.database.initialize() diff --git a/meshchatx/src/backend/integrity_manager.py b/meshchatx/src/backend/integrity_manager.py index 7185c831..764b661d 100644 --- a/meshchatx/src/backend/integrity_manager.py +++ b/meshchatx/src/backend/integrity_manager.py @@ -11,9 +11,27 @@ from pathlib import Path from typing import ClassVar +class CriticalIntegrityError(RuntimeError): + """Raised when tampering is detected in identity or database files at startup.""" + + +def select_critical_integrity_issues(issues: list[str]) -> list[str]: + return [ + issue + for issue in issues + if any(marker in issue for marker in IntegrityManager.CRITICAL_ISSUE_MARKERS) + ] + + class IntegrityManager: """Manages the integrity of the database and identity files at rest.""" + CRITICAL_ISSUE_MARKERS: ClassVar[tuple[str, ...]] = ( + "Critical security component integrity compromised", + "Database structural issue", + "Identity mismatch", + ) + # Filename globs frequently rewritten by RNS/LXMF or SQLite that should be # ignored during integrity checks. IGNORED_PATTERNS: ClassVar[list[str]] = [ diff --git a/meshchatx/src/backend/websocket_config_guard.py b/meshchatx/src/backend/websocket_config_guard.py index 6d677129..232a5c45 100644 --- a/meshchatx/src/backend/websocket_config_guard.py +++ b/meshchatx/src/backend/websocket_config_guard.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: 0BSD -"""WebSocket config update guards. +"""WebSocket guards for config updates and authenticated mutators. Settings that change the HTTP security boundary must go through CSRF-protected HTTP endpoints, not the unauthenticated ``config.set`` WebSocket message. @@ -19,6 +19,47 @@ WEBSOCKET_CONFIG_DENYLIST = frozenset( }, ) +WEBSOCKET_PUBLIC_TYPES = frozenset( + { + "ping", + }, +) + +WEBSOCKET_READ_TYPES = frozenset( + { + "nomadnet.page.archives.get", + "nomadnet.page.archive.load", + "lxmf.forwarding.rules.get", + "keyboard_shortcuts.get", + }, +) + +WEBSOCKET_MUTATOR_TYPES = frozenset( + { + "config.set", + "keyboard_shortcuts.delete", + "keyboard_shortcuts.set", + "lxm.generate_paper_uri", + "lxm.ingest_uri", + "lxmf.forwarding.rule.add", + "lxmf.forwarding.rule.delete", + "lxmf.forwarding.rule.toggle", + "nomadnet.download.cancel", + "nomadnet.file.download", + "nomadnet.page.archive.add", + "nomadnet.page.archive.flush", + "nomadnet.page.download", + }, +) + + +def websocket_type_requires_auth(msg_type: str) -> bool: + if msg_type in WEBSOCKET_PUBLIC_TYPES or msg_type in WEBSOCKET_READ_TYPES: + return False + if msg_type in WEBSOCKET_MUTATOR_TYPES: + return True + return False + def sanitize_websocket_config_update(config: object) -> dict: """Return a copy of *config* with security-sensitive keys removed.""" diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index b5942d74..79d1477a 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -228,7 +228,7 @@