authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-08-21 07:07:49+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 22:07:49-07:00
log20e5a3396858f93157a4ad6b1f3a2f565c032fee
tree30f9a9c9b82bd40a5f1ce643701d64b58ffe4b65
parent45a2c4b2a680ab1056b64a3be52c1848eec36084
signature Signed by PGP key B5690EEEBB952194

fix(desktop): restrict editor deep links (#7697)

Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>

2 files changed, 64 insertions(+), 6 deletions(-)

apps/desktop/src/electron/ElectronShell.test.ts+49
......@@ -36,6 +36,41 @@ describe("ElectronShell", () => {
3636 }).pipe(Effect.provide(ElectronShell.layer)),
3737 );
3838
39 it.effect("opens remote SSH editor URLs", () =>
40 Effect.gen(function* () {
41 openExternalMock.mockResolvedValue(undefined);
42
43 const electronShell = yield* ElectronShell.ElectronShell;
44 const result = yield* electronShell.openExternal(
45 "vscode://vscode-remote/ssh-remote+example.com/home/user/project",
46 );
47
48 assert.equal(result, true);
49 assert.deepEqual(openExternalMock.mock.calls, [
50 ["vscode://vscode-remote/ssh-remote+example.com/home/user/project"],
51 ]);
52 }).pipe(Effect.provide(ElectronShell.layer)),
53 );
54
55 it.effect("does not open remote editor URLs with userinfo", () =>
56 Effect.gen(function* () {
57 openExternalMock.mockResolvedValue(undefined);
58
59 const electronShell = yield* ElectronShell.ElectronShell;
60 const results = yield* Effect.all([
61 electronShell.openExternal(
62 "vscode://user@vscode-remote/ssh-remote+example.com/home/user/project",
63 ),
64 electronShell.openExternal(
65 "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
66 ),
67 ]);
68
69 assert.deepEqual(results, [false, false]);
70 assert.equal(openExternalMock.mock.calls.length, 0);
71 }).pipe(Effect.provide(ElectronShell.layer)),
72 );
73
3974 it.effect("does not open unsafe external URLs", () =>
4075 Effect.gen(function* () {
4176 const electronShell = yield* ElectronShell.ElectronShell;
......@@ -46,6 +81,20 @@ describe("ElectronShell", () => {
4681 }).pipe(Effect.provide(ElectronShell.layer)),
4782 );
4883
84 it.effect("does not open non-remote editor URLs", () =>
85 Effect.gen(function* () {
86 openExternalMock.mockResolvedValue(undefined);
87
88 const electronShell = yield* ElectronShell.ElectronShell;
89 const result = yield* electronShell.openExternal(
90 "vscode://ms-python.python/some-command?argument=attacker",
91 );
92
93 assert.equal(result, false);
94 assert.equal(openExternalMock.mock.calls.length, 0);
95 }).pipe(Effect.provide(ElectronShell.layer)),
96 );
97
4998 it.effect("returns false when Electron rejects openExternal", () =>
5099 Effect.gen(function* () {
51100 openExternalMock.mockRejectedValue(new Error("open failed"));
apps/desktop/src/electron/ElectronShell.ts+15-6
......@@ -8,14 +8,21 @@ import * as Electron from "electron";
88
99// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
1010// must reach the OS handler; every other non-web scheme stays blocked.
11const SAFE_EXTERNAL_PROTOCOLS = new Set([
12 "http:",
13 "https:",
14 ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
11const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
12const REMOTE_EDITOR_PROTOCOLS = new Set(
13 REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
1514 const scheme = remoteSchemeForEditor(id);
1615 return scheme === undefined ? [] : [`${scheme}:`];
1716 }),
18]);
17);
18
19const isRemoteEditorUrl = (url: URL) =>
20 REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
21 url.username.length === 0 &&
22 url.password.length === 0 &&
23 url.host === "vscode-remote" &&
24 url.pathname.startsWith("/ssh-remote+") &&
25 url.pathname.length > "/ssh-remote+".length;
1926
2027export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
2128 if (typeof rawUrl !== "string") {
......@@ -24,7 +31,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
2431
2532 try {
2633 const url = new URL(rawUrl);
27 return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none();
34 return SAFE_WEB_PROTOCOLS.has(url.protocol) || isRemoteEditorUrl(url)
35 ? Option.some(url.href)
36 : Option.none();
2837 } catch {
2938 return Option.none();
3039 }