mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(electron): add in-window navigation guard to restrict external URL navigation and improve security
This commit is contained in:
parent
407b651248
commit
17e20e7724
9 changed files with 222 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue