Skip to content

Commit de359a4

Browse files
authored
Handle more variations of chaining and subshell command validation (#6486)
1 parent 816dc75 commit de359a4

File tree

2 files changed

+120
-29
lines changed

2 files changed

+120
-29
lines changed

webview-ui/src/utils/__tests__/command-validation.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ describe("Command Validation", () => {
2121
expect(parseCommand("npm test || npm run build")).toEqual(["npm test", "npm run build"])
2222
expect(parseCommand("npm test; npm run build")).toEqual(["npm test", "npm run build"])
2323
expect(parseCommand("npm test | npm run build")).toEqual(["npm test", "npm run build"])
24+
expect(parseCommand("npm test & npm run build")).toEqual(["npm test", "npm run build"])
25+
})
26+
27+
it("handles & operator for background execution", () => {
28+
expect(parseCommand("ls & whoami")).toEqual(["ls", "whoami"])
29+
expect(parseCommand("ls & whoami & pwd")).toEqual(["ls", "whoami", "pwd"])
30+
expect(parseCommand("ls && whoami & pwd || echo done")).toEqual(["ls", "whoami", "pwd", "echo done"])
31+
expect(parseCommand("ls&whoami")).toEqual(["ls", "whoami"])
2432
})
2533

2634
it("preserves quoted content", () => {
@@ -48,6 +56,53 @@ describe("Command Validation", () => {
4856
expect(containsSubshell("echo hello")).toBe(false) // no subshells
4957
})
5058

59+
it("detects subshell grouping patterns", () => {
60+
// Basic subshell grouping with shell operators
61+
expect(containsSubshell("(ls; rm file)")).toBe(true)
62+
expect(containsSubshell("(cd /tmp && rm -rf *)")).toBe(true)
63+
expect(containsSubshell("(command1 || command2)")).toBe(true)
64+
expect(containsSubshell("(ls | grep test)")).toBe(true)
65+
expect(containsSubshell("(sleep 10 & echo done)")).toBe(true)
66+
67+
// Nested subshells
68+
expect(containsSubshell("(cd /tmp && (rm -rf * || echo failed))")).toBe(true)
69+
70+
// Multiple operators in subshell
71+
expect(containsSubshell("(cmd1; cmd2 && cmd3 | cmd4)")).toBe(true)
72+
73+
// Subshell with spaces
74+
expect(containsSubshell("( ls ; rm file )")).toBe(true)
75+
})
76+
77+
it("does NOT detect legitimate parentheses usage", () => {
78+
// Function calls should not be flagged as subshells
79+
expect(containsSubshell("myfunction(arg1, arg2)")).toBe(false)
80+
expect(containsSubshell("func( arg1, arg2 )")).toBe(false)
81+
82+
// Simple parentheses without operators
83+
expect(containsSubshell("(simple text)")).toBe(false)
84+
85+
// Parentheses in strings
86+
expect(containsSubshell('echo "this (has) parentheses"')).toBe(false)
87+
88+
// Empty parentheses
89+
expect(containsSubshell("()")).toBe(false)
90+
})
91+
92+
it("handles mixed subshell patterns", () => {
93+
// Mixed subshell types
94+
expect(containsSubshell("(echo $(date); rm file)")).toBe(true)
95+
96+
// Subshell with command substitution
97+
expect(containsSubshell("(ls `pwd`; echo done)")).toBe(true)
98+
99+
// No subshells
100+
expect(containsSubshell("echo hello world")).toBe(false)
101+
102+
// Empty string
103+
expect(containsSubshell("")).toBe(false)
104+
})
105+
51106
it("handles empty and whitespace input", () => {
52107
expect(parseCommand("")).toEqual([])
53108
expect(parseCommand(" ")).toEqual([])

webview-ui/src/utils/command-validation.ts

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,18 @@ type ShellToken = string | { op: string } | { command: string }
3636
*
3737
* ## Command Processing Pipeline:
3838
*
39-
* 1. **Subshell Detection**: Commands containing $() or `` are blocked if denylist exists
40-
* 2. **Command Parsing**: Split chained commands (&&, ||, ;, |) into individual commands
41-
* 3. **Pattern Matching**: For each command, find longest matching prefixes in both lists
42-
* 4. **Decision Logic**: Apply longest prefix match rule to determine approval/denial
43-
* 5. **Aggregation**: Combine decisions (any denial blocks the entire command chain)
39+
* 1. **Subshell Detection**: Commands containing dangerous patterns like $(), ``, or (cmd1; cmd2) are flagged as security risks
40+
* 2. **Command Parsing**: Split chained commands (&&, ||, ;, |, &) into individual commands for separate validation
41+
* 3. **Pattern Matching**: For each individual command, find the longest matching prefix in both allowlist and denylist
42+
* 4. **Decision Logic**: Apply longest prefix match rule - more specific (longer) matches take precedence
43+
* 5. **Aggregation**: Combine individual decisions - if any command is denied, the entire chain is denied
4444
*
4545
* ## Security Considerations:
4646
*
47-
* - **Subshell Protection**: Prevents command injection via $(command), `command`, or process substitution
48-
* - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately
49-
* - **Case Insensitive**: All matching is case-insensitive for consistency
50-
* - **Whitespace Handling**: Commands are trimmed and normalized before matching
47+
* - **Subshell Protection**: Detects and blocks command injection attempts via command substitution, process substitution, and subshell grouping
48+
* - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately to prevent bypassing via chaining
49+
* - **Case Insensitive**: All pattern matching is case-insensitive for consistent behavior across different input styles
50+
* - **Whitespace Handling**: Commands are trimmed and normalized before matching to prevent whitespace-based bypasses
5151
*
5252
* ## Configuration Merging:
5353
*
@@ -59,37 +59,73 @@ type ShellToken = string | { op: string } | { command: string }
5959
*/
6060

6161
/**
62-
* Detect subshell usage and command substitution patterns:
63-
* - $() - command substitution
64-
* - `` - backticks (legacy command substitution)
65-
* - <() - process substitution (input)
66-
* - >() - process substitution (output)
67-
* - $(()) - arithmetic expansion
68-
* - $[] - arithmetic expansion (alternative syntax)
62+
* Detect subshell usage and command substitution patterns that could be security risks.
63+
*
64+
* Subshells allow executing commands in isolated environments and can be used to bypass
65+
* command validation by hiding dangerous commands inside substitution patterns.
66+
*
67+
* Detected patterns:
68+
* - $() - command substitution: executes command and substitutes output
69+
* - `` - backticks (legacy command substitution): same as $() but older syntax
70+
* - <() - process substitution (input): creates temporary file descriptor for command output
71+
* - >() - process substitution (output): creates temporary file descriptor for command input
72+
* - $(()) - arithmetic expansion: evaluates mathematical expressions (can contain commands)
73+
* - $[] - arithmetic expansion (alternative syntax): same as $(()) but older syntax
74+
* - (cmd1; cmd2) - subshell grouping: executes multiple commands in isolated subshell
75+
*
76+
* @param source - The command string to analyze for subshell patterns
77+
* @returns true if any subshell patterns are detected, false otherwise
6978
*
7079
* @example
7180
* ```typescript
72-
* containsSubshell("echo $(date)") // true - command substitution
73-
* containsSubshell("echo `date`") // true - backtick substitution
74-
* containsSubshell("diff <(sort f1)") // true - process substitution
75-
* containsSubshell("echo $((1+2))") // true - arithmetic expansion
76-
* containsSubshell("echo $[1+2]") // true - arithmetic expansion (alt)
77-
* containsSubshell("echo hello") // false - no subshells
81+
* // Command substitution - executes 'date' and substitutes its output
82+
* containsSubshell("echo $(date)") // true
83+
*
84+
* // Backtick substitution - legacy syntax for command substitution
85+
* containsSubshell("echo `date`") // true
86+
*
87+
* // Process substitution - creates file descriptor for command output
88+
* containsSubshell("diff <(sort f1)") // true
89+
*
90+
* // Arithmetic expansion - can contain command execution
91+
* containsSubshell("echo $((1+2))") // true
92+
* containsSubshell("echo $[1+2]") // true
93+
*
94+
* // Subshell grouping - executes commands in isolated environment
95+
* containsSubshell("(ls; rm file)") // true
96+
* containsSubshell("(cd /tmp && rm -rf *)") // true
97+
*
98+
* // Safe patterns that should NOT be flagged
99+
* containsSubshell("func(arg1, arg2)") // false - function call, not subshell
100+
* containsSubshell("echo hello") // false - no subshell patterns
101+
* containsSubshell("(simple text)") // false - no shell operators in parentheses
78102
* ```
79103
*/
80104
export function containsSubshell(source: string): boolean {
81-
return /(\$\()|`|(<\(|>\()|(\$\(\()|(\$\[)/.test(source)
105+
// Check for command substitution, process substitution, and arithmetic expansion patterns
106+
// These patterns allow executing commands and substituting their output, which can bypass validation
107+
const commandSubstitutionPatterns = /(\$\()|`|(<\(|>\()|(\$\(\()|(\$\[)/.test(source)
108+
109+
// Check for subshell grouping: parentheses containing shell command operators
110+
// Pattern explanation: \( = literal opening paren, [^)]* = any chars except closing paren,
111+
// [;&|]+ = one or more shell operators (semicolon, ampersand, pipe), [^)]* = any chars except closing paren, \) = literal closing paren
112+
// This detects dangerous patterns like: (cmd1; cmd2), (cmd1 && cmd2), (cmd1 || cmd2), (cmd1 | cmd2), (cmd1 & cmd2)
113+
// But avoids false positives like function calls: func(arg1, arg2) - no shell operators inside
114+
const subshellGroupingPattern = /\([^)]*[;&|]+[^)]*\)/.test(source)
115+
116+
// Return true if any subshell pattern is detected
117+
return commandSubstitutionPatterns || subshellGroupingPattern
82118
}
83119

84120
/**
85121
* Split a command string into individual sub-commands by
86-
* chaining operators (&&, ||, ;, or |) and newlines.
122+
* chaining operators (&&, ||, ;, |, or &) and newlines.
87123
*
88124
* Uses shell-quote to properly handle:
89125
* - Quoted strings (preserves quotes)
90126
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
91127
* - PowerShell redirections (2>&1)
92-
* - Chain operators (&&, ||, ;, |)
128+
* - Chain operators (&&, ||, ;, |, &)
93129
* - Newlines as command separators
94130
*/
95131
export function parseCommand(command: string): string[] {
@@ -228,7 +264,7 @@ function parseCommandLine(command: string): string[] {
228264

229265
// Simple fallback: split by common operators
230266
const fallbackCommands = processedCommand
231-
.split(/(?:&&|\|\||;|\|)/)
267+
.split(/(?:&&|\|\||;|\||&)/)
232268
.map((cmd) => cmd.trim())
233269
.filter((cmd) => cmd.length > 0)
234270

@@ -253,13 +289,13 @@ function parseCommandLine(command: string): string[] {
253289
for (const token of tokens) {
254290
if (typeof token === "object" && "op" in token) {
255291
// Chain operator - split command
256-
if (["&&", "||", ";", "|"].includes(token.op)) {
292+
if (["&&", "||", ";", "|", "&"].includes(token.op)) {
257293
if (currentCommand.length > 0) {
258294
commands.push(currentCommand.join(" "))
259295
currentCommand = []
260296
}
261297
} else {
262-
// Other operators (>, &) are part of the command
298+
// Other operators (>) are part of the command
263299
currentCommand.push(token.op)
264300
}
265301
} else if (typeof token === "string") {
@@ -436,7 +472,7 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user"
436472
*
437473
* **Decision Logic:**
438474
* 1. **Subshell Protection**: If subshells ($() or ``) are present and denylist exists → auto-deny
439-
* 2. **Command Parsing**: Split command chains (&&, ||, ;, |) into individual commands
475+
* 2. **Command Parsing**: Split command chains (&&, ||, ;, |, &) into individual commands
440476
* 3. **Individual Validation**: For each sub-command, apply longest prefix match rule
441477
* 4. **Aggregation**: Combine decisions using "any denial blocks all" principle
442478
*

0 commit comments

Comments
 (0)