diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index a2e09bcb333..c085f7d1e06 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -17,6 +17,14 @@ "core:window:allow-close", "notification:default", "opener:default", + { + "identifier": "opener:allow-open-url", + "allow": [ + { "url": "obsidian://open[?]file=*" }, + { "url": "obsidian://open[?]path=*" }, + { "url": "obsidian://open[?]vault=*" } + ] + }, "websocket:default", "window-state:default", "dialog:default", diff --git a/desktop/src/shared/lib/obsidianLink.test.mjs b/desktop/src/shared/lib/obsidianLink.test.mjs new file mode 100644 index 00000000000..74e6f942d64 --- /dev/null +++ b/desktop/src/shared/lib/obsidianLink.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isObsidianOpenLink } from "./obsidianLink.ts"; + +test("accepts Obsidian open links for vault-relative and absolute targets", () => { + assert.equal( + isObsidianOpenLink( + "obsidian://open?vault=holistics-digest&file=agent-cowork%2Fprojects%2Fanfra%2FANFRA_SETUP_END_TO_END.canvas", + ), + true, + ); + assert.equal(isObsidianOpenLink("obsidian://open?file=README.md"), true); + assert.equal( + isObsidianOpenLink("obsidian://open?path=%2Ftmp%2Fexample.md"), + true, + ); +}); + +test("rejects non-open actions and ambiguous or extensible parameters", () => { + for (const link of [ + "obsidian://new?vault=work&name=note", + "obsidian://search?vault=work&query=secret", + "obsidian://open?vault=work&file=note.md&x-success=https://example.com", + "obsidian://open?vault=first&vault=second", + "obsidian://open?vault=work&path=%2Ftmp%2Fnote.md", + ]) { + assert.equal(isObsidianOpenLink(link), false, link); + } +}); + +test("rejects malformed or unsafe open links", () => { + for (const link of [ + "obsidian://open", + "obsidian://user@open?vault=work", + "obsidian://open/extra?vault=work", + "obsidian://open?vault=work#fragment", + "obsidian://open?vault=%00work", + "not a URL", + ]) { + assert.equal(isObsidianOpenLink(link), false, link); + } +}); diff --git a/desktop/src/shared/lib/obsidianLink.ts b/desktop/src/shared/lib/obsidianLink.ts new file mode 100644 index 00000000000..d4a16e20e23 --- /dev/null +++ b/desktop/src/shared/lib/obsidianLink.ts @@ -0,0 +1,53 @@ +const OBSIDIAN_OPEN_PARAMS = new Set(["file", "path", "vault"]); +const MAX_OBSIDIAN_LINK_LENGTH = 8_192; + +function hasControlCharacters(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +/** + * Accept only Obsidian's read-oriented `open` action. Other Obsidian URI + * actions can create notes, run searches, or invoke plugin-defined behavior, + * so message links must never pass arbitrary `obsidian://` URLs to the OS. + */ +export function isObsidianOpenLink(value: string): boolean { + if (!value || value.length > MAX_OBSIDIAN_LINK_LENGTH) return false; + + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + + if ( + url.protocol !== "obsidian:" || + url.hostname !== "open" || + (url.pathname !== "" && url.pathname !== "/") || + url.username || + url.password || + url.port || + url.hash + ) { + return false; + } + + for (const key of url.searchParams.keys()) { + if (!OBSIDIAN_OPEN_PARAMS.has(key)) return false; + } + for (const key of OBSIDIAN_OPEN_PARAMS) { + if (url.searchParams.getAll(key).length > 1) return false; + } + + const vault = url.searchParams.get("vault")?.trim() ?? ""; + const file = url.searchParams.get("file")?.trim() ?? ""; + const path = url.searchParams.get("path")?.trim() ?? ""; + if ([vault, file, path].some(hasControlCharacters)) return false; + + // `path` is the absolute-path form. `vault`/`file` address a vault-relative + // target and may be used individually or together. + return path ? !vault && !file : Boolean(vault || file); +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 8136ff9f08e..2e243eadbf2 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -538,6 +538,7 @@ import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import { isChannelLink } from "../../features/messages/lib/channelLink.ts"; import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; import { parseEntityLink } from "../lib/entityLink.ts"; +import { isObsidianOpenLink } from "../lib/obsidianLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; const OWNER_HEX = @@ -549,6 +550,7 @@ function buzzDeepLinkUrlTransform(value, key) { if (key !== "href") return defaultUrlTransform(value); if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; + if (isObsidianOpenLink(value)) return value; return defaultUrlTransform(value); } @@ -611,6 +613,22 @@ test("messageLinkUrlTransform: passes http(s) through unchanged", () => { assert.match(html, /href="https:\/\/example\.com\/path"/); }); +test("buzzDeepLinkUrlTransform: preserves validated Obsidian open links", () => { + const link = + "obsidian://open?vault=holistics-digest&file=agent-cowork%2Fprojects%2Fanfra%2FANFRA_SETUP_END_TO_END.canvas"; + const html = renderMarkdown(`[Open in Obsidian](${link})`); + assert.match(html, /href="obsidian:\/\/open\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: strips other Obsidian actions", () => { + const html = renderMarkdown( + "[Create note](obsidian://new?vault=holistics-digest&name=unsafe)", + ); + assert.match(html, /href=""/); + assert.doesNotMatch(html, /obsidian:\/\/new/); +}); + test("messageLinkUrlTransform: preserves legacy buzz://message href", () => { const html = renderMarkdown( "Click [here](buzz://message?channel=abc&id=xyz)", diff --git a/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx b/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx index 00f19fb8dec..703e9390bda 100644 --- a/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx +++ b/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { cn } from "@/shared/lib/cn"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { isObsidianOpenLink } from "@/shared/lib/obsidianLink"; import { MaskedLinkTooltip } from "./MaskedLinkTooltip"; import { @@ -37,6 +38,12 @@ export function ExternalLinkAnchor({ const [menu, setMenu] = React.useState(null); const closeMenu = React.useCallback(() => setMenu(null), []); useDismissMediaContextMenu(Boolean(menu), closeMenu); + const openHref = React.useCallback(() => { + if (!href) return; + void openUrl(href).catch(() => { + toast.error("Failed to open link"); + }); + }, [href]); const anchor = ( { + anchorProps.onClick?.(event); + if (event.defaultPrevented || !href || !isObsidianOpenLink(href)) { + return; + } + event.preventDefault(); + openHref(); + }} onContextMenuCapture={(event) => { if (!href) return; event.preventDefault(); @@ -71,9 +86,7 @@ export function ExternalLinkAnchor({ label: "Open link", onSelect: () => { closeMenu(); - void openUrl(href).catch(() => { - toast.error("Failed to open link"); - }); + openHref(); }, }, { diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index f84984d51e5..29d600e9be9 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -4,6 +4,7 @@ import { defaultUrlTransform } from "react-markdown"; import { isChannelLink } from "@/features/messages/lib/channelLink"; import { isMessageLink } from "@/features/messages/lib/messageLink"; import { parseEntityLink } from "@/shared/lib/entityLink"; +import { isObsidianOpenLink } from "@/shared/lib/obsidianLink"; export function useStableArray(arr: T[]): T[] { const ref = React.useRef(arr); @@ -179,12 +180,14 @@ export function isInsideHiddenSpoiler(element: Element): boolean { * message-link pill renderer). * - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink` * succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs. + * - Valid `obsidian://open` hrefs — preserved for the guarded OS-opener path. * - Everything else delegates to `defaultUrlTransform`. */ export function buzzDeepLinkUrlTransform(value: string, key: string): string { if (key !== "href") return defaultUrlTransform(value); if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; + if (isObsidianOpenLink(value)) return value; return defaultUrlTransform(value); } diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 9a93086cf87..d7380b04f42 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -414,6 +414,44 @@ test("send a message and see it in timeline", async ({ page }) => { ); }); +test("validated Obsidian link opens through the native OS opener", async ({ + page, +}) => { + const link = + "obsidian://open?vault=holistics-digest&file=agent-cowork%2Fprojects%2Fanfra%2FANFRA_SETUP_END_TO_END.canvas"; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(`[Open in Obsidian](${link})`); + await page.getByTestId("send-message").click(); + + const anchor = page + .getByTestId("message-row") + .last() + .getByRole("link", { name: "Open in Obsidian" }); + await expect(anchor).toHaveAttribute("href", link); + await anchor.click(); + + await expect + .poll(() => + page.evaluate(() => { + const calls = + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { url?: string }; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__ ?? []; + return calls + .filter(({ command }) => command === "plugin:opener|open_url") + .map(({ payload }) => payload.url); + }), + ) + .toContain(link); +}); + test("long autolink wraps without widening the timeline", async ({ page }) => { await page.setViewportSize({ width: 800, height: 600 });