feat(security): improvce IPC event handling and WebView security settings to prevent unauthorized access

This commit is contained in:
Ivan 2026-08-14 11:09:43 -05:00
parent 0f62f8107b
commit 879075cb15
No known key found for this signature in database
10 changed files with 264 additions and 40 deletions

View file

@ -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 {
}
}
}

View file

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

View file

@ -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,
};

View file

@ -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,
};

Binary file not shown.

View file

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

View file

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

View file

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

View file

@ -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");
});

View file

@ -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");
});
});
});