feat(websocket): implement authentication for WebSocket mutators and update async utility functions

This commit is contained in:
Ivan 2026-07-06 22:28:59 -05:00
parent eca1486f3c
commit c8ea41c1e9
No known key found for this signature in database
13 changed files with 414 additions and 27 deletions

View file

@ -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(

View file

@ -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):

View file

@ -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()

View file

@ -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]] = [

View file

@ -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."""

View file

@ -228,7 +228,7 @@
<!-- navigation -->
<div class="flex-1">
<ul class="py-3 pr-2 space-y-1">
<li v-for="item in visibleNavItems" :key="item.id" v-if="isNavItemVisible(item)">
<li v-for="item in visibleNavItems" :key="item.id">
<SidebarLink :to="item.route" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
@ -700,7 +700,7 @@ export default {
return GlobalState.config?.rrc_enabled !== false;
},
visibleNavItems() {
return listNavItems();
return listNavItems().filter((item) => this.isNavItemVisible(item));
},
isSyncingPropagationNode() {
return [
@ -839,6 +839,9 @@ export default {
},
methods: {
isNavItemVisible(item) {
if (!item) {
return false;
}
if (item.visibleWhen === "rrcEnabled") {
return this.rrcEnabled;
}

View file

@ -29,15 +29,6 @@
</div>
</div>
</div>
<transition name="fade">
<div
v-if="copyToast"
class="mt-3 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 px-3 py-1 text-xs inline-flex items-center gap-2"
>
{{ copyToast }}
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
</div>
</transition>
<div
class="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3 mt-4 text-sm text-gray-600 dark:text-gray-300"
>

View file

@ -150,11 +150,19 @@ Authoring rules, security constraints for HTML/CSS, and API behaviour are docume
## Extensibility Points
MeshChatX supports a capability-based plugin system with separate frontend and backend runtimes:
- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for sidebar navigation, tools, command palette actions, settings sections, and typed WebSocket events.
- **Frontend plugins** run in dedicated Workers (`meshchatx/src/frontend/js/plugins/PluginHost.js`) with declarative UI slots rendered by `PluginSlotRenderer.vue`.
- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions (`meshchatx/src/backend/plugin_manager.py`).
- **Generic plugin API** under `/api/v1/plugins/*` for install, enable/disable, invoke, and asset serving.
The most practical extension points today are:
- plugin manifests in `plugin.json` with `contributes` and `permissions` blocks,
- new API routes in backend routing sections,
- new manager modules under `meshchatx/src/backend`,
- frontend page/component additions wired through existing router/state patterns,
- frontend page/component additions wired through contribution registries,
- new config surface through CLI flags + environment variables,
- schema extension through the existing migration/versioning approach.
@ -162,4 +170,5 @@ When adding features, prefer:
- identity-scoped state over global mutable state,
- explicit migration/version changes for DB schema updates,
- endpoint-level tests plus focused manager unit tests.
- endpoint-level tests plus focused manager unit tests,
- plugin permissions that are declared in manifests and enforced by the host.

View file

@ -6,6 +6,7 @@ from __future__ import annotations
import asyncio
import threading
import time
import warnings
import pytest
@ -18,12 +19,36 @@ def _reset_async_utils():
AsyncUtils.main_loop = None
AsyncUtils._pending_futures.clear()
AsyncUtils._pending_coroutines.clear()
AsyncUtils._background_loop = None
AsyncUtils._background_thread = None
AsyncUtils._background_ready.clear()
yield
AsyncUtils.main_loop = None
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
AsyncUtils._pending_futures.clear()
AsyncUtils._pending_coroutines.clear()
if AsyncUtils._background_loop and AsyncUtils._background_loop.is_running():
AsyncUtils._background_loop.call_soon_threadsafe(AsyncUtils._background_loop.stop)
AsyncUtils._background_loop = None
AsyncUtils._background_thread = None
AsyncUtils._background_ready.clear()
def test_ensure_background_loop_schedules_coroutines_before_web_startup():
seen: list[bool] = []
async def record():
seen.append(True)
AsyncUtils.ensure_background_loop()
AsyncUtils.run_async(record())
deadline = time.time() + 2.0
while time.time() < deadline and not seen:
time.sleep(0.05)
assert seen == [True]
async def _noop():

View file

@ -0,0 +1,55 @@
# SPDX-License-Identifier: 0BSD
import tempfile
from unittest.mock import MagicMock
import pytest
from meshchatx.src.backend.integrity_manager import (
CriticalIntegrityError,
select_critical_integrity_issues,
)
def test_select_critical_integrity_issues_filters_security_markers():
issues = [
"Last integrity snapshot: 2026-01-01",
"Critical security component integrity compromised: identity",
"File signature mismatch: stickers/foo.json",
]
critical = select_critical_integrity_issues(issues)
assert critical == ["Critical security component integrity compromised: identity"]
def test_identity_context_blocks_startup_on_critical_integrity_failure():
from meshchatx.src.backend.identity_context import IdentityContext
app = MagicMock()
app.emergency = False
app.auto_recover = False
app.integrity_issues = []
app.cleanup_rns_state_for_identity = MagicMock()
identity = MagicMock()
identity.hash = b"a" * 16
identity.get_private_key.return_value = b"private-key"
with tempfile.TemporaryDirectory() as tmp:
app.storage_dir = tmp
ctx = IdentityContext(identity, app)
ctx.integrity_manager.check_integrity = MagicMock(
return_value=(
False,
[
"Critical security component integrity compromised: identity",
],
),
)
with pytest.raises(CriticalIntegrityError):
ctx.setup()
def test_non_critical_integrity_issues_do_not_block_selector():
issues = ["File signature mismatch: stickers/foo.json"]
assert select_critical_integrity_issues(issues) == []

View file

@ -0,0 +1,69 @@
# SPDX-License-Identifier: 0BSD
import time
from unittest.mock import MagicMock
import pytest
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def flood_app(mock_app):
mock_app._lxmf_incoming_timestamps = []
mock_app._flood_protection_current_cost = None
mock_app._flood_protection_last_bump_time = 0
mock_app.current_context.config.lxmf_flood_protection_enabled.set(True)
mock_app.current_context.config.lxmf_flood_threshold_per_minute.set(5)
mock_app.current_context.config.lxmf_flood_max_stamp_cost.set(12)
mock_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
mock_app.current_context.config.block_all_from_strangers.set(False)
mock_app.current_context.local_lxmf_destination = MagicMock()
mock_app.current_context.local_lxmf_destination.hash = b"\x01" * 16
mock_app.current_context.message_router = MagicMock()
mock_app.current_context.config.display_name.set("Peer")
return mock_app
def test_lxmf_flood_protection_raises_stamp_cost_when_threshold_exceeded(flood_app):
now = time.time()
flood_app._lxmf_incoming_timestamps = [now - index for index in range(6)]
flood_app._check_lxmf_flood_protection()
assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 4
flood_app.current_context.message_router.set_inbound_stamp_cost.assert_called_once()
def test_lxmf_flood_protection_steps_down_after_cooldown(flood_app):
now = time.time()
flood_app._lxmf_incoming_timestamps = [now - 120]
flood_app._flood_protection_current_cost = 2
flood_app._flood_protection_last_bump_time = now - 120
flood_app.current_context.config.lxmf_inbound_stamp_cost.set(6)
flood_app.current_context.config.lxmf_flood_cooldown_seconds.set(30)
flood_app._check_lxmf_flood_protection()
assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 5
def test_lxmf_flood_protection_disabled_is_noop(flood_app):
flood_app.current_context.config.lxmf_flood_protection_enabled.set(False)
flood_app._lxmf_incoming_timestamps = [time.time()] * 20
flood_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
flood_app._check_lxmf_flood_protection()
assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 2
flood_app.current_context.message_router.set_inbound_stamp_cost.assert_not_called()
def test_lxmf_flood_protection_skips_when_block_strangers_enabled(flood_app):
flood_app.current_context.config.block_all_from_strangers.set(True)
flood_app._lxmf_incoming_timestamps = [time.time()] * 20
flood_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
flood_app._check_lxmf_flood_protection()
flood_app.current_context.message_router.set_inbound_stamp_cost.assert_not_called()

View file

@ -2,7 +2,13 @@
import pytest
from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update
from meshchatx.src.backend.websocket_config_guard import (
WEBSOCKET_MUTATOR_TYPES,
WEBSOCKET_PUBLIC_TYPES,
WEBSOCKET_READ_TYPES,
sanitize_websocket_config_update,
websocket_type_requires_auth,
)
def test_sanitize_websocket_config_update_strips_auth_keys():
@ -20,3 +26,12 @@ def test_sanitize_websocket_config_update_strips_auth_keys():
@pytest.mark.parametrize("payload", [None, [], "bad", 42])
def test_sanitize_websocket_config_update_rejects_non_dict(payload):
assert sanitize_websocket_config_update(payload) == {}
def test_websocket_mutator_manifest_is_disjoint_from_public_and_read_types():
assert WEBSOCKET_MUTATOR_TYPES.isdisjoint(WEBSOCKET_PUBLIC_TYPES)
assert WEBSOCKET_MUTATOR_TYPES.isdisjoint(WEBSOCKET_READ_TYPES)
def test_websocket_type_requires_auth_unknown_type_is_not_mutator():
assert websocket_type_requires_auth("not.a.real.type") is False

View file

@ -1,7 +1,89 @@
# SPDX-License-Identifier: 0BSD
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshchatx.src.backend.websocket_config_guard import (
WEBSOCKET_MUTATOR_TYPES,
WEBSOCKET_PUBLIC_TYPES,
WEBSOCKET_READ_TYPES,
websocket_type_requires_auth,
)
def _run_async_immediate(coro):
return asyncio.create_task(coro)
def test_websocket_type_requires_auth_classifies_mutators():
for msg_type in WEBSOCKET_MUTATOR_TYPES:
assert websocket_type_requires_auth(msg_type) is True
def test_websocket_type_requires_auth_allows_ping_and_reads():
for msg_type in WEBSOCKET_PUBLIC_TYPES | WEBSOCKET_READ_TYPES:
assert websocket_type_requires_auth(msg_type) is False
@pytest.mark.asyncio
async def test_websocket_mutator_rejected_without_session_when_auth_enabled(mock_app):
mock_app.config.auth_enabled.set(True)
mock_app.config.auth_password_hash.set("hash")
client = MagicMock()
client.request = MagicMock()
client.send_str = AsyncMock()
with (
patch.object(mock_app, "_websocket_session_authorized", return_value=False),
patch(
"meshchatx.meshchat.AsyncUtils.run_async",
side_effect=_run_async_immediate,
),
):
await mock_app.on_websocket_data_received(
client,
{"type": "keyboard_shortcuts.set", "shortcuts": []},
)
await asyncio.sleep(0)
client.send_str.assert_awaited_once()
payload = client.send_str.await_args.args[0]
assert '"Authentication required"' in payload
@pytest.mark.asyncio
async def test_websocket_read_allowed_without_session_when_auth_enabled(mock_app):
mock_app.config.auth_enabled.set(True)
client = MagicMock()
client.request = MagicMock()
client.send_str = AsyncMock()
with (
patch.object(mock_app, "_websocket_session_authorized", return_value=False),
patch.object(
mock_app,
"get_archived_page_versions",
return_value=[],
),
patch(
"meshchatx.meshchat.AsyncUtils.run_async",
side_effect=_run_async_immediate,
),
):
await mock_app.on_websocket_data_received(
client,
{
"type": "nomadnet.page.archives.get",
"destination_hash": "aa" * 16,
"page_path": "index.mu",
},
)
await asyncio.sleep(0)
client.send_str.assert_awaited()
@pytest.mark.asyncio
async def test_websocket_config_set_ignores_auth_enabled(mock_app):
@ -9,17 +91,18 @@ async def test_websocket_config_set_ignores_auth_enabled(mock_app):
mock_app.config.auth_password_hash.set("existing-hash")
client = object()
await mock_app.on_websocket_data_received(
client,
{
"type": "config.set",
"config": {
"display_name": "Updated Peer",
"auth_enabled": False,
"auth_password_hash": None,
with patch.object(mock_app, "_websocket_session_authorized", return_value=True):
await mock_app.on_websocket_data_received(
client,
{
"type": "config.set",
"config": {
"display_name": "Updated Peer",
"auth_enabled": False,
"auth_password_hash": None,
},
},
},
)
)
assert mock_app.config.auth_enabled.get() is True
assert mock_app.config.auth_password_hash.get() == "existing-hash"