mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-11 00:56:55 +00:00
feat(security): improve URL handling in ConversationViewer and RNCPPage to block unsafe links and validate hashes
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = "<script>alert('xss')</script>";
|
||||
const result = MarkdownRenderer.render(malformed);
|
||||
|
||||
Reference in New Issue
Block a user