Skip to content

Commit b665f08

Browse files
Merge pull request #7430 from Shopify/sentinel/fix-tree-kill-command-injection-13735669651940213355
[Security] Fix command injection in treeKill utility
2 parents f036539 + d8b74e0 commit b665f08

2 files changed

Lines changed: 74 additions & 5 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/* eslint-disable no-restricted-imports */
2+
import {treeKill} from './tree-kill.js'
3+
import {vi, describe, test, expect, afterEach} from 'vitest'
4+
import {spawn} from 'child_process'
5+
6+
vi.mock('child_process', async () => {
7+
const actual: any = await vi.importActual('child_process')
8+
return {
9+
...actual,
10+
spawn: vi.fn(),
11+
}
12+
})
13+
14+
describe('treeKill', () => {
15+
afterEach(() => {
16+
vi.unstubAllGlobals()
17+
})
18+
19+
test('calls the callback with an error if the PID is not a number (string with digits)', async () => {
20+
const maliciousPid = '1234; calc.exe'
21+
22+
await new Promise<void>((resolve) => {
23+
// Correct signature for treeKill is (pid, signal, killRoot, callback)
24+
treeKill(maliciousPid, 'SIGTERM', true, (err) => {
25+
expect(err?.message).toBe('pid must be a number')
26+
resolve()
27+
})
28+
})
29+
30+
expect(spawn).not.toHaveBeenCalled()
31+
})
32+
33+
test('works with a valid numeric PID as string', () => {
34+
const pid = '1234'
35+
vi.mocked(spawn).mockReturnValue({
36+
on: vi.fn(),
37+
stdout: {on: vi.fn()},
38+
} as any)
39+
vi.stubGlobal('process', {...process, platform: 'win32'})
40+
41+
treeKill(pid)
42+
43+
expect(spawn).toHaveBeenCalledWith('taskkill', ['/pid', '1234', '/T', '/F'])
44+
})
45+
46+
test('works with a valid numeric PID as number', () => {
47+
const pid = 1234
48+
vi.mocked(spawn).mockReturnValue({
49+
on: vi.fn(),
50+
stdout: {on: vi.fn()},
51+
} as any)
52+
vi.stubGlobal('process', {...process, platform: 'win32'})
53+
54+
treeKill(pid)
55+
56+
expect(spawn).toHaveBeenCalledWith('taskkill', ['/pid', '1234', '/T', '/F'])
57+
})
58+
})

packages/cli-kit/src/public/node/tree-kill.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/* eslint-disable no-restricted-imports */
44

55
import {outputDebug} from './output.js'
6-
import {exec, spawn} from 'child_process'
6+
import {spawn} from 'child_process'
77

88
type ProcessTree = Record<string, string[]>
99

@@ -52,7 +52,8 @@ function adaptedTreeKill(
5252
): void {
5353
const rootPid = typeof pid === 'number' ? pid.toString() : pid
5454

55-
if (Number.isNaN(rootPid)) {
55+
// Security: Validate that the PID is a number to prevent command injection
56+
if (!/^\d+$/.test(rootPid)) {
5657
if (callback) {
5758
callback(new Error('pid must be a number'))
5859
return
@@ -71,10 +72,20 @@ function adaptedTreeKill(
7172

7273
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- default handles all Unix-like platforms
7374
switch (process.platform) {
74-
case 'win32':
75-
// @ts-ignore
76-
exec(`taskkill /pid ${pid} /T /F`, callback)
75+
case 'win32': {
76+
// Security: Use spawn instead of exec to avoid shell injection when killing processes on Windows
77+
const taskkill = spawn('taskkill', ['/pid', rootPid, '/T', '/F'])
78+
taskkill.on('close', (code: number) => {
79+
if (callback) {
80+
if (code === 0) {
81+
callback()
82+
} else {
83+
callback(new Error(`taskkill exited with code ${code}`))
84+
}
85+
}
86+
})
7787
break
88+
}
7889
case 'darwin':
7990
buildProcessTree(
8091
rootPid,

0 commit comments

Comments
 (0)