Files
jackwener__opencli/src/external.test.ts
jakevin 53699eb807 fix: harden security-sensitive execution paths (#335)
* fix(security): harden against command injection and sandbox escape

1. cli.ts: Remove auto-discover of arbitrary system binaries via denylist.
   Unknown commands now require explicit registration via `opencli register`.
   The previous denylist approach was trivially bypassable (bash, curl, etc.).

2. template.ts: Protect evalJsExpr against prototype chain escape.
   Block expressions containing constructor/prototype/__proto__/process/etc.
   Deep-copy context objects to sever prototype chains before passing to
   new Function().

3. external.ts: Expand shell operator detection in parseCommand to cover
   $(), $, #, \n, \r — preventing command substitution and comment injection.

4. fetch.ts: Use JSON.stringify for HTTP method in browser evaluate() instead
   of raw string interpolation, preventing JS injection via crafted method values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden security-sensitive execution paths

* chore: tighten template sandbox guard

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 11:28:22 +08:00

98 lines
2.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
mockExecFileSync: vi.fn(),
mockPlatform: vi.fn(() => 'darwin'),
}));
vi.mock('node:child_process', () => ({
spawnSync: vi.fn(),
execFileSync: mockExecFileSync,
}));
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os');
return {
...actual,
platform: mockPlatform,
};
});
import { installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
describe('parseCommand', () => {
it('splits binaries and quoted arguments without invoking a shell', () => {
expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
binary: 'npm',
args: ['install', '-g', '@scope/tool name'],
});
});
it('rejects shell operators', () => {
expect(() => parseCommand('brew install gh && rm -rf /')).toThrow(
'Install command contains unsafe shell operators',
);
});
it('rejects command substitution and multiline input', () => {
expect(() => parseCommand('brew install $(whoami)')).toThrow(
'Install command contains unsafe shell operators',
);
expect(() => parseCommand('brew install gh\nrm -rf /')).toThrow(
'Install command contains unsafe shell operators',
);
});
});
describe('installExternalCli', () => {
const cli: ExternalCliConfig = {
name: 'readwise',
binary: 'readwise',
install: {
default: 'npm install -g @readwiseio/readwise-cli',
},
};
beforeEach(() => {
mockExecFileSync.mockReset();
mockPlatform.mockReturnValue('darwin');
});
it('retries with .cmd on Windows when the bare binary is unavailable', () => {
mockPlatform.mockReturnValue('win32');
mockExecFileSync
.mockImplementationOnce(() => {
const err = new Error('not found') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
})
.mockReturnValueOnce(Buffer.from(''));
expect(installExternalCli(cli)).toBe(true);
expect(mockExecFileSync).toHaveBeenNthCalledWith(
1,
'npm',
['install', '-g', '@readwiseio/readwise-cli'],
{ stdio: 'inherit' },
);
expect(mockExecFileSync).toHaveBeenNthCalledWith(
2,
'npm.cmd',
['install', '-g', '@readwiseio/readwise-cli'],
{ stdio: 'inherit' },
);
});
it('does not mask non-ENOENT failures', () => {
mockPlatform.mockReturnValue('win32');
mockExecFileSync.mockImplementationOnce(() => {
const err = new Error('permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
expect(installExternalCli(cli)).toBe(false);
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
});
});