Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit dc5bfea

Browse files
committed
open files in external apps via argv not shell
1 parent be5d307 commit dc5bfea

2 files changed

Lines changed: 130 additions & 19 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from "vitest";
2+
import { type OpenCommand, resolveOpenCommand } from "./external-apps";
3+
4+
// A path a malicious repo could ship: shell metacharacters that would run under
5+
// /bin/sh if this were ever concatenated into a command string.
6+
const MALICIOUS_PATH = "/repo/$(touch /tmp/pwned)`whoami`; rm -rf x.txt";
7+
8+
const app = (id: string, path: string) => ({ id, path });
9+
10+
describe("resolveOpenCommand", () => {
11+
it.each<{
12+
name: string;
13+
platform: NodeJS.Platform;
14+
app: { id: string; path: string };
15+
isFile: boolean;
16+
expected: OpenCommand;
17+
}>([
18+
{
19+
name: "darwin finder reveal",
20+
platform: "darwin",
21+
app: app("finder", "/System/Library/CoreServices/Finder.app"),
22+
isFile: true,
23+
expected: { file: "open", args: ["-R", MALICIOUS_PATH] },
24+
},
25+
{
26+
name: "darwin gitkraken",
27+
platform: "darwin",
28+
app: app("gitkraken", "/Applications/GitKraken.app"),
29+
isFile: false,
30+
expected: {
31+
file: "open",
32+
args: [
33+
"-na",
34+
"/Applications/GitKraken.app",
35+
"--args",
36+
"-p",
37+
MALICIOUS_PATH,
38+
],
39+
},
40+
},
41+
{
42+
name: "darwin default editor",
43+
platform: "darwin",
44+
app: app("vscode", "/Applications/Code.app"),
45+
isFile: false,
46+
expected: {
47+
file: "open",
48+
args: ["-a", "/Applications/Code.app", MALICIOUS_PATH],
49+
},
50+
},
51+
{
52+
name: "win32 explorer select",
53+
platform: "win32",
54+
app: app("explorer", "explorer.exe"),
55+
isFile: true,
56+
expected: { file: "explorer.exe", args: [`/select,${MALICIOUS_PATH}`] },
57+
},
58+
{
59+
name: "win32 default editor",
60+
platform: "win32",
61+
app: app("vscode", "C:/Code/Code.exe"),
62+
isFile: false,
63+
expected: { file: "C:/Code/Code.exe", args: [MALICIOUS_PATH] },
64+
},
65+
])(
66+
"passes the crafted path as a single argv element: $name",
67+
({ platform, app: a, isFile, expected }) => {
68+
const result = resolveOpenCommand(platform, a, MALICIOUS_PATH, isFile);
69+
70+
expect(result).toEqual(expected);
71+
// The tainted path is never split or concatenated into another argument.
72+
expect(result?.args).toContain(
73+
expected.args.find((arg) => arg.includes(MALICIOUS_PATH)),
74+
);
75+
},
76+
);
77+
78+
it("returns null for an unsupported platform", () => {
79+
expect(
80+
resolveOpenCommand("linux", app("vscode", "/usr/bin/code"), "/x", false),
81+
).toBeNull();
82+
});
83+
});

packages/workspace-server/src/services/external-apps/external-apps.ts

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { exec } from "node:child_process";
1+
import { exec, execFile } from "node:child_process";
22
import fs from "node:fs/promises";
33
import path from "node:path";
44
import { promisify } from "node:util";
@@ -14,6 +14,44 @@ import type { DetectedApplication } from "./schemas";
1414
import type { AppDefinition } from "./types";
1515

1616
const execAsync = promisify(exec);
17+
const execFileAsync = promisify(execFile);
18+
19+
export interface OpenCommand {
20+
file: string;
21+
args: string[];
22+
}
23+
24+
// argv form, never a shell string: targetPath and app.path come from an opened
25+
// repository, so a crafted filename would run under /bin/sh if concatenated
26+
// into a command (VERIA-355). Returns null for unsupported platforms.
27+
export function resolveOpenCommand(
28+
platform: NodeJS.Platform,
29+
app: { id: string; path: string },
30+
targetPath: string,
31+
isFile: boolean,
32+
): OpenCommand | null {
33+
if (platform === "darwin") {
34+
if (app.id === "finder" && isFile) {
35+
return { file: "open", args: ["-R", targetPath] };
36+
}
37+
if (app.id === "gitkraken") {
38+
// GitKraken ignores positional args; it needs `--args -p <path>`.
39+
return {
40+
file: "open",
41+
args: ["-na", app.path, "--args", "-p", targetPath],
42+
};
43+
}
44+
return { file: "open", args: ["-a", app.path, targetPath] };
45+
}
46+
if (platform === "win32") {
47+
if (app.id === "explorer" && isFile) {
48+
// Explorer needs `/select,<path>` as a single token.
49+
return { file: "explorer.exe", args: [`/select,${targetPath}`] };
50+
}
51+
return { file: app.path, args: [targetPath] };
52+
}
53+
return null;
54+
}
1755

1856
const LOCALAPPDATA = process.env.LOCALAPPDATA ?? "";
1957
const PROGRAMFILES = process.env.PROGRAMFILES ?? "C:\\Program Files";
@@ -620,27 +658,17 @@ export class ExternalAppsService {
620658
isFile = false;
621659
}
622660

623-
let command: string;
624-
625-
if (process.platform === "darwin") {
626-
if (appToOpen.id === "finder" && isFile) {
627-
command = `open -R "${targetPath}"`;
628-
} else if (appToOpen.id === "gitkraken") {
629-
// GitKraken ignores positional args; it needs `--args -p <path>`.
630-
command = `open -na "${appToOpen.path}" --args -p "${targetPath}"`;
631-
} else {
632-
command = `open -a "${appToOpen.path}" "${targetPath}"`;
633-
}
634-
} else if (process.platform === "win32") {
635-
command =
636-
appToOpen.id === "explorer" && isFile
637-
? `explorer.exe /select,"${targetPath}"`
638-
: `"${appToOpen.path}" "${targetPath}"`;
639-
} else {
661+
const command = resolveOpenCommand(
662+
process.platform,
663+
appToOpen,
664+
targetPath,
665+
isFile,
666+
);
667+
if (!command) {
640668
return { success: false, error: "Unsupported platform" };
641669
}
642670

643-
await execAsync(command);
671+
await execFileAsync(command.file, command.args);
644672
return { success: true };
645673
} catch (error) {
646674
return {

0 commit comments

Comments
 (0)