Skip to content

Commit 41aedf6

Browse files
0xslinejackwener
andauthored
fix(external): replace execSync with execFileSync to prevent command injection (#309)
* fix(external): replace execSync with execFileSync to prevent command injection * fix(review): preserve Windows external installs and restore docs build * fix(review): preserve Windows external installs after rebase --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 8bf750c commit 41aedf6

File tree

3 files changed

+144
-3
lines changed

3 files changed

+144
-3
lines changed

src/discovery.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import yaml from 'js-yaml';
1616
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
1717
import { getErrorMessage } from './errors.js';
1818
import { log } from './logger.js';
19-
import { getErrorMessage } from './errors.js';
2019
import type { ManifestEntry } from './build-manifest.js';
2120

2221
/** Plugins directory: ~/.opencli/plugins/ */

src/external.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
4+
mockExecFileSync: vi.fn(),
5+
mockPlatform: vi.fn(() => 'darwin'),
6+
}));
7+
8+
vi.mock('node:child_process', () => ({
9+
spawnSync: vi.fn(),
10+
execFileSync: mockExecFileSync,
11+
}));
12+
13+
vi.mock('node:os', async () => {
14+
const actual = await vi.importActual<typeof import('node:os')>('node:os');
15+
return {
16+
...actual,
17+
platform: mockPlatform,
18+
};
19+
});
20+
21+
import { installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
22+
23+
describe('parseCommand', () => {
24+
it('splits binaries and quoted arguments without invoking a shell', () => {
25+
expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
26+
binary: 'npm',
27+
args: ['install', '-g', '@scope/tool name'],
28+
});
29+
});
30+
31+
it('rejects shell operators', () => {
32+
expect(() => parseCommand('brew install gh && rm -rf /')).toThrow(
33+
'Install command contains unsafe shell operators',
34+
);
35+
});
36+
});
37+
38+
describe('installExternalCli', () => {
39+
const cli: ExternalCliConfig = {
40+
name: 'readwise',
41+
binary: 'readwise',
42+
install: {
43+
default: 'npm install -g @readwiseio/readwise-cli',
44+
},
45+
};
46+
47+
beforeEach(() => {
48+
mockExecFileSync.mockReset();
49+
mockPlatform.mockReturnValue('darwin');
50+
});
51+
52+
it('retries with .cmd on Windows when the bare binary is unavailable', () => {
53+
mockPlatform.mockReturnValue('win32');
54+
mockExecFileSync
55+
.mockImplementationOnce(() => {
56+
const err = new Error('not found') as NodeJS.ErrnoException;
57+
err.code = 'ENOENT';
58+
throw err;
59+
})
60+
.mockReturnValueOnce(Buffer.from(''));
61+
62+
expect(installExternalCli(cli)).toBe(true);
63+
expect(mockExecFileSync).toHaveBeenNthCalledWith(
64+
1,
65+
'npm',
66+
['install', '-g', '@readwiseio/readwise-cli'],
67+
{ stdio: 'inherit' },
68+
);
69+
expect(mockExecFileSync).toHaveBeenNthCalledWith(
70+
2,
71+
'npm.cmd',
72+
['install', '-g', '@readwiseio/readwise-cli'],
73+
{ stdio: 'inherit' },
74+
);
75+
});
76+
77+
it('does not mask non-ENOENT failures', () => {
78+
mockPlatform.mockReturnValue('win32');
79+
mockExecFileSync.mockImplementationOnce(() => {
80+
const err = new Error('permission denied') as NodeJS.ErrnoException;
81+
err.code = 'EACCES';
82+
throw err;
83+
});
84+
85+
expect(installExternalCli(cli)).toBe(false);
86+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
87+
});
88+
});

src/external.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
22
import * as path from 'node:path';
33
import * as os from 'node:os';
44
import { fileURLToPath } from 'node:url';
5-
import { spawnSync, execSync, execFileSync } from 'node:child_process';
5+
import { spawnSync, execFileSync } from 'node:child_process';
66
import yaml from 'js-yaml';
77
import chalk from 'chalk';
88
import { log } from './logger.js';
@@ -82,6 +82,60 @@ export function getInstallCmd(installConfig?: ExternalCliInstall): string | null
8282
return null;
8383
}
8484

85+
/**
86+
* Safely parses a command string into a binary and argument list.
87+
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
88+
* cannot be safely expressed as execFileSync arguments.
89+
*
90+
* Args:
91+
* cmd: Raw command string from YAML config (e.g. "brew install gh")
92+
*
93+
* Returns:
94+
* Object with `binary` and `args` fields, or throws on unsafe input.
95+
*/
96+
export function parseCommand(cmd: string): { binary: string; args: string[] } {
97+
const shellOperators = /&&|\|\|?|;|[><`]/;
98+
if (shellOperators.test(cmd)) {
99+
throw new Error(
100+
`Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
101+
`Please install the tool manually.`
102+
);
103+
}
104+
105+
// Tokenise respecting single- and double-quoted segments (no variable expansion).
106+
const tokens: string[] = [];
107+
const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
108+
let match: RegExpExecArray | null;
109+
while ((match = re.exec(cmd)) !== null) {
110+
tokens.push(match[1] ?? match[2] ?? match[3]);
111+
}
112+
113+
if (tokens.length === 0) {
114+
throw new Error(`Install command is empty.`);
115+
}
116+
117+
const [binary, ...args] = tokens;
118+
return { binary, args };
119+
}
120+
121+
function shouldRetryWithCmdShim(binary: string, err: NodeJS.ErrnoException): boolean {
122+
return os.platform() === 'win32' && !path.extname(binary) && err.code === 'ENOENT';
123+
}
124+
125+
function runInstallCommand(cmd: string): void {
126+
const { binary, args } = parseCommand(cmd);
127+
128+
try {
129+
execFileSync(binary, args, { stdio: 'inherit' });
130+
} catch (err: any) {
131+
if (shouldRetryWithCmdShim(binary, err)) {
132+
execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
133+
return;
134+
}
135+
throw err;
136+
}
137+
}
138+
85139
export function installExternalCli(cli: ExternalCliConfig): boolean {
86140
if (!cli.install) {
87141
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
@@ -99,7 +153,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean {
99153
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
100154
console.log(chalk.dim(`$ ${cmd}`));
101155
try {
102-
execSync(cmd, { stdio: 'inherit' });
156+
runInstallCommand(cmd);
103157
console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
104158
return true;
105159
} catch (err: any) {

0 commit comments

Comments
 (0)