mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
53699eb807
* 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>
98 lines
2.7 KiB
TypeScript
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);
|
|
});
|
|
});
|