Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion actions/start.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { red } from 'ansis';
import { spawn } from 'child_process';
import * as fs from 'fs';
import { join } from 'path';
import { platform } from 'os';
import * as killProcess from 'tree-kill';
import { Input } from '../commands';
import { getTscConfigPath } from '../lib/compiler/helpers/get-tsc-config.path';
Expand Down Expand Up @@ -79,7 +80,8 @@ export class StartAction extends BuildAction {
const shellOption = commandOptions.find(
(option) => option.name === 'shell',
);
const useShell = !!shellOption?.value;
const isWindows = platform() === 'win32';
const useShell = shellOption?.value !== false && isWindows;

const envFileOption = commandOptions.find(
(option) => option.name === 'envFile',
Expand Down
6 changes: 4 additions & 2 deletions lib/runners/abstract.runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { red } from 'ansis';
import { ChildProcess, spawn, SpawnOptions } from 'child_process';
import { platform } from 'os';
import { MESSAGES } from '../ui';

export class AbstractRunner {
Expand All @@ -14,14 +15,15 @@ export class AbstractRunner {
cwd: string = process.cwd(),
): Promise<null | string> {
const args: string[] = [command];
const isWindows = platform() === 'win32';
const options: SpawnOptions = {
cwd,
stdio: collect ? 'pipe' : 'inherit',
shell: true,
shell: isWindows,
};
return new Promise<null | string>((resolve, reject) => {
const child: ChildProcess = spawn(
`${this.binary}`,
this.binary,
[...this.args, ...args],
options,
);
Expand Down
95 changes: 95 additions & 0 deletions test/lib/runners/abstract.runner.security.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { spawn } from 'child_process';
import { platform } from 'os';
import { AbstractRunner } from '../../../lib/runners/abstract.runner';

jest.mock('child_process', () => ({
spawn: jest.fn(),
}));

jest.mock('os', () => ({
platform: jest.fn(),
}));

describe('AbstractRunner Security Tests', () => {
let mockSpawn: jest.MockedFunction<typeof spawn>;
let mockPlatform: jest.MockedFunction<typeof platform>;

beforeEach(() => {
mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
mockPlatform = platform as jest.MockedFunction<typeof platform>;

const mockChild = {
on: jest.fn((event: string, callback: (code: number) => void) => {
if (event === 'close') {
setTimeout(() => callback(0), 0);
}
}),
} as any;

mockSpawn.mockReturnValue(mockChild);
});

afterEach(() => {
jest.resetAllMocks();
});

describe('Shell injection protection', () => {
it('should not use shell on macOS/Linux to prevent command injection', async () => {
mockPlatform.mockReturnValue('darwin');

const runner = new AbstractRunner('node');
await runner.run('$(malicious-command)');

expect(mockSpawn).toHaveBeenCalledWith(
'node',
['$(malicious-command)'],
expect.objectContaining({
shell: false,
}),
);
});

it('should not use shell on Linux to prevent variable expansion', async () => {
mockPlatform.mockReturnValue('linux');

const runner = new AbstractRunner('node');
await runner.run('$USER');

expect(mockSpawn).toHaveBeenCalledWith(
'node',
['$USER'],
expect.objectContaining({
shell: false,
}),
);
});

it('should use shell only on Windows for compatibility', async () => {
mockPlatform.mockReturnValue('win32');

const runner = new AbstractRunner('node');
await runner.run('test-command');

expect(mockSpawn).toHaveBeenCalledWith(
'node',
['test-command'],
expect.objectContaining({
shell: true,
}),
);
});

it('should pass commands as separate arguments, not concatenated strings', async () => {
mockPlatform.mockReturnValue('darwin');

const runner = new AbstractRunner('node', ['--enable-source-maps']);
await runner.run('dist/main.js');

expect(mockSpawn).toHaveBeenCalledWith(
'node',
['--enable-source-maps', 'dist/main.js'],
expect.any(Object),
);
});
});
});