From bc2fe331dcb454d14a2f44241d5cc01fb6c2055b Mon Sep 17 00:00:00 2001 From: npmur Date: Wed, 12 Aug 2026 10:36:44 -0400 Subject: [PATCH] fix(desktop): allow Obsidian deep links in openExternal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat markdown links like obsidian://open?vault=…&file=… were rejected with "Invalid external URL" because openExternalUrl only allowed http/https/mailto. Extract an explicit shell-open scheme allowlist and add obsidian: so vault deep links launch the local Obsidian app when registered with the OS. Dangerous schemes stay denied. --- apps/desktop/electron/external-url.test.ts | 51 ++++++++++++++++++++++ apps/desktop/electron/external-url.ts | 36 +++++++++++++++ apps/desktop/electron/main.ts | 5 ++- 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/external-url.test.ts create mode 100644 apps/desktop/electron/external-url.ts diff --git a/apps/desktop/electron/external-url.test.ts b/apps/desktop/electron/external-url.test.ts new file mode 100644 index 0000000000000..a1265bea32dad --- /dev/null +++ b/apps/desktop/electron/external-url.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { isAllowedShellOpenExternalUrl, SHELL_OPEN_EXTERNAL_PROTOCOLS } from './external-url' + +test('allowlist keeps the original web + mail schemes', () => { + assert.equal(SHELL_OPEN_EXTERNAL_PROTOCOLS.has('http:'), true) + assert.equal(SHELL_OPEN_EXTERNAL_PROTOCOLS.has('https:'), true) + assert.equal(SHELL_OPEN_EXTERNAL_PROTOCOLS.has('mailto:'), true) +}) + +test('isAllowedShellOpenExternalUrl accepts http(s) and mailto', () => { + assert.equal(isAllowedShellOpenExternalUrl('https://example.com/path'), true) + assert.equal(isAllowedShellOpenExternalUrl('http://127.0.0.1:5174/'), true) + assert.equal(isAllowedShellOpenExternalUrl('mailto:user@example.com'), true) +}) + +test('isAllowedShellOpenExternalUrl accepts Obsidian vault deep links', () => { + // Regression: Desktop chat previously threw "Invalid external URL" for + // obsidian:// because openExternalUrl only allowed http/https/mailto. + const vaultLink = + 'obsidian://open?vault=b9dec633969aa228&file=Policy%2FExample.md' + + assert.equal(isAllowedShellOpenExternalUrl(vaultLink), true) + assert.equal(isAllowedShellOpenExternalUrl('obsidian://open?vault=Lead%20Maine'), true) + assert.equal(isAllowedShellOpenExternalUrl('OBSIDIAN://open?vault=x'), true) +}) + +test('isAllowedShellOpenExternalUrl rejects empty and unparseable input', () => { + assert.equal(isAllowedShellOpenExternalUrl(''), false) + assert.equal(isAllowedShellOpenExternalUrl(' '), false) + assert.equal(isAllowedShellOpenExternalUrl(null), false) + assert.equal(isAllowedShellOpenExternalUrl(undefined), false) + assert.equal(isAllowedShellOpenExternalUrl('not a url'), false) + assert.equal(isAllowedShellOpenExternalUrl('/relative/path.md'), false) +}) + +test('isAllowedShellOpenExternalUrl rejects dangerous or unlisted schemes', () => { + // file: is handled via openPath, not this allowlist + assert.equal(isAllowedShellOpenExternalUrl('file:///C:/Users/x/note.md'), false) + assert.equal(isAllowedShellOpenExternalUrl('javascript:alert(1)'), false) + assert.equal(isAllowedShellOpenExternalUrl('data:text/html,hi'), false) + assert.equal(isAllowedShellOpenExternalUrl('vbscript:msgbox(1)'), false) + assert.equal(isAllowedShellOpenExternalUrl('chrome://settings'), false) + assert.equal(isAllowedShellOpenExternalUrl('about:blank'), false) + assert.equal(isAllowedShellOpenExternalUrl('ms-settings:bluetooth'), false) + // Not yet allowlisted — add deliberately if a product surface needs them + assert.equal(isAllowedShellOpenExternalUrl('vscode://file/tmp/x'), false) + assert.equal(isAllowedShellOpenExternalUrl('cursor://file/tmp/x'), false) +}) diff --git a/apps/desktop/electron/external-url.ts b/apps/desktop/electron/external-url.ts new file mode 100644 index 0000000000000..c6c088098d4d7 --- /dev/null +++ b/apps/desktop/electron/external-url.ts @@ -0,0 +1,36 @@ +// Protocols that Desktop may hand to the OS via shell.openExternal (or the +// WSL→Windows `cmd /c start` path). Kept as an explicit allowlist so chat +// markdown can't drive arbitrary custom handlers (javascript:, data:, ms-*, +// etc.). `file:` is intentionally NOT here — openExternalUrl routes it through +// shell.openPath after path hardening instead. +// +// App deep-link schemes go here only when: +// 1. a real product surface wants them clickable in chat, and +// 2. the handler is a known local desktop app (not a web navigation). +// Obsidian vault deep links (`obsidian://open?vault=…&file=…`) are the first. + +export const SHELL_OPEN_EXTERNAL_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'mailto:', + 'obsidian:' +]) + +/** True when `rawUrl` parses and its protocol is on the shell-open allowlist. */ +export function isAllowedShellOpenExternalUrl(rawUrl: unknown): boolean { + const raw = String(rawUrl ?? '').trim() + + if (!raw) { + return false + } + + let parsed: URL + + try { + parsed = new URL(raw) + } catch { + return false + } + + return SHELL_OPEN_EXTERNAL_PROTOCOLS.has(parsed.protocol.toLowerCase()) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 09a59287c09bb..05104d4682671 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -91,6 +91,7 @@ import { import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' +import { isAllowedShellOpenExternalUrl } from './external-url' import { findGitBash as _findGitBash } from './find-git-bash' import { installFoundInPageForwarder, performFind, stopFind } from './find-in-page' import { createFirstRunSetupGate } from './first-run-setup-gate' @@ -1350,7 +1351,9 @@ function openExternalUrl(rawUrl) { return true } - if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) { + // Explicit scheme allowlist (http/https/mailto + known local app deep + // links like obsidian://). See electron/external-url.ts. + if (!isAllowedShellOpenExternalUrl(parsed.toString())) { return false }