Skip to content

Commit 23c8a2e

Browse files
Merge pull request #91 from AikidoSec/escape-special-chars-in-shell
Escape special chars in shell scripts
2 parents 2e1ee0d + 0029a7e commit 23c8a2e

File tree

2 files changed

+190
-10
lines changed

2 files changed

+190
-10
lines changed

packages/safe-chain/src/utils/safeSpawn.js

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,77 @@
1-
import { spawn } from "child_process";
1+
import { spawn, execSync } from "child_process";
2+
import os from "os";
23

3-
function escapeArg(arg) {
4-
// If argument contains spaces or quotes, wrap in double quotes and escape double quotes
5-
if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) {
6-
return '"' + arg.replaceAll('"', '\\"') + '"';
4+
function sanitizeShellArgument(arg) {
5+
// If argument contains shell metacharacters, wrap in double quotes
6+
// and escape characters that are special even inside double quotes
7+
if (hasShellMetaChars(arg)) {
8+
// Inside double quotes, we need to escape: " $ ` \
9+
return '"' + escapeDoubleQuoteContent(arg) + '"';
710
}
811
return arg;
912
}
1013

14+
function hasShellMetaChars(arg) {
15+
// Shell metacharacters that need escaping
16+
// These characters have special meaning in shells and need to be quoted
17+
// Whenever one of these characters is present, we should quote the argument
18+
// Characters: space, ", &, ', |, ;, <, >, (, ), $, `, \, !, *, ?, [, ], {, }, ~, #
19+
const shellMetaChars = /[ "&'|;<>()$`\\!*?[\]{}~#]/;
20+
return shellMetaChars.test(arg);
21+
}
22+
23+
function escapeDoubleQuoteContent(arg) {
24+
// Escape special characters for shell safety
25+
// This escapes ", $, `, and \ by prefixing them with a backslash
26+
return arg.replace(/(["`$\\])/g, "\\$1");
27+
}
28+
1129
function buildCommand(command, args) {
12-
const escapedArgs = args.map(escapeArg);
30+
if (args.length === 0) {
31+
return command;
32+
}
33+
34+
const escapedArgs = args.map(sanitizeShellArgument);
35+
1336
return `${command} ${escapedArgs.join(" ")}`;
1437
}
1538

39+
function resolveCommandPath(command) {
40+
// command will be "npm", "yarn", etc.
41+
// Use 'command -v' to find the full path
42+
const fullPath = execSync(`command -v ${command}`, {
43+
encoding: "utf8",
44+
shell: true,
45+
}).trim();
46+
47+
if (!fullPath) {
48+
throw new Error(`Command not found: ${command}`);
49+
}
50+
51+
return fullPath;
52+
}
53+
1654
export async function safeSpawn(command, args, options = {}) {
17-
const fullCommand = buildCommand(command, args);
55+
// The command is always one of our supported package managers.
56+
// It should always be alphanumeric or _ or -
57+
// Reject any command names with suspicious characters
58+
if (!/^[a-zA-Z0-9_-]+$/.test(command)) {
59+
throw new Error(`Invalid command name: ${command}`);
60+
}
61+
1862
return new Promise((resolve, reject) => {
19-
const child = spawn(fullCommand, { ...options, shell: true });
63+
// Windows requires shell: true because .bat and .cmd files are not executable
64+
// without a terminal. On Unix/macOS, we resolve the full path first, then use
65+
// array args (safer, no escaping needed).
66+
// See: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options
67+
let child;
68+
if (os.platform() === "win32") {
69+
const fullCommand = buildCommand(command, args);
70+
child = spawn(fullCommand, { ...options, shell: true });
71+
} else {
72+
const fullPath = resolveCommandPath(command);
73+
child = spawn(fullPath, args, options);
74+
}
2075

2176
// When stdio is piped, we need to collect the output
2277
let stdout = "";

packages/safe-chain/src/utils/safeSpawn.spec.js

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import assert from "node:assert";
44
describe("safeSpawn", () => {
55
let safeSpawn;
66
let spawnCalls = [];
7+
let os;
78

89
beforeEach(async () => {
910
spawnCalls = [];
11+
os = "win32"; // Test Windows behavior by default
1012

1113
// Mock child_process module to capture what command string gets built
1214
mock.module("child_process", {
@@ -15,13 +17,27 @@ describe("safeSpawn", () => {
1517
spawnCalls.push({ command, options });
1618
return {
1719
on: (event, callback) => {
18-
if (event === 'close') {
20+
if (event === "close") {
1921
// Simulate immediate success
2022
setTimeout(() => callback(0), 0);
2123
}
22-
}
24+
},
2325
};
2426
},
27+
execSync: (cmd) => {
28+
// Simulate 'command -v' returning full path
29+
const match = cmd.match(/command -v (.+)/);
30+
if (match) {
31+
return `/usr/bin/${match[1]}\n`;
32+
}
33+
return "";
34+
},
35+
},
36+
});
37+
38+
mock.module("os", {
39+
namedExports: {
40+
platform: () => os,
2541
},
2642
});
2743

@@ -86,4 +102,113 @@ describe("safeSpawn", () => {
86102
assert.strictEqual(spawnCalls[0].command, "npm install axios --save");
87103
assert.strictEqual(spawnCalls[0].options.shell, true);
88104
});
105+
106+
it(`should escape ampersand character`, async () => {
107+
await safeSpawn("npx", ["cypress", "run", "--env", "password=foo&bar"]);
108+
109+
assert.strictEqual(spawnCalls.length, 1);
110+
// & should be escaped by wrapping the arg in quotes
111+
assert.strictEqual(
112+
spawnCalls[0].command,
113+
'npx cypress run --env "password=foo&bar"'
114+
);
115+
assert.strictEqual(spawnCalls[0].options.shell, true);
116+
});
117+
118+
it("should escape dollar signs to prevent variable expansion", async () => {
119+
await safeSpawn("echo", ["$HOME/test"]);
120+
121+
assert.strictEqual(spawnCalls.length, 1);
122+
assert.strictEqual(spawnCalls[0].command, 'echo "\\$HOME/test"');
123+
});
124+
125+
it("should escape backticks to prevent command substitution", async () => {
126+
await safeSpawn("echo", ["file`whoami`.txt"]);
127+
128+
assert.strictEqual(spawnCalls.length, 1);
129+
assert.strictEqual(spawnCalls[0].command, 'echo "file\\`whoami\\`.txt"');
130+
});
131+
132+
it("should escape backslashes properly", async () => {
133+
await safeSpawn("echo", ["path\\with\\backslash"]);
134+
135+
assert.strictEqual(spawnCalls.length, 1);
136+
assert.strictEqual(
137+
spawnCalls[0].command,
138+
'echo "path\\\\with\\\\backslash"'
139+
);
140+
});
141+
142+
it("should handle multiple special characters in one argument", async () => {
143+
await safeSpawn("cmd", ['test "quoted" $var `cmd` & more']);
144+
145+
assert.strictEqual(spawnCalls.length, 1);
146+
assert.strictEqual(
147+
spawnCalls[0].command,
148+
'cmd "test \\"quoted\\" \\$var \\`cmd\\` & more"'
149+
);
150+
});
151+
152+
it("should handle pipe character", async () => {
153+
await safeSpawn("echo", ["foo|bar"]);
154+
155+
assert.strictEqual(spawnCalls.length, 1);
156+
assert.strictEqual(spawnCalls[0].command, 'echo "foo|bar"');
157+
});
158+
159+
it("should handle parentheses", async () => {
160+
await safeSpawn("echo", ["(test)"]);
161+
162+
assert.strictEqual(spawnCalls.length, 1);
163+
assert.strictEqual(spawnCalls[0].command, 'echo "(test)"');
164+
});
165+
166+
it("should handle angle brackets for redirection", async () => {
167+
await safeSpawn("echo", ["foo>output.txt"]);
168+
169+
assert.strictEqual(spawnCalls.length, 1);
170+
assert.strictEqual(spawnCalls[0].command, 'echo "foo>output.txt"');
171+
});
172+
173+
it("should handle wildcard characters", async () => {
174+
await safeSpawn("echo", ["*.txt"]);
175+
176+
assert.strictEqual(spawnCalls.length, 1);
177+
assert.strictEqual(spawnCalls[0].command, 'echo "*.txt"');
178+
});
179+
180+
it("should handle multiple arguments with mixed escaping needs", async () => {
181+
await safeSpawn("cmd", ["safe", "needs space", "$dangerous", "also-safe"]);
182+
183+
assert.strictEqual(spawnCalls.length, 1);
184+
assert.strictEqual(
185+
spawnCalls[0].command,
186+
'cmd safe "needs space" "\\$dangerous" also-safe'
187+
);
188+
});
189+
190+
it("should reject command names with special characters", async () => {
191+
await assert.rejects(async () => await safeSpawn("npm; echo hacked", []), {
192+
message: "Invalid command name: npm; echo hacked",
193+
});
194+
});
195+
196+
it("should reject command names with spaces", async () => {
197+
await assert.rejects(async () => await safeSpawn("npm install", []), {
198+
message: "Invalid command name: npm install",
199+
});
200+
});
201+
202+
it("should reject command names with slashes", async () => {
203+
await assert.rejects(async () => await safeSpawn("../../malicious", []), {
204+
message: "Invalid command name: ../../malicious",
205+
});
206+
});
207+
208+
it("should accept valid command names with letters, numbers, underscores and hyphens", async () => {
209+
await safeSpawn("valid_command-123", []);
210+
211+
assert.strictEqual(spawnCalls.length, 1);
212+
assert.strictEqual(spawnCalls[0].command, "valid_command-123");
213+
});
89214
});

0 commit comments

Comments
 (0)