feat(electron): add in-window navigation guard to restrict external URL navigation and improve security

This commit is contained in:
Ivan 2026-06-22 17:31:15 -05:00
parent 407b651248
commit 17e20e7724
No known key found for this signature in database
9 changed files with 222 additions and 11 deletions

View file

@ -22,6 +22,7 @@ const {
formatRenderProcessGoneDetails,
isLocalBackendUrl,
shouldOpenInElectronWindow,
shouldAllowInWindowNavigation,
} = require("./mainHelpers");
const { isAllowedShellPath } = require("./shellPathGuard");
const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
@ -370,6 +371,19 @@ function attachWindowOpenHandler(browserWindow) {
browserWindow.webContents.setWindowOpenHandler(({ url }) => handleWindowOpenRequest(url));
}
function attachInWindowNavigationGuard(browserWindow) {
browserWindow.webContents.on("will-navigate", (event, url) => {
if (shouldAllowInWindowNavigation(url)) {
return;
}
event.preventDefault();
const safe = normalizeExternalUrlForOpen(url);
if (safe) {
shell.openExternal(safe);
}
});
}
function attachDevToolsF12Shortcut(browserWindow) {
browserWindow.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || input.key !== "F12") {
@ -610,6 +624,7 @@ app.whenReady().then(async () => {
attachDefaultContextMenu(browserWindow);
attachDevToolsF12Shortcut(browserWindow);
attachWindowOpenHandler(browserWindow);
attachInWindowNavigationGuard(browserWindow);
});
// Security: Enforce CSP for all requests as a shell-level fallback

View file

@ -69,9 +69,26 @@ function shouldOpenInElectronWindow(url) {
return url.includes("#/popout/");
}
/**
* Whether the main frame may navigate to this URL inside Electron (local app shell).
* External http(s) links must open in the system browser instead.
* @param {unknown} url
* @returns {boolean}
*/
function shouldAllowInWindowNavigation(url) {
if (!url || typeof url !== "string") {
return false;
}
if (url.startsWith("blob:")) {
return true;
}
return isLocalBackendUrl(url);
}
module.exports = {
getUserProvidedArguments,
formatRenderProcessGoneDetails,
isLocalBackendUrl,
shouldOpenInElectronWindow,
shouldAllowInWindowNavigation,
};

View file

@ -3,4 +3,4 @@
"""Reticulum MeshChatX - A mesh network communications app."""
# Synced from package.json via scripts/sync_version.js (also writes meshchatx/src/version.py).
__version__ = "4.7.2"
__version__ = "4.7.2"

View file

@ -10999,7 +10999,13 @@ class ReticulumMeshChat:
node = self.page_node_manager.get_node(node_id)
if not node:
return web.json_response({"message": "Node not found"}, status=404)
data = await request.json()
try:
data = await request.json()
except Exception as e:
return web.json_response(
{"message": f"Invalid request body: {e}"},
status=400,
)
name = data.get("name", "")
content = data.get("content", "")
if not name:
@ -11011,6 +11017,16 @@ class ReticulumMeshChat:
saved_name = node.add_page(name, content)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
except OSError as e:
return web.json_response(
{"message": f"Failed to write page: {e}"},
status=500,
)
except Exception as e:
return web.json_response(
{"message": f"Failed to save page: {e}"},
status=500,
)
return web.json_response({"name": saved_name, "message": "Page saved"})
@routes.get("/api/v1/page-nodes/{node_id}/pages/{page_name}")

View file

@ -314,7 +314,12 @@ class PageNode:
with open(page_path, "wb") as f:
f.write(content)
if self.running:
self._register_page_handler(name)
try:
self._register_page_handler(name)
except Exception as e:
raise RuntimeError(
f"Page written but failed to register on mesh: {e}"
) from e
return name
def remove_page(self, name):

View file

@ -162,6 +162,7 @@
<div
ref="previewRef"
class="flex-1 overflow-auto text-zinc-100 p-4 font-mono text-sm whitespace-pre-wrap wrap-break-word nodeContainer"
@click="onPreviewClick"
v-html="renderedContent"
></div>
<!-- eslint-enable vue/no-v-html -->
@ -176,8 +177,12 @@ import MicronParser from "../../js/MicronParser.js";
import { micronStorage } from "../../js/MicronStorage";
import { preloadNomadMicronWasm, isMicronWasmBundled } from "../../js/MicronWasmLoader";
import DialogUtils from "../../js/DialogUtils";
import LinkUtils from "../../js/LinkUtils.js";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
const NOMAD_DESTINATION_HASH = /^[a-fA-F0-9]{32}$/;
const PAGE_EXTENSIONS = [".mu", ".html", ".md", ".txt"];
export default {
name: "MicronEditorPage",
components: {
@ -227,6 +232,101 @@ export default {
this.renderActiveTab();
this.saveContent();
},
openExternalHttpUrl(url) {
if (!url) {
return;
}
window.open(url, "_blank", "noopener,noreferrer");
},
openNomadDestination(destination) {
const raw = String(destination || "")
.trim()
.replace(/^nomadnetwork:\/\//i, "")
.replace(/^lxmf:\/\//i, "");
if (!raw) {
return;
}
const httpHref = LinkUtils.httpUrlHrefOrNull(raw);
if (httpHref) {
this.openExternalHttpUrl(httpHref);
return;
}
const [hash, ...pathParts] = raw.split(":");
if (!NOMAD_DESTINATION_HASH.test(hash)) {
return;
}
const pathPart = pathParts.join(":") || "/page/index.mu";
const pagePath = pathPart.split("`")[0].split("?")[0];
this.$router.push({
name: "nomadnetwork",
params: { destinationHash: hash },
query: { path: pagePath },
});
},
onPreviewClick(event) {
const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
if (nomadLink) {
event.preventDefault();
event.stopPropagation();
const url = nomadLink.getAttribute("data-nomadnet-url");
if (url) {
this.openNomadDestination(url);
}
return;
}
const externalAnchor = event.target.closest("a[href]");
if (externalAnchor && !externalAnchor.classList.contains("nomadnet-link")) {
const href = externalAnchor.getAttribute("href");
const httpHref = href ? LinkUtils.httpUrlHrefOrNull(href.trim()) : null;
if (httpHref) {
event.preventDefault();
event.stopPropagation();
this.openExternalHttpUrl(httpHref);
return;
}
}
const fragAnchor = event.target.closest("a[href]");
if (
fragAnchor &&
fragAnchor.getAttribute("href") &&
fragAnchor.getAttribute("href") !== "#" &&
fragAnchor.getAttribute("href").startsWith("#") &&
!fragAnchor.getAttribute("data-nomadnet-url")
) {
event.preventDefault();
event.stopPropagation();
const raw = fragAnchor.getAttribute("href").slice(1);
const id = decodeURIComponent(raw);
const root = this.$refs.previewRef;
const el = root ? root.querySelector(`#${CSS.escape(id)}`) : document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
return;
}
const nodeLink = event.target.closest('[data-action="openNode"]');
if (nodeLink) {
event.preventDefault();
event.stopPropagation();
const destination = nodeLink.getAttribute("data-destination");
if (destination) {
this.openNomadDestination(destination);
}
return;
}
const anchor = event.target.closest("a[href]");
if (anchor) {
const href = (anchor.getAttribute("href") || "").trim();
if (href && href !== "#" && !href.startsWith("#")) {
event.preventDefault();
event.stopPropagation();
}
}
},
renderActiveTab() {
if (this.tabs.length === 0 || !this.tabs[this.activeTabIndex]) {
this.renderedContent = "";
@ -958,11 +1058,34 @@ ${b}=
},
tabNameToPageBase(tab) {
let name = (tab.name || "").trim().replace(/\s+/g, "_");
if (name.toLowerCase().endsWith(".mu")) {
name = name.slice(0, -3);
const lower = name.toLowerCase();
for (const ext of PAGE_EXTENSIONS) {
if (lower.endsWith(ext)) {
return name.slice(0, -ext.length);
}
}
return name;
},
pageBaseWithExtension(base, tab) {
const trimmed = String(base || "").trim();
if (!trimmed) {
return trimmed;
}
const lower = trimmed.toLowerCase();
for (const ext of PAGE_EXTENSIONS) {
if (lower.endsWith(ext)) {
return trimmed;
}
}
const tabName = (tab?.name || "").trim();
const tabLower = tabName.toLowerCase();
for (const ext of PAGE_EXTENSIONS) {
if (tabLower.endsWith(ext)) {
return `${trimmed}${ext}`;
}
}
return trimmed;
},
isUnsetMicronTabName(name) {
const trimmed = (name || "").trim();
if (!trimmed) {
@ -994,8 +1117,11 @@ ${b}=
return null;
}
let base = String(entered).trim().replace(/\s+/g, "_");
if (base.toLowerCase().endsWith(".mu")) {
base = base.slice(0, -3);
const lower = base.toLowerCase();
for (const ext of PAGE_EXTENSIONS) {
if (lower.endsWith(ext)) {
return base;
}
}
return base || null;
},
@ -1007,12 +1133,13 @@ ${b}=
if (!pageBase) {
return;
}
const publishName = this.pageBaseWithExtension(pageBase, tab);
const response = await window.api.post(`/api/v1/page-nodes/${node.node_id}/pages`, {
name: pageBase,
name: publishName,
content: tab.content,
});
this.showPublishMenu = false;
const savedName = response.data?.name || `${pageBase}.mu`;
const savedName = response.data?.name || publishName;
DialogUtils.alert(
this.$t("tools.micron_editor.publish_published", { page: savedName, server: node.name })
);
@ -1042,9 +1169,10 @@ ${b}=
if (!pageBase) {
continue;
}
const publishName = this.pageBaseWithExtension(pageBase, tab);
try {
const response = await window.api.post(`/api/v1/page-nodes/${node.node_id}/pages`, {
name: pageBase,
name: publishName,
content: tab.content,
});
const savedName = response.data?.name;

View file

@ -43,7 +43,9 @@ def mock_app():
ReticulumMeshChat.process_incoming_telemetry.__get__(app, ReticulumMeshChat)
)
app._resolve_location_for_telemetry = (
ReticulumMeshChat._resolve_location_for_telemetry.__get__(app, ReticulumMeshChat)
ReticulumMeshChat._resolve_location_for_telemetry.__get__(
app, ReticulumMeshChat
)
)
return app

View file

@ -42,4 +42,13 @@ describe("electron/mainHelpers", () => {
expect(shouldOpenInElectronWindow("https://example.com/#/popout/map")).toBe(false);
expect(shouldOpenInElectronWindow("")).toBe(false);
});
it("shouldAllowInWindowNavigation keeps local backend URLs in Electron", () => {
const { shouldAllowInWindowNavigation } = require("../../electron/mainHelpers.js");
expect(shouldAllowInWindowNavigation("https://127.0.0.1:9337/#/tools/micron-editor")).toBe(true);
expect(shouldAllowInWindowNavigation("http://localhost:9337/")).toBe(true);
expect(shouldAllowInWindowNavigation("blob:https://127.0.0.1:9337/print")).toBe(true);
expect(shouldAllowInWindowNavigation("https://example.com/")).toBe(false);
expect(shouldAllowInWindowNavigation("file:///etc/passwd")).toBe(false);
});
});

View file

@ -146,6 +146,25 @@ describe("MicronEditorPage.vue", () => {
});
});
it("pageBaseWithExtension preserves html tab extension when publishing", async () => {
const wrapper = mountMicronEditorPage();
await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
const tab = { name: "Landing.html", content: "<p>hi</p>" };
expect(wrapper.vm.pageBaseWithExtension("landing", tab)).toBe("landing.html");
});
it("onPreviewClick opens http links externally", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const wrapper = mountMicronEditorPage();
await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
const preview = wrapper.find(".nodeContainer");
preview.element.innerHTML = '<a href="https://example.com/page.html">Example</a>';
const link = preview.find("a");
await link.trigger("click");
expect(openSpy).toHaveBeenCalledWith("https://example.com/page.html", "_blank", "noopener,noreferrer");
openSpy.mockRestore();
});
it("resets all content", async () => {
DialogUtils.confirm.mockResolvedValue(true);
const wrapper = mountMicronEditorPage();