refactor: update landlock sandbox path normalization and improve repository server error handling in demo mode

This commit is contained in:
Ivan 2026-08-13 14:33:24 -05:00
parent 3e6d52834c
commit da89eadb3d
No known key found for this signature in database
10 changed files with 157 additions and 5 deletions

Binary file not shown.

View file

@ -2119,6 +2119,9 @@ class ReticulumMeshChat:
self.contexts.clear()
self.current_context = None
self.running = False
# Same drop as teardown_identity. Reload and zip restore must not keep
# Nomad or RNS Link sessions from the torn-down identities.
self._clear_mesh_link_caches()
gc.collect()
async def _send_rns_reload_status(

View file

@ -192,8 +192,11 @@ def register_repository_server_routes(routes, app):
if not mgr:
return web.json_response({"error": "Unavailable"}, status=503)
try:
app._require_outbound_http("repository bundled wheel refresh")
result = await asyncio.to_thread(mgr.refresh_bundled_wheels)
return web.json_response(result)
except OutboundHttpBlockedError as e:
return web.json_response({"error": str(e)}, status=403)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)

View file

@ -193,6 +193,19 @@ def register_websocket_upgrade_routes(routes, app):
)
await websocket_response.prepare(request)
if getattr(app, "demo_mode", False):
await websocket_response.send_str(
json.dumps(
{
"type": "error",
"message": "Demo mode is read-only",
"code": "demo_readonly",
},
),
)
await websocket_response.close()
return websocket_response
# Chaquopy Android and headless/web deployments have no usable LXST
# host audio device, so always allow the websocket bridge.
web_audio_allowed = (

View file

@ -489,6 +489,35 @@ def _add_path_beneath_rule(
os.close(fd)
def _normalize_extra_landlock_read_root(path: str) -> str | None:
"""Return a realpath extra read root, or None when it would widen the jail.
Rejects the filesystem root and the user home directory itself. A Sideband
plugins folder may live under home. Home or / as the extra root would let
a compromised plugin read ssh keys and the rest of the host tree.
"""
if not isinstance(path, str) or not path.strip():
return None
try:
resolved = os.path.realpath(os.path.abspath(os.path.expanduser(path.strip())))
except OSError:
return None
if not os.path.isdir(resolved):
return None
fs_root = os.path.realpath(os.path.abspath(os.sep))
if resolved == fs_root:
return None
home = os.path.expanduser("~")
if home and home != "~":
try:
home_real = os.path.realpath(os.path.abspath(home))
except OSError:
home_real = ""
if home_real and resolved == home_real:
return None
return resolved
def extra_read_roots_from_app(app) -> list[str]:
"""Sideband command-plugin dirs chosen in settings, if they exist on disk.
@ -505,8 +534,8 @@ def extra_read_roots_from_app(app) -> list[str]:
raw = None
if not raw:
return []
resolved = os.path.abspath(os.path.expanduser(str(raw)))
if os.path.isdir(resolved):
resolved = _normalize_extra_landlock_read_root(str(raw))
if resolved:
return [resolved]
return []
@ -562,8 +591,8 @@ def apply_landlock_sandbox(
for extra in extra_read_roots or []:
if not extra:
continue
resolved = os.path.abspath(os.path.expanduser(str(extra)))
if os.path.isdir(resolved) and resolved not in read_roots:
resolved = _normalize_extra_landlock_read_root(str(extra))
if resolved and resolved not in read_roots:
read_roots.append(resolved)
for root in read_roots:
_add_path_beneath_rule(

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: 0BSD
import secrets
from unittest.mock import MagicMock
import bcrypt
import pytest
@ -153,3 +154,20 @@ async def test_privacy_mode_blocks_map_export(mock_app):
headers=headers,
)
assert r.status == 403
@pytest.mark.asyncio
@pytest.mark.usefixtures("require_loopback_tcp")
async def test_privacy_mode_blocks_repository_refresh(mock_app):
mock_app.config.privacy_mode_enabled.set(True)
mock_app.repository_server_manager = MagicMock()
aio_app = _make_aio_app(mock_app, use_https=False)
async with TestClient(TestServer(aio_app)) as client:
headers = await fetch_api_csrf_headers(client)
r = await client.post(
"/api/v1/repository-server/refresh-bundled",
headers=headers,
)
assert r.status == 403
mock_app.repository_server_manager.refresh_bundled_wheels.assert_not_called()

View file

@ -603,5 +603,16 @@ def test_extra_read_roots_from_app_uses_command_plugins_path(tmp_path):
assert ll.extra_read_roots_from_app(_App(str(missing))) == []
present = tmp_path / "plugins"
present.mkdir()
assert ll.extra_read_roots_from_app(_App(str(present))) == [str(present)]
assert ll.extra_read_roots_from_app(_App(str(present))) == [
os.path.realpath(str(present)),
]
assert ll.extra_read_roots_from_app(_App(None)) == []
assert ll.extra_read_roots_from_app(_App(os.sep)) == []
home = os.path.expanduser("~")
if home and home != "~" and os.path.isdir(home):
assert ll.extra_read_roots_from_app(_App(home)) == []
nested = tmp_path / "homeish" / "plugins"
nested.mkdir(parents=True)
assert ll.extra_read_roots_from_app(_App(str(nested))) == [
os.path.realpath(str(nested)),
]

View file

@ -313,3 +313,26 @@ def test_apply_landlock_allows_user_local_argospm_list(tmp_path):
storage=storage,
)
assert_probe_ok(result)
def test_normalize_extra_landlock_read_root_rejects_overbroad(tmp_path):
plugins = tmp_path / "sideband-plugins"
plugins.mkdir()
assert ll._normalize_extra_landlock_read_root(str(plugins)) == os.path.realpath(
str(plugins),
)
assert ll._normalize_extra_landlock_read_root(os.sep) is None
assert ll._normalize_extra_landlock_read_root("") is None
home = os.path.expanduser("~")
if home and home != "~" and os.path.isdir(home):
assert ll._normalize_extra_landlock_read_root(home) is None
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
def test_normalize_extra_landlock_read_root_rejects_symlink_to_fs_root(tmp_path):
link = tmp_path / "rootlink"
try:
link.symlink_to(os.sep)
except OSError:
pytest.skip("cannot create symlink to filesystem root")
assert ll._normalize_extra_landlock_read_root(str(link)) is None

View file

@ -910,3 +910,33 @@ async def test_reload_teardown_stops_all_context_services(mock_rns, temp_dir):
ctx_b.teardown.assert_called_once()
assert app.contexts == {}
assert app.current_context is None
def test_teardown_all_contexts_for_reload_clears_mesh_link_caches(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.contexts = {}
app.current_context = None
app.page_node_manager.teardown = MagicMock()
app._clear_mesh_link_caches = MagicMock()
app._teardown_all_contexts_for_reload()
app._clear_mesh_link_caches.assert_called_once()

View file

@ -192,3 +192,25 @@ async def test_telephone_audio_ws_bad_json_does_not_crash_handler(web_audio_app)
assert pong["type"] == "pong"
bridge.detach_client.assert_called_once()
@pytest.mark.asyncio
async def test_telephone_audio_ws_demo_mode_rejects_before_attach(web_audio_app):
from meshchatx.src.backend.demo_mode import DEMO_READONLY_CODE
web_audio_app.demo_mode = True
bridge = _bridge_with_clients()
web_audio_app.web_audio_bridge = bridge
aio_app = _build_aio_app(web_audio_app)
async with TestClient(TestServer(aio_app)) as client:
ws = await client.ws_connect("/ws/telephone/audio")
msg = await ws.receive_json()
await ws.send_bytes(b"\x01\x02\x03")
await ws.close()
assert msg["type"] == "error"
assert msg["code"] == DEMO_READONLY_CODE
bridge.send_status.assert_not_called()
bridge.attach_client.assert_not_called()
bridge.push_client_frame.assert_not_called()