mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 21:50:46 +08:00
fix(security): allow vscode/cursor/zed deep-links in openExternal (#14437)
### What this PR does fix #14433 Before this PR: - The external-app config in `packages/shared/externalApp/config/index.ts` exposes **Visual Studio Code / Cursor / Zed** as code-editor targets, and the renderer has "Open in <editor>" buttons on the Agent chat navbar (`OpenExternalAppButton.tsx`) and inline file paths in agent messages (`ClickableFilePath.tsx`) that call `window.open(buildEditorUrl(app, path))`. - These eventually hit `mainWindow.webContents.setWindowOpenHandler` in `src/main/services/WindowService.ts`, which checks the URL against `isSafeExternalUrl()` before calling `shell.openExternal`. - `isSafeExternalUrl()`'s allowlist only contained `http:`, `https:`, `mailto:`, `obsidian:`, so `vscode://…`, `cursor://…`, `zed://…` all fell through to the `else` branch and were silently denied with a `Blocked shell.openExternal for untrusted URL scheme: vscode://…` warning. Clicking "Open in VS Code" did nothing. After this PR: - `ALLOWED_EXTERNAL_PROTOCOLS` also includes `vscode:`, `vscode-insiders:`, `cursor:`, `zed:`, so the deep-links produced by `buildEditorUrl()` pass the safety check and actually open the file in the chosen editor. - `security.test.ts` gains an `allows code-editor deep-link protocols` case covering all four schemes (including the exact URL shape reported in the logs: `vscode://file/C%3A/…?windowId=_blank`). Fixes # ### Why we need it and why it was done in this way Users already see and click the editor buttons, so the current behavior (silent failure + warning log) is a functional regression, not a security-hardening feature. We just forgot to sync the main-process allowlist when those editor buttons were introduced. The following tradeoffs were made: - Kept the fix minimal — only the allowlist and its test are touched — per the `main`-branch code-freeze rule that hotfix PRs must not include refactoring. - Added `vscode-insiders:` proactively so VS Code Insiders users won't hit the same dead-button bug even though the current config doesn't list Insiders as a separate editor. The following alternatives were considered: - Deriving the allowlist dynamically from `EXTERNAL_APPS` in `packages/shared/externalApp/config` so new editors are auto-covered. Rejected for now because it's a refactor and `main` is frozen; suitable for the `v2` branch instead. Links to places where the discussion took place: N/A ### Breaking changes None. ### Special notes for your reviewer - Security-sensitive surface. The added schemes are all documented editor deep-links (`vscode://`, `vscode-insiders://`, `cursor://`, `zed://`) that the app already offers UI for. No `file:`, `javascript:`, `ms-msdt:`, etc. are permitted — the existing negative tests still pass. - The same `isSafeExternalUrl()` is also used by `will-navigate` and `WebviewService` / `ipc.ts` `openExternal`, so the newly-allowed schemes are consistent across entry points (and that's the behavior we want — opening a file in VS Code from a message link should work everywhere). ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [x] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [x] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [x] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note Fix "Open in VS Code / Cursor / Zed" buttons on the Agent chat navbar and inline file-path menus being silently blocked by the main-process URL allowlist. Clicking an editor button now actually opens the file in the selected editor. ``` --------- Signed-off-by: beyondkmp <beyondkmp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<path>` 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<drive>
|
||||
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)
|
||||
|
||||
@@ -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://<publisher>.<extension>/...` (invokes extension URL
|
||||
* handlers).
|
||||
*/
|
||||
const EDITOR_DEEP_LINK_PROTOCOLS = new Set(['vscode:', 'vscode-insiders:', 'cursor:', 'zed:'])
|
||||
|
||||
/**
|
||||
* Zed's deep-link format is `zed://file<path>` (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 <scheme>://file/<path>,
|
||||
// where the URL authority is exactly "file".
|
||||
return parsed.host === 'file'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user