diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java index e8c03fb7..ee9e2e51 100644 --- a/android/app/src/main/java/com/meshchatx/MainActivity.java +++ b/android/app/src/main/java/com/meshchatx/MainActivity.java @@ -214,9 +214,11 @@ public class MainActivity extends AppCompatActivity { webSettings.setJavaScriptEnabled(true); webSettings.setDomStorageEnabled(true); webSettings.setDatabaseEnabled(true); - webSettings.setAllowFileAccess(true); + webSettings.setAllowFileAccess(false); webSettings.setAllowContentAccess(true); - webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); + webSettings.setAllowFileAccessFromFileURLs(false); + webSettings.setAllowUniversalAccessFromFileURLs(false); + webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); webSettings.setMediaPlaybackRequiresUserGesture(false); webSettings.setGeolocationEnabled(true); @@ -2031,4 +2033,3 @@ public class MainActivity extends AppCompatActivity { } } } - diff --git a/electron/main.js b/electron/main.js index de6ea44e..bb0bef9b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -26,6 +26,7 @@ const { isLocalBackendUrl, shouldOpenInElectronWindow, shouldAllowInWindowNavigation, + isTrustedIpcEvent, } = require("./mainHelpers"); const { isAllowedShellPath } = require("./shellPathGuard"); const { normalizeExternalUrlForOpen } = require("./safeExternalUrl"); @@ -156,18 +157,27 @@ app.on("open-url", (event, url) => { } }); +function trustedIpcHandle(channel, listener) { + ipcMain.handle(channel, async (event, ...args) => { + if (!isTrustedIpcEvent(event)) { + throw new Error("MeshChatX IPC blocked for this origin"); + } + return listener(event, ...args); + }); +} + // allow fetching app version via ipc -ipcMain.handle("app-version", () => { +trustedIpcHandle("app-version", () => { return app.getVersion(); }); // allow fetching hardware acceleration status via ipc -ipcMain.handle("is-hardware-acceleration-enabled", () => { +trustedIpcHandle("is-hardware-acceleration-enabled", () => { return app.isHardwareAccelerationEnabled(); }); // allow fetching integrity status -ipcMain.handle("get-integrity-status", () => { +trustedIpcHandle("get-integrity-status", () => { return integrityStatus; }); @@ -179,7 +189,7 @@ const { closeAllMessageNotifications, } = require("./messageNotifications.js"); -ipcMain.handle("show-notification", (event, { title, body, silent, destinationHash }) => { +trustedIpcHandle("show-notification", (event, { title, body, silent, destinationHash }) => { const notification = new Notification({ title: title, body: body, @@ -199,7 +209,7 @@ ipcMain.handle("show-notification", (event, { title, body, silent, destinationHa }); }); -ipcMain.handle("close-message-notifications", (_event, destinationHash) => { +trustedIpcHandle("close-message-notifications", (_event, destinationHash) => { if (destinationHash) { return closeMessageNotificationsFor(destinationHash); } @@ -207,7 +217,7 @@ ipcMain.handle("close-message-notifications", (_event, destinationHash) => { }); // Power Management IPC -ipcMain.handle("set-power-save-blocker", (event, enabled) => { +trustedIpcHandle("set-power-save-blocker", (event, enabled) => { if (enabled) { if (activePowerSaveBlockerId === null) { activePowerSaveBlockerId = powerSaveBlocker.start("prevent-app-suspension"); @@ -226,28 +236,28 @@ ipcMain.handle("set-power-save-blocker", (event, enabled) => { // ignore ssl errors app.commandLine.appendSwitch("ignore-certificate-errors"); -ipcMain.handle("backend-http-only", () => { +trustedIpcHandle("backend-http-only", () => { return getUserProvidedArguments(process.argv).includes("--no-https"); }); -ipcMain.handle("backend-runtime-state", () => { +trustedIpcHandle("backend-runtime-state", () => { return getBackendManager().getRuntimeState(); }); -ipcMain.handle("backend-startup-diagnostics", () => { +trustedIpcHandle("backend-startup-diagnostics", () => { return getBackendManager().getStartupDiagnostics(); }); -ipcMain.handle("mark-backend-healthy", () => { +trustedIpcHandle("mark-backend-healthy", () => { getBackendManager().markBackendHealthy(); return { ok: true }; }); -ipcMain.handle("restart-backend", async () => { +trustedIpcHandle("restart-backend", async () => { return await getBackendManager().restartBackend(integrityStatus); }); -ipcMain.handle("open-backend-crash-report", async () => { +trustedIpcHandle("open-backend-crash-report", async () => { const lastCrash = getBackendManager().getLastCrash(); if (!lastCrash) { return { ok: false, error: "No backend crash report is available." }; @@ -256,7 +266,7 @@ ipcMain.handle("open-backend-crash-report", async () => { return { ok: true }; }); -ipcMain.handle("crash-recovery-info", () => { +trustedIpcHandle("crash-recovery-info", () => { const manager = getBackendManager(); const lastCrash = manager.getLastCrash() || {}; const logs = manager.getJoinedLogs(); @@ -271,7 +281,7 @@ ipcMain.handle("crash-recovery-info", () => { }); }); -ipcMain.handle("restore-database-backup", async (_event, backupPath) => { +trustedIpcHandle("restore-database-backup", async (_event, backupPath) => { if (!backupPath || typeof backupPath !== "string") { return { ok: false, error: "No backup path provided." }; } @@ -291,7 +301,7 @@ ipcMain.handle("restore-database-backup", async (_event, backupPath) => { return await getBackendManager().runMaintenanceTask(["--restore-db", backupPath]); }); -ipcMain.handle("pick-database-backup", async () => { +trustedIpcHandle("pick-database-backup", async () => { const win = getDialogParentWindow(); if (!win) { return null; @@ -307,14 +317,14 @@ ipcMain.handle("pick-database-backup", async () => { }); // add support for showing an alert window via ipc -ipcMain.handle("alert", async (event, message) => { +trustedIpcHandle("alert", async (event, message) => { return await dialog.showMessageBox(mainWindow, { message: message, }); }); // add support for showing a confirm window via ipc -ipcMain.handle("confirm", async (event, message) => { +trustedIpcHandle("confirm", async (event, message) => { // show confirm dialog const result = await dialog.showMessageBox(mainWindow, { type: "question", @@ -333,7 +343,7 @@ ipcMain.handle("confirm", async (event, message) => { }); // add support for showing a prompt window via ipc -ipcMain.handle("prompt", async (event, message, defaultValue = "") => { +trustedIpcHandle("prompt", async (event, message, defaultValue = "") => { return await electronPrompt({ title: message, label: "", @@ -346,7 +356,7 @@ ipcMain.handle("prompt", async (event, message, defaultValue = "") => { }); // allow relaunching app via ipc -ipcMain.handle("relaunch", () => { +trustedIpcHandle("relaunch", () => { const relaunchOptions = {}; if (!process.defaultApp && process.platform === "linux" && process.env.APPIMAGE) { relaunchOptions.execPath = process.env.APPIMAGE; @@ -356,7 +366,7 @@ ipcMain.handle("relaunch", () => { quit(); }); -ipcMain.handle("relaunch-emergency", () => { +trustedIpcHandle("relaunch-emergency", () => { const relaunchOptions = { args: process.argv.slice(1).concat(["--emergency"]), }; @@ -368,7 +378,7 @@ ipcMain.handle("relaunch-emergency", () => { quit(); }); -ipcMain.handle("relaunch-auto-recover", () => { +trustedIpcHandle("relaunch-auto-recover", () => { const relaunchOptions = { args: process.argv.slice(1).concat(["--auto-recover"]), }; @@ -380,32 +390,32 @@ ipcMain.handle("relaunch-auto-recover", () => { quit(); }); -ipcMain.handle("shutdown", () => { +trustedIpcHandle("shutdown", () => { isQuiting = true; quit(); }); -ipcMain.handle("get-close-settings", () => { +trustedIpcHandle("get-close-settings", () => { return getCloseSettings(); }); -ipcMain.handle("set-close-settings", (_event, partial) => { +trustedIpcHandle("set-close-settings", (_event, partial) => { return updateCloseSettings(partial || {}); }); -ipcMain.handle("get-screen-security-settings", () => { +trustedIpcHandle("get-screen-security-settings", () => { return getScreenSecuritySettingsPayload(); }); -ipcMain.handle("set-screen-security-enabled", (_event, enabled) => { +trustedIpcHandle("set-screen-security-enabled", (_event, enabled) => { return updateScreenSecurityEnabled(enabled === true); }); -ipcMain.handle("get-memory-usage", async () => { +trustedIpcHandle("get-memory-usage", async () => { return process.getProcessMemoryInfo(); }); -ipcMain.handle("get-battery-status", async () => { +trustedIpcHandle("get-battery-status", async () => { let onBattery = null; try { if (typeof powerMonitor?.isOnBatteryPower === "function") { @@ -455,7 +465,7 @@ ipcMain.handle("get-battery-status", async () => { }); // allow showing a file path in os file manager -ipcMain.handle("showPathInFolder", (event, targetPath) => { +trustedIpcHandle("showPathInFolder", (event, targetPath) => { const ctx = { app, getDefaultStorageDir, @@ -469,7 +479,7 @@ ipcMain.handle("showPathInFolder", (event, targetPath) => { shell.showItemInFolder(targetPath); }); -ipcMain.handle("open-path", (event, targetPath) => { +trustedIpcHandle("open-path", (event, targetPath) => { const ctx = { app, getDefaultStorageDir, @@ -483,7 +493,7 @@ ipcMain.handle("open-path", (event, targetPath) => { return shell.openPath(targetPath); }); -ipcMain.handle("pick-file", async () => { +trustedIpcHandle("pick-file", async () => { const win = getDialogParentWindow(); if (!win) { return null; @@ -497,7 +507,7 @@ ipcMain.handle("pick-file", async () => { return filePaths[0]; }); -ipcMain.handle("pick-directory", async () => { +trustedIpcHandle("pick-directory", async () => { const win = getDialogParentWindow(); if (!win) { return null; diff --git a/electron/mainHelpers.js b/electron/mainHelpers.js index 222a95ca..55db82f4 100644 --- a/electron/mainHelpers.js +++ b/electron/mainHelpers.js @@ -123,6 +123,8 @@ const { isTrustedBlobUrl, isTrustedShellFileUrl, isTrustedShellOrigin, + isTrustedIpcEvent, + senderUrlFromIpcEvent, shouldOpenInElectronWindow, shouldAllowInWindowNavigation, } = require("./shellOrigin"); @@ -136,6 +138,8 @@ module.exports = { isTrustedBlobUrl, isTrustedShellFileUrl, isTrustedShellOrigin, + isTrustedIpcEvent, + senderUrlFromIpcEvent, shouldOpenInElectronWindow, shouldAllowInWindowNavigation, }; diff --git a/electron/shellOrigin.js b/electron/shellOrigin.js index 30a00d4d..3592835d 100644 --- a/electron/shellOrigin.js +++ b/electron/shellOrigin.js @@ -154,11 +154,48 @@ function shouldAllowInWindowNavigation(url) { return isLocalBackendUrl(url); } +/** + * URL of the renderer frame that invoked an ipcMain handler. + * Prefers senderFrame.url, then sender.getURL(). + * @param {unknown} event + * @returns {string} + */ +function senderUrlFromIpcEvent(event) { + if (!event || typeof event !== "object") { + return ""; + } + const frame = event.senderFrame; + if (frame && typeof frame.url === "string" && frame.url) { + return frame.url; + } + const sender = event.sender; + if (sender && typeof sender.getURL === "function") { + try { + const url = sender.getURL(); + return typeof url === "string" ? url : ""; + } catch { + return ""; + } + } + return ""; +} + +/** + * Whether ipcMain may run for this invoke. Same allowlist as preload. + * @param {unknown} event + * @returns {boolean} + */ +function isTrustedIpcEvent(event) { + return isTrustedShellOrigin(senderUrlFromIpcEvent(event)); +} + module.exports = { isLocalBackendUrl, isTrustedBlobUrl, isTrustedShellFileUrl, isTrustedShellOrigin, + isTrustedIpcEvent, + senderUrlFromIpcEvent, shouldOpenInElectronWindow, shouldAllowInWindowNavigation, }; diff --git a/meshchatx.rsm b/meshchatx.rsm index c89b0b2b..d870781c 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/backend/plugin_permissions.py b/meshchatx/src/backend/plugin_permissions.py index 62aaffb6..44f82607 100644 --- a/meshchatx/src/backend/plugin_permissions.py +++ b/meshchatx/src/backend/plugin_permissions.py @@ -8,6 +8,7 @@ import json import os import re from typing import Any +from urllib.parse import urlparse KNOWN_HOOKS = frozenset( { @@ -42,12 +43,20 @@ KNOWN_MANAGERS = frozenset( KNOWN_STORAGE = frozenset({"isolated", "none"}) KNOWN_NETWORK = frozenset({"none", "fetch"}) -_URL_IN_TEXT_RE = re.compile(r"""https?://[^\s"'<>\\)\]]+""") +_URL_IN_TEXT_RE = re.compile(r"""https?://[^\s"'<>\\)]+""") _SCHEME_HOST_RE = re.compile( r"https?://([a-z0-9][-a-z0-9.]*(?:\.[a-z0-9][-a-z0-9.]*)+)", re.IGNORECASE, ) _SCAN_EXTENSIONS = frozenset({".js", ".mjs", ".json", ".wasm", ".ts", ".go", ".wat"}) +_LOOPBACK_OR_UNSPECIFIED_HOSTS = frozenset( + { + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + }, +) def permission_id_for_hook(hook: str) -> str: @@ -183,13 +192,25 @@ def _is_http_url(value: str) -> bool: return lower.startswith("http://") or lower.startswith("https://") +def _hostname_is_loopback_or_unspecified(hostname: str | None) -> bool: + if not hostname: + return False + host = hostname.strip().lower().strip("[]") + return host in _LOOPBACK_OR_UNSPECIFIED_HOSTS + + def _is_external_http_url(value: str) -> bool: if not _is_http_url(value): return False - lower = value.lower() - if "localhost" in lower or "127.0.0.1" in lower or "0.0.0.0" in lower: - return False - if "/_plugins/" in lower or "/api/v1/plugins/" in lower: + try: + parsed = urlparse(value) + hostname = parsed.hostname + except (ValueError, UnicodeError): + return True + scheme = (parsed.scheme or "").lower() + if scheme not in ("http", "https"): + return True + if _hostname_is_loopback_or_unspecified(hostname): return False return True diff --git a/tests/backend/test_plugin_permissions.py b/tests/backend/test_plugin_permissions.py index 2999339c..b240ffe6 100644 --- a/tests/backend/test_plugin_permissions.py +++ b/tests/backend/test_plugin_permissions.py @@ -55,6 +55,18 @@ def test_extract_and_collect_network_endpoints(tmp_path): text = 'const url = "https://api.example.com/v1"; fetch("http://localhost/ignore");' assert extract_urls_from_text(text) == ["https://api.example.com/v1"] + query = 'fetch("https://api.example.com/?x=127.0.0.1")' + assert extract_urls_from_text(query) == ["https://api.example.com/?x=127.0.0.1"] + userinfo = 'fetch("http://127.0.0.1:9337@example.com/v1")' + assert extract_urls_from_text(userinfo) == ["http://127.0.0.1:9337@example.com/v1"] + substring_host = 'fetch("https://notlocalhost.com/a"); fetch("https://127.0.0.1.example.com/")' + extracted = extract_urls_from_text(substring_host) + assert "https://notlocalhost.com/a" in extracted + assert "https://127.0.0.1.example.com/" in extracted + assert extract_urls_from_text('fetch("http://localhost/ignore")') == [] + assert extract_urls_from_text('fetch("http://127.0.0.1:9337/api/v1/plugins/x")') == [] + assert extract_urls_from_text('fetch("http://[::1]:8000/")') == [] + plugin_dir = tmp_path / "plugin" plugin_dir.mkdir() (plugin_dir / "frontend").mkdir() diff --git a/tests/backend/test_ws_origin_filesync_oracles.py b/tests/backend/test_ws_origin_filesync_oracles.py new file mode 100644 index 00000000..13ccb6f7 --- /dev/null +++ b/tests/backend/test_ws_origin_filesync_oracles.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: 0BSD + +"""Security oracles for cross-origin WebSocket access and the FileSync sync-root jail. + +Invariants under test: + +1. A WebSocket upgrade whose Origin is not the local backend origin must be + rejected, regardless of whether HTTP auth is enabled. +2. A cross-origin WebSocket client with no session must not mutate server state. +3. The FileSync sync directory must never resolve to a sensitive identity-tree + directory such as ssl (TLS key material), while ordinary subdirectories + remain selectable. +""" + +import json +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp import WSServerHandshakeError, WSMsgType +from aiohttp.test_utils import TestClient, TestServer + +from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler +from tests.backend.test_http_auth_security import _make_aio_app + +EVIL_ORIGIN = "https://evil.example" + + +def _patch_ws_broadcasts(mock_app): + mock_app.send_config_to_websocket_clients = AsyncMock() + mock_app.send_active_sessions_to_websocket_clients = AsyncMock() + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") +async def test_ws_upgrade_rejects_cross_site_origin(mock_app): + """Invariant 1: hostile Origin must not complete the /ws upgrade.""" + _patch_ws_broadcasts(mock_app) + aio_app = _make_aio_app(mock_app, use_https=False) + + async with TestClient(TestServer(aio_app)) as client: + with pytest.raises(WSServerHandshakeError): + await client.ws_connect("/ws", origin=EVIL_ORIGIN) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") +async def test_cross_origin_ws_mutator_does_not_change_state(mock_app): + """Invariant 2: no session and hostile Origin means no state mutation.""" + _patch_ws_broadcasts(mock_app) + aio_app = _make_aio_app(mock_app, use_https=False) + + async with TestClient(TestServer(aio_app)) as client: + try: + ws = await client.ws_connect("/ws", origin=EVIL_ORIGIN) + except Exception: + return + + probe_action = "oracle_cross_origin_probe" + await ws.send_str( + json.dumps( + { + "type": "keyboard_shortcuts.set", + "action": probe_action, + "keys": ["ctrl+alt+9"], + }, + ), + ) + try: + while True: + msg = await ws.receive(timeout=2) + if msg.type in (WSMsgType.CLOSED, WSMsgType.ERROR): + break + if msg.type == WSMsgType.TEXT and "keyboard_shortcuts" in msg.data: + break + except TimeoutError: + pass + + stored = mock_app.database.misc.get_keyboard_shortcuts( + mock_app.identity.hash.hex(), + ) + assert all(s["action"] != probe_action for s in stored) + + +def _make_filesync_handler(tmp_path): + storage = tmp_path / "identity-storage" + storage.mkdir() + return RnsFilesyncHandler( + reticulum_instance=MagicMock(), + identity=MagicMock(), + storage_dir=str(storage), + ), storage + + +def test_filesync_sync_dir_rejects_identity_ssl_dir(tmp_path): + """Invariant 3: ssl holds the identity TLS key and must not be syncable.""" + handler, storage = _make_filesync_handler(tmp_path) + target = os.path.join(str(storage), "ssl") + os.makedirs(target, exist_ok=True) + assert handler._resolve_sync_directory(target) is None + + +def test_filesync_sync_dir_rejects_identity_root_and_escape(tmp_path): + """Controls: identity root and sibling escape stay rejected today.""" + handler, storage = _make_filesync_handler(tmp_path) + assert handler._resolve_sync_directory(str(storage)) is None + sibling = os.path.join(str(storage), "..", "other-identity") + assert handler._resolve_sync_directory(sibling) is None + + +def test_filesync_sync_dir_allows_plain_subdirectory(tmp_path): + """Control: an ordinary identity-storage subdirectory stays selectable.""" + handler, storage = _make_filesync_handler(tmp_path) + target = os.path.join(str(storage), "shared-docs") + os.makedirs(target, exist_ok=True) + assert handler._resolve_sync_directory(target) is not None diff --git a/tests/electron/mainHelpers.test.js b/tests/electron/mainHelpers.test.js index 1ba6ac06..d5851f5d 100644 --- a/tests/electron/mainHelpers.test.js +++ b/tests/electron/mainHelpers.test.js @@ -86,6 +86,20 @@ describe("electron/mainHelpers", () => { expect(isTrustedShellOrigin("file:///tmp/evil.html")).toBe(false); }); + it("isTrustedIpcEvent uses senderFrame.url then sender.getURL", () => { + const { isTrustedIpcEvent } = require("../../electron/mainHelpers.js"); + expect(isTrustedIpcEvent({ senderFrame: { url: "https://127.0.0.1:9337/" } })).toBe(true); + expect(isTrustedIpcEvent({ senderFrame: { url: "http://127.0.0.1:9337@example.com/" } })).toBe(false); + expect(isTrustedIpcEvent({ senderFrame: { url: "https://example.com/" } })).toBe(false); + expect( + isTrustedIpcEvent({ + sender: { getURL: () => "file:///opt/meshchatx/electron/loading.html" }, + }), + ).toBe(true); + expect(isTrustedIpcEvent({})).toBe(false); + expect(isTrustedIpcEvent(null)).toBe(false); + }); + it("parseArgvFlag reads a value following the flag", () => { expect(parseArgvFlag(["--storage-dir", "/mnt/persist"], "--storage-dir")).toBe("/mnt/persist"); }); diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js index c75769e3..3686dd21 100644 --- a/tests/frontend/behaviorContracts.test.js +++ b/tests/frontend/behaviorContracts.test.js @@ -182,12 +182,21 @@ describe("behavior contracts: user-visible wiring must stay connected", () => { const origin = readSource("electron/shellOrigin.js"); expect(origin).not.toMatch(/startsWith\(\s*["']https?:\/\/127/); expect(origin).toContain("parsed.username"); + expect(origin).toContain("isTrustedIpcEvent"); + expect(main).toContain("function trustedIpcHandle"); + expect(main).toContain("isTrustedIpcEvent"); + expect(main).not.toMatch(/ipcMain\.handle\("/); }); it("Android WebView opens external http(s) in the system browser", () => { const src = readSource("android/app/src/main/java/com/meshchatx/MainActivity.java"); expect(src).toContain("openExternalBrowserUri"); expect(src).toContain("Intent.ACTION_VIEW"); + expect(src).toContain("setAllowFileAccess(false)"); + expect(src).toContain("setAllowFileAccessFromFileURLs(false)"); + expect(src).toContain("setAllowUniversalAccessFromFileURLs(false)"); + expect(src).toContain("MIXED_CONTENT_NEVER_ALLOW"); + expect(src).not.toContain("MIXED_CONTENT_ALWAYS_ALLOW"); }); }); });