feat(security): improve URL handling in ConversationViewer and RNCPPage to block unsafe links and validate hashes

This commit is contained in:
Ivan
2026-04-23 04:51:12 -05:00
parent c59e50a24e
commit e8f6a4e059
6 changed files with 165 additions and 16 deletions
@@ -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 {
+124 -3
View File
@@ -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" });
+15
View File
@@ -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(
+1 -1
View File
@@ -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);