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", () => {...@@ -36,6 +36,41 @@ describe("ElectronShell", () => {
36 }).pipe(Effect.provide(ElectronShell.layer)),36 }).pipe(Effect.provide(ElectronShell.layer)),
37 );37 );
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
39 it.effect("does not open unsafe external URLs", () =>74 it.effect("does not open unsafe external URLs", () =>
40 Effect.gen(function* () {75 Effect.gen(function* () {
41 const electronShell = yield* ElectronShell.ElectronShell;76 const electronShell = yield* ElectronShell.ElectronShell;
...@@ -46,6 +81,20 @@ describe("ElectronShell", () => {...@@ -46,6 +81,20 @@ describe("ElectronShell", () => {
46 }).pipe(Effect.provide(ElectronShell.layer)),81 }).pipe(Effect.provide(ElectronShell.layer)),
47 );82 );
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
49 it.effect("returns false when Electron rejects openExternal", () =>98 it.effect("returns false when Electron rejects openExternal", () =>
50 Effect.gen(function* () {99 Effect.gen(function* () {
51 openExternalMock.mockRejectedValue(new Error("open failed"));100 openExternalMock.mockRejectedValue(new Error("open failed"));
apps/desktop/src/electron/ElectronShell.ts+15-6
...@@ -8,14 +8,21 @@ import * as Electron from "electron";...@@ -8,14 +8,21 @@ import * as Electron from "electron";
88
9// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)9// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
10// must reach the OS handler; every other non-web scheme stays blocked.10// must reach the OS handler; every other non-web scheme stays blocked.
11const SAFE_EXTERNAL_PROTOCOLS = new Set([11const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
12 "http:",12const REMOTE_EDITOR_PROTOCOLS = new Set(
13 "https:",13 REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
14 ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
15 const scheme = remoteSchemeForEditor(id);14 const scheme = remoteSchemeForEditor(id);
16 return scheme === undefined ? [] : [`${scheme}:`];15 return scheme === undefined ? [] : [`${scheme}:`];
17 }),16 }),
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
20export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {27export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
21 if (typeof rawUrl !== "string") {28 if (typeof rawUrl !== "string") {
...@@ -24,7 +31,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {...@@ -24,7 +31,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
2431
25 try {32 try {
26 const url = new URL(rawUrl);33 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();
28 } catch {37 } catch {
29 return Option.none();38 return Option.none();
30 }39 }