diff --git a/src/main/services/__tests__/security.test.ts b/src/main/services/__tests__/security.test.ts index 76c09982cf..e49480dd1a 100644 --- a/src/main/services/__tests__/security.test.ts +++ b/src/main/services/__tests__/security.test.ts @@ -21,6 +21,40 @@ describe('isSafeExternalUrl', () => { expect(isSafeExternalUrl('obsidian://new?file=test&vault=myvault&clipboard')).toBe(true) }) + it('allows code-editor file-open deep-links on Unix paths', () => { + expect(isSafeExternalUrl('vscode://file/C%3A/Users/foo/bar.ts?windowId=_blank')).toBe(true) + expect(isSafeExternalUrl('vscode-insiders://file/C%3A/Users/foo/bar.ts')).toBe(true) + expect(isSafeExternalUrl('cursor://file/C%3A/Users/foo/bar.ts?windowId=_blank')).toBe(true) + expect(isSafeExternalUrl('zed://file/Users/foo/bar.ts')).toBe(true) + }) + + it('allows Zed file-open deep-links for Windows absolute paths', () => { + // buildEditorUrl() for Zed emits `zed://file` without a slash, so a + // Windows path like C:\Users\foo\bar.ts produces zed://fileC%3A/... + expect(isSafeExternalUrl('zed://fileC%3A/Users/foo/bar.ts')).toBe(true) + expect(isSafeExternalUrl('zed://filed%3a/data/foo.ts')).toBe(true) + }) + + it('rejects editor deep-links with non-file authorities', () => { + // command authority runs registered VS Code commands + expect(isSafeExternalUrl('vscode://command/workbench.action.terminal.sendSequence?text=rm')).toBe(false) + // extension URL handlers + expect(isSafeExternalUrl('vscode://ms-python.python/do-something')).toBe(false) + expect(isSafeExternalUrl('cursor://settings')).toBe(false) + expect(isSafeExternalUrl('zed://extension/evil')).toBe(false) + // missing authority entirely + expect(isSafeExternalUrl('vscode:command/foo')).toBe(false) + }) + + it('rejects Zed deep-links that do not match the file-open shape', () => { + // host starts with "file" but is not file or file + expect(isSafeExternalUrl('zed://filename/path')).toBe(false) + expect(isSafeExternalUrl('zed://files.evil.com/cmd')).toBe(false) + // userinfo smuggling: "file" in userinfo, real host is attacker-controlled + expect(isSafeExternalUrl('zed://file@evil.com/path')).toBe(false) + expect(isSafeExternalUrl('vscode://file:pw@evil.com/path')).toBe(false) + }) + it('rejects file:// protocol', () => { expect(isSafeExternalUrl('file:///etc/passwd')).toBe(false) expect(isSafeExternalUrl('file://localhost/tmp')).toBe(false) diff --git a/src/main/services/security.ts b/src/main/services/security.ts index ea59f289e1..21639d4309 100644 --- a/src/main/services/security.ts +++ b/src/main/services/security.ts @@ -2,22 +2,73 @@ * Security utility functions for the main process. */ -const ALLOWED_EXTERNAL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'obsidian:']) +const ALLOWED_EXTERNAL_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'mailto:', + 'obsidian:', + 'vscode:', + 'vscode-insiders:', + 'cursor:', + 'zed:' +]) + +/** + * Editor deep-link schemes. For these we only accept the "open a file" shape + * produced by `buildEditorUrl()`, so that attacker-supplied links cannot + * reach other authorities such as `vscode://command/...` (runs registered + * commands) or `vscode://./...` (invokes extension URL + * handlers). + */ +const EDITOR_DEEP_LINK_PROTOCOLS = new Set(['vscode:', 'vscode-insiders:', 'cursor:', 'zed:']) + +/** + * Zed's deep-link format is `zed://file` (no slash separator before + * the path — Zed strips the `zed://file` prefix and treats the rest as a + * filesystem path). That means on Unix the URL is `zed://file/abs/path` + * (host parses as `file`), but on Windows it is `zed://fileC%3A/abs/path` + * (host parses as `fileC%3A`), so a plain `host === 'file'` check is + * insufficient. Match the two exact shapes buildEditorUrl() can emit: a + * slash, or a single-letter encoded drive followed by a slash. + */ +const ZED_FILE_URL_RE = /^zed:\/\/file(\/|[A-Za-z]%3[Aa]\/)/i /** * Check whether a URL is safe to open via shell.openExternal(). * - * Only http(s) and mailto links are allowed. This prevents attackers from - * abusing custom protocol handlers (e.g. file://, ms-msdt:, calculator:) - * to execute local files or launch arbitrary applications. + * Only an explicit allowlist of schemes is permitted (web links, mail, and + * known code-editor deep-links used by the app). Editor schemes are further + * restricted to the "open a file" URL shape emitted by `buildEditorUrl()` so + * that attackers cannot smuggle in `vscode://command/...` command URIs, + * extension URL handlers, or userinfo tricks like `zed://file@evil/...`. * * @see https://benjamin-altpeter.de/shell-openexternal-dangers/ */ export function isSafeExternalUrl(url: string): boolean { try { const parsed = new URL(url) - return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol) + if (!ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol)) { + return false + } + if (EDITOR_DEEP_LINK_PROTOCOLS.has(parsed.protocol)) { + return isFileOpenEditorUrl(parsed, url) + } + return true } catch { return false } } + +function isFileOpenEditorUrl(parsed: URL, rawUrl: string): boolean { + // Reject userinfo in any form to foil `zed://file@evil/path`-style tricks + // where "file" ends up as the username and the real host is attacker-chosen. + if (parsed.username !== '' || parsed.password !== '') { + return false + } + if (parsed.protocol === 'zed:') { + return ZED_FILE_URL_RE.test(rawUrl) + } + // vscode / vscode-insiders / cursor all produce ://file/, + // where the URL authority is exactly "file". + return parsed.host === 'file' +}