mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
refactor: update landlock sandbox path normalization and improve repository server error handling in demo mode
This commit is contained in:
parent
3e6d52834c
commit
da89eadb3d
10 changed files with 157 additions and 5 deletions
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue