diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 096cd71..dc362a7 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -1521,6 +1521,7 @@ import ToastUtils from "../../js/ToastUtils"; import PaperMessageModal from "./PaperMessageModal.vue"; import GlobalState from "../../js/GlobalState"; import MarkdownRenderer from "../../js/MarkdownRenderer"; +import LinkUtils from "../../js/LinkUtils"; import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from "../../js/mapLinkUtils.js"; import { COLUMBA_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions"; import { createOutboundQueue } from "../../js/outboundSendQueue"; @@ -2161,6 +2162,7 @@ export default { return base; }, async handleMessageClick(event) { + const hex32 = /^[a-fA-F0-9]{32}$/; const nomadnetLink = event.target.closest(".nomadnet-link"); if (nomadnetLink) { event.preventDefault(); @@ -2168,6 +2170,9 @@ export default { if (url) { const [hash, ...pathParts] = url.split(":"); const path = pathParts.join(":"); + if (!hex32.test(hash)) { + return; + } const routeName = this.$route.meta.isPopout ? "nomadnetwork-popout" : "nomadnetwork"; this.$router.push({ name: routeName, @@ -2182,7 +2187,7 @@ export default { if (lxmfLink) { event.preventDefault(); const address = lxmfLink.getAttribute("data-lxmf-address"); - if (address) { + if (address && hex32.test(address)) { this.$router.push({ name: "messages", params: { destinationHash: address }, @@ -2196,8 +2201,9 @@ export default { return; } - const href = String(standardLink.getAttribute("href") || "").trim(); - if (!/^https?:\/\//i.test(href)) { + const hrefRaw = String(standardLink.getAttribute("href") || "").trim(); + const safeHttp = LinkUtils.httpUrlHrefOrNull(hrefRaw); + if (!safeHttp) { event.preventDefault(); return; } @@ -2205,14 +2211,14 @@ export default { event.preventDefault(); if (this.isStrangerPeer && this.warnOnStrangerLinksEnabled) { const proceed = await DialogUtils.confirm( - this.$t("messages.stranger_link_open_confirm", { url: href }) + this.$t("messages.stranger_link_open_confirm", { url: safeHttp }) ); if (!proceed) { return; } } - window.open(href, "_blank", "noopener"); + window.open(safeHttp, "_blank", "noopener,noreferrer"); }, async updatePropagationNodeStatus() { try { diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue index ec24ec1..a108337 100644 --- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue +++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue @@ -836,6 +836,7 @@ export default { return MarkdownRenderer.render(text); }, handleMessageClick(event) { + const hex32 = /^[a-fA-F0-9]{32}$/; const nomadnetLink = event.target.closest(".nomadnet-link"); if (nomadnetLink) { event.preventDefault(); @@ -843,19 +844,22 @@ export default { if (url) { const [hash, ...pathParts] = url.split(":"); const path = pathParts.join(":"); - this.$router.push({ - name: "nomadnetwork", - params: { destinationHash: hash }, - query: { path: path }, - }); + if (hex32.test(hash)) { + this.$router.push({ + name: "nomadnetwork", + params: { destinationHash: hash }, + query: { path: path }, + }); + } } + return; } const lxmfLink = event.target.closest(".lxmf-link"); if (lxmfLink) { event.preventDefault(); const address = lxmfLink.getAttribute("data-lxmf-address"); - if (address) { + if (address && hex32.test(address)) { this.$router.push({ name: "messages", params: { destinationHash: address }, diff --git a/meshchatx/src/frontend/js/KeyboardShortcuts.js b/meshchatx/src/frontend/js/KeyboardShortcuts.js index d6bd99d..0baf239 100644 --- a/meshchatx/src/frontend/js/KeyboardShortcuts.js +++ b/meshchatx/src/frontend/js/KeyboardShortcuts.js @@ -155,8 +155,11 @@ class KeyboardShortcuts { } setShortcuts(shortcuts) { - // Merge with defaults to ensure all actions have a description const defaults = this.getDefaultShortcuts(); + if (!Array.isArray(shortcuts) || shortcuts.length === 0) { + this.shortcuts = defaults.map((d) => ({ ...d })); + return; + } this.shortcuts = shortcuts.map((s) => { const def = defaults.find((d) => d.action === s.action); return { diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index ee26b44..8362dcc 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -67,7 +67,7 @@ describe("ConversationViewer.vue", () => { WebSocketConnection.destroy(); }); - const mountConversationViewer = (props = {}) => { + const mountConversationViewer = (props = {}, extraMocks = {}) => { return mount(ConversationViewer, { props: { selectedPeer: { destination_hash: "test-hash", display_name: "Test Peer" }, @@ -79,6 +79,9 @@ describe("ConversationViewer.vue", () => { directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, mocks: { $t: (key) => key, + $route: { meta: {} }, + $router: { push: vi.fn() }, + ...extraMocks, }, stubs: { MaterialDesignIcon: true, @@ -195,7 +198,7 @@ describe("ConversationViewer.vue", () => { expect(event.preventDefault).toHaveBeenCalled(); expect(DialogUtils.confirm).toHaveBeenCalledWith("messages.stranger_link_open_confirm"); - expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener"); + expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener,noreferrer"); }); it("does not open stranger http link when warning confirm is rejected", async () => { @@ -232,7 +235,7 @@ describe("ConversationViewer.vue", () => { expect(event.preventDefault).toHaveBeenCalled(); expect(DialogUtils.confirm).not.toHaveBeenCalled(); - expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener"); + expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener,noreferrer"); }); it("blocks non-http href payloads like data urls in message anchors", async () => { @@ -253,6 +256,124 @@ describe("ConversationViewer.vue", () => { expect(window.open).not.toHaveBeenCalled(); }); + it("blocks https-looking anchors that are not a single safe http(s) URL (LinkUtils)", async () => { + const wrapper = mountConversationViewer(); + wrapper.vm.isStrangerPeer = false; + window.open.mockClear(); + + const anchor = document.createElement("a"); + anchor.setAttribute("href", "https://example.com javascript:alert(1)"); + const event = { target: anchor, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(window.open).not.toHaveBeenCalled(); + }); + + it("blocks javascript: href in rendered message anchors", async () => { + const wrapper = mountConversationViewer(); + window.open.mockClear(); + + const anchor = document.createElement("a"); + anchor.setAttribute("href", "javascript:alert(1)"); + const event = { target: anchor, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(window.open).not.toHaveBeenCalled(); + }); + + it("does not router.push for nomadnet link when destination hash is not 32 hex chars", async () => { + const push = vi.fn(); + const wrapper = mountConversationViewer( + {}, + { + $router: { push }, + $route: { meta: { isPopout: false } }, + } + ); + + const a = document.createElement("a"); + a.className = "nomadnet-link"; + a.setAttribute("data-nomadnet-url", "not32hexchars:/page/index.mu"); + const event = { target: a, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); + }); + + it("router.push for valid nomadnet data-nomadnet-url", async () => { + const push = vi.fn(); + const wrapper = mountConversationViewer( + {}, + { + $router: { push }, + $route: { meta: { isPopout: false } }, + } + ); + const hash = "1dfeb0d794963579bd21ac8f153c77a4"; + const a = document.createElement("a"); + a.className = "nomadnet-link"; + a.setAttribute("data-nomadnet-url", `${hash}:/page/index.mu`); + const event = { target: a, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(push).toHaveBeenCalledWith({ + name: "nomadnetwork", + params: { destinationHash: hash }, + query: { path: "/page/index.mu" }, + }); + }); + + it("does not router.push for lxmf link when address is not 32 hex chars", async () => { + const push = vi.fn(); + const wrapper = mountConversationViewer( + {}, + { + $router: { push }, + $route: { meta: {} }, + } + ); + + const a = document.createElement("a"); + a.className = "lxmf-link"; + a.setAttribute("data-lxmf-address", "abcdabcdabcdabcdabcdabcdabcdab"); + const event = { target: a, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); + }); + + it("router.push for valid lxmf data-lxmf-address", async () => { + const push = vi.fn(); + const wrapper = mountConversationViewer( + {}, + { + $router: { push }, + $route: { meta: {} }, + } + ); + const hash = "1dfeb0d794963579bd21ac8f153c77a4"; + const a = document.createElement("a"); + a.className = "lxmf-link"; + a.setAttribute("data-lxmf-address", hash); + const event = { target: a, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(push).toHaveBeenCalledWith({ + name: "messages", + params: { destinationHash: hash }, + }); + }); + it("onComposerImageDrop ignores non-image files", () => { const wrapper = mountConversationViewer(); const pdf = new File([""], "doc.pdf", { type: "application/pdf" }); diff --git a/tests/frontend/KeyboardShortcuts.test.js b/tests/frontend/KeyboardShortcuts.test.js index f0a5928..e5615a4 100644 --- a/tests/frontend/KeyboardShortcuts.test.js +++ b/tests/frontend/KeyboardShortcuts.test.js @@ -119,6 +119,21 @@ describe("KeyboardShortcuts", () => { KeyboardShortcuts.setShortcuts(KeyboardShortcuts.getDefaultShortcuts()); }); + it("setShortcuts ignores non-array payloads and keeps defaults", () => { + KeyboardShortcuts.setShortcuts(null); + dispatchKeyDown({ key: "1", altKey: true, code: "Digit1" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_messages"); + KeyboardShortcuts.setShortcuts({ action: "nav_map", keys: ["alt", "3"] }); + dispatchKeyDown({ key: "3", altKey: true, code: "Digit3" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_map"); + }); + + it("setShortcuts treats empty array as reset to defaults", () => { + KeyboardShortcuts.setShortcuts([]); + dispatchKeyDown({ key: "2", altKey: true, code: "Digit2" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_nomad"); + }); + it("saveShortcut sends keyboard_shortcuts.set over WebSocket", async () => { await KeyboardShortcuts.saveShortcut("nav_messages", ["alt", "q"]); expect(wsSend).toHaveBeenCalledWith( diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js index 559022c..b8ea91d 100644 --- a/tests/frontend/MarkdownRenderer.test.js +++ b/tests/frontend/MarkdownRenderer.test.js @@ -141,7 +141,7 @@ describe("MarkdownRenderer.js", () => { }); }); - describe("security: XSS prevention", () => { + describe("security: XSS prevention (MarkdownRenderer output is v-html in ConversationMessageEntry)", () => { it("escapes script tags", () => { const malformed = ""; const result = MarkdownRenderer.render(malformed);