Skip to content

Commit 436328a

Browse files
committed
Handle substitution patterns in command validation
1 parent decc233 commit 436328a

File tree

2 files changed

+259
-5
lines changed

2 files changed

+259
-5
lines changed

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

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getSingleCommandDecision,
1212
CommandValidator,
1313
createCommandValidator,
14+
containsDangerousSubstitution,
1415
} from "../command-validation"
1516

1617
describe("Command Validation", () => {
@@ -200,6 +201,109 @@ ls -la || echo "Failed"`
200201
expect(isAutoApprovedSingleCommand("npm test", [])).toBe(false)
201202
})
202203
})
204+
205+
describe("containsDangerousSubstitution", () => {
206+
it("detects parameter expansion with @P operator (prompt string expansion)", () => {
207+
// This is the specific vulnerability from the report - @P can execute commands
208+
expect(containsDangerousSubstitution('echo "${var1=aa\\140whoami\\140c}${var1@P}"')).toBe(true)
209+
expect(containsDangerousSubstitution("echo ${var@P}")).toBe(true)
210+
expect(containsDangerousSubstitution("result=${input@P}")).toBe(true)
211+
expect(containsDangerousSubstitution("${somevar@P}")).toBe(true)
212+
})
213+
214+
it("detects other dangerous parameter expansion operators", () => {
215+
// @Q - Quote removal
216+
expect(containsDangerousSubstitution("echo ${var@Q}")).toBe(true)
217+
// @E - Escape sequence expansion
218+
expect(containsDangerousSubstitution("echo ${var@E}")).toBe(true)
219+
// @A - Assignment statement
220+
expect(containsDangerousSubstitution("echo ${var@A}")).toBe(true)
221+
// @a - Attribute flags
222+
expect(containsDangerousSubstitution("echo ${var@a}")).toBe(true)
223+
})
224+
225+
it("detects parameter assignments with octal escape sequences", () => {
226+
// Octal \140 is backtick, which can execute commands
227+
expect(containsDangerousSubstitution('echo "${var=\\140whoami\\140}"')).toBe(true)
228+
expect(containsDangerousSubstitution('echo "${var:=\\140ls\\140}"')).toBe(true)
229+
expect(containsDangerousSubstitution('echo "${var+\\140pwd\\140}"')).toBe(true)
230+
expect(containsDangerousSubstitution('echo "${var:-\\140date\\140}"')).toBe(true)
231+
expect(containsDangerousSubstitution('echo "${var:+\\140echo test\\140}"')).toBe(true)
232+
expect(containsDangerousSubstitution('echo "${var:?\\140rm file\\140}"')).toBe(true)
233+
// Test various octal patterns
234+
expect(containsDangerousSubstitution('echo "${var=\\001\\140\\141}"')).toBe(true)
235+
expect(containsDangerousSubstitution('echo "${var=\\777}"')).toBe(true)
236+
})
237+
238+
it("detects parameter assignments with hex escape sequences", () => {
239+
// Hex \x60 is backtick
240+
expect(containsDangerousSubstitution('echo "${var=\\x60whoami\\x60}"')).toBe(true)
241+
expect(containsDangerousSubstitution('echo "${var:=\\x60ls\\x60}"')).toBe(true)
242+
expect(containsDangerousSubstitution('echo "${var+\\x60pwd\\x60}"')).toBe(true)
243+
expect(containsDangerousSubstitution('echo "${var:-\\x60date\\x60}"')).toBe(true)
244+
// Test various hex patterns
245+
expect(containsDangerousSubstitution('echo "${var=\\x00\\x60\\x61}"')).toBe(true)
246+
expect(containsDangerousSubstitution('echo "${var=\\xFF}"')).toBe(true)
247+
})
248+
249+
it("detects indirect variable references", () => {
250+
// ${!var} performs indirect expansion which can be dangerous
251+
expect(containsDangerousSubstitution("echo ${!var}")).toBe(true)
252+
expect(containsDangerousSubstitution("result=${!indirect}")).toBe(true)
253+
expect(containsDangerousSubstitution("${!prefix*}")).toBe(true)
254+
expect(containsDangerousSubstitution("${!prefix@}")).toBe(true)
255+
})
256+
257+
it("detects here-strings with command substitution", () => {
258+
expect(containsDangerousSubstitution("cat <<<$(whoami)")).toBe(true)
259+
expect(containsDangerousSubstitution("read <<<`date`")).toBe(true)
260+
expect(containsDangerousSubstitution("grep pattern <<< $(ls)")).toBe(true)
261+
expect(containsDangerousSubstitution("sort <<< `pwd`")).toBe(true)
262+
})
263+
264+
it("does NOT flag safe parameter expansions", () => {
265+
// Regular parameter expansions without dangerous operators
266+
expect(containsDangerousSubstitution("echo ${var}")).toBe(false)
267+
expect(containsDangerousSubstitution("echo ${var:-default}")).toBe(false)
268+
expect(containsDangerousSubstitution("echo ${var:+alternative}")).toBe(false)
269+
expect(containsDangerousSubstitution("echo ${#var}")).toBe(false)
270+
expect(containsDangerousSubstitution("echo ${var%pattern}")).toBe(false)
271+
expect(containsDangerousSubstitution("echo ${var#pattern}")).toBe(false)
272+
expect(containsDangerousSubstitution("echo ${var/old/new}")).toBe(false)
273+
expect(containsDangerousSubstitution("echo ${var^^}")).toBe(false)
274+
expect(containsDangerousSubstitution("echo ${var,,}")).toBe(false)
275+
expect(containsDangerousSubstitution("echo ${var:0:5}")).toBe(false)
276+
277+
// Parameter assignments without escape sequences
278+
expect(containsDangerousSubstitution('echo "${var=normal text}"')).toBe(false)
279+
expect(containsDangerousSubstitution('echo "${var:-default value}"')).toBe(false)
280+
expect(containsDangerousSubstitution('echo "${var:+alternative}"')).toBe(false)
281+
282+
// Here-strings without command substitution
283+
expect(containsDangerousSubstitution("cat <<<plain_text")).toBe(false)
284+
expect(containsDangerousSubstitution('read <<<"static string"')).toBe(false)
285+
expect(containsDangerousSubstitution("grep <<<$var")).toBe(false) // Plain variable, not command substitution
286+
})
287+
288+
it("handles complex combinations of dangerous patterns", () => {
289+
// Multiple dangerous patterns in one command
290+
expect(containsDangerousSubstitution('echo "${var1=\\140ls\\140}${var1@P}" && ${!indirect}')).toBe(true)
291+
// Nested patterns
292+
expect(containsDangerousSubstitution('echo "${outer=${inner@P}}"')).toBe(true)
293+
// Mixed with safe patterns
294+
expect(containsDangerousSubstitution("echo ${safe:-default} ${dangerous@P}")).toBe(true)
295+
})
296+
297+
it("detects the exact exploit from the security report", () => {
298+
// The exact pattern reported in the vulnerability
299+
const exploit = 'echo "${var1=aa\\140whoami\\140c}${var1@P}"'
300+
expect(containsDangerousSubstitution(exploit)).toBe(true)
301+
302+
// Variations of the exploit
303+
expect(containsDangerousSubstitution('echo "${x=\\140id\\140}${x@P}"')).toBe(true)
304+
expect(containsDangerousSubstitution('result="${cmd=\\x60pwd\\x60}${cmd@P}"')).toBe(true)
305+
})
306+
})
203307
})
204308

205309
/**
@@ -710,6 +814,97 @@ describe("Unified Command Decision Functions", () => {
710814
expect(getCommandDecision("npm install && echo done", allowedCommands, deniedCommands)).toBe("auto_approve")
711815
})
712816

817+
describe("dangerous substitution handling", () => {
818+
it("prevents auto-approve for commands with dangerous parameter expansion", () => {
819+
// Commands that would normally be auto-approved are blocked by dangerous patterns
820+
expect(getCommandDecision("echo ${var@P}", allowedCommands, deniedCommands)).toBe("ask_user")
821+
expect(getCommandDecision("echo hello", allowedCommands, deniedCommands)).toBe("auto_approve") // Safe version
822+
823+
// Even with allowed prefix, dangerous patterns prevent auto-approval
824+
expect(getCommandDecision("npm install ${var@P}", allowedCommands, deniedCommands)).toBe("ask_user")
825+
expect(
826+
getCommandDecision('echo "${var1=\\140whoami\\140c}${var1@P}"', allowedCommands, deniedCommands),
827+
).toBe("ask_user")
828+
})
829+
830+
it("does NOT override auto_deny decisions with dangerous patterns", () => {
831+
// If a command would be denied, dangerous patterns don't change that
832+
expect(getCommandDecision("npm test ${var@P}", allowedCommands, deniedCommands)).toBe("auto_deny")
833+
expect(getCommandDecision('npm test "${var=\\140ls\\140}"', allowedCommands, deniedCommands)).toBe(
834+
"auto_deny",
835+
)
836+
837+
// Regular denied commands without dangerous patterns
838+
expect(getCommandDecision("npm test --coverage", allowedCommands, deniedCommands)).toBe("auto_deny")
839+
})
840+
841+
it("prevents auto-approval for various dangerous substitution types", () => {
842+
// Octal escape sequences
843+
expect(getCommandDecision('echo "${var=\\140ls\\140}"', allowedCommands, deniedCommands)).toBe(
844+
"ask_user",
845+
)
846+
expect(getCommandDecision('npm run "${var:=\\140pwd\\140}"', allowedCommands, deniedCommands)).toBe(
847+
"ask_user",
848+
)
849+
850+
// Hex escape sequences
851+
expect(getCommandDecision('echo "${var=\\x60whoami\\x60}"', allowedCommands, deniedCommands)).toBe(
852+
"ask_user",
853+
)
854+
855+
// Indirect variable references
856+
expect(getCommandDecision("echo ${!var}", allowedCommands, deniedCommands)).toBe("ask_user")
857+
858+
// Here-strings with command substitution
859+
expect(getCommandDecision("cat <<<$(whoami)", allowedCommands, deniedCommands)).toBe("ask_user")
860+
expect(getCommandDecision("read <<<`date`", allowedCommands, deniedCommands)).toBe("ask_user")
861+
})
862+
863+
it("allows safe parameter expansions to follow normal rules", () => {
864+
// Safe parameter expansions should follow normal allowlist/denylist rules
865+
expect(getCommandDecision("echo ${var}", allowedCommands, deniedCommands)).toBe("auto_approve")
866+
expect(getCommandDecision("echo ${var:-default}", allowedCommands, deniedCommands)).toBe("auto_approve")
867+
expect(getCommandDecision("npm install ${package_name}", allowedCommands, deniedCommands)).toBe(
868+
"auto_approve",
869+
)
870+
871+
// Here-strings without command substitution are safe
872+
expect(getCommandDecision("echo test <<<$var", allowedCommands, deniedCommands)).toBe("auto_approve")
873+
})
874+
875+
it("handles command chains correctly with dangerous patterns", () => {
876+
// If any part of a chain has dangerous substitution, prevent auto-approval
877+
expect(getCommandDecision("npm install && echo ${var@P}", allowedCommands, deniedCommands)).toBe(
878+
"ask_user",
879+
)
880+
expect(
881+
getCommandDecision('echo safe && echo "${var=\\140ls\\140}"', allowedCommands, deniedCommands),
882+
).toBe("ask_user")
883+
884+
// But if chain would be denied, keep the deny decision
885+
expect(getCommandDecision("npm test ${var@P} && echo safe", allowedCommands, deniedCommands)).toBe(
886+
"auto_deny",
887+
)
888+
expect(getCommandDecision("npm install && npm test ${var@P}", allowedCommands, deniedCommands)).toBe(
889+
"auto_deny",
890+
)
891+
892+
// Safe chains should still be auto-approved
893+
expect(getCommandDecision("npm install && echo done", allowedCommands, deniedCommands)).toBe(
894+
"auto_approve",
895+
)
896+
})
897+
898+
it("handles the exact exploit from the security report", () => {
899+
const exploit = 'echo "${var1=aa\\140whoami\\140c}${var1@P}"'
900+
// Even though 'echo' is in the allowlist, the dangerous pattern prevents auto-approval
901+
expect(getCommandDecision(exploit, allowedCommands, deniedCommands)).toBe("ask_user")
902+
903+
// But if it were a denied command, it would still be denied
904+
expect(getCommandDecision(`npm test ${exploit}`, allowedCommands, deniedCommands)).toBe("auto_deny")
905+
})
906+
})
907+
713908
it("returns auto_deny for commands with any sub-command auto-denied", () => {
714909
expect(getCommandDecision("npm test", allowedCommands, deniedCommands)).toBe("auto_deny")
715910
expect(getCommandDecision("npm install && npm test", allowedCommands, deniedCommands)).toBe("auto_deny")

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

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@ type ShellToken = string | { op: string } | { command: string }
3636
*
3737
* ## Command Processing Pipeline:
3838
*
39-
* 1. **Subshell Detection**: Commands containing dangerous patterns like $(), ``, or (cmd1; cmd2) are flagged as security risks
39+
* 1. **Dangerous Substitution Detection**: Commands containing dangerous patterns like ${var@P} are never auto-approved
4040
* 2. **Command Parsing**: Split chained commands (&&, ||, ;, |, &) into individual commands for separate validation
4141
* 3. **Pattern Matching**: For each individual command, find the longest matching prefix in both allowlist and denylist
4242
* 4. **Decision Logic**: Apply longest prefix match rule - more specific (longer) matches take precedence
4343
* 5. **Aggregation**: Combine individual decisions - if any command is denied, the entire chain is denied
4444
*
4545
* ## Security Considerations:
4646
*
47-
* - **Subshell Protection**: Detects and blocks command injection attempts via command substitution, process substitution, and subshell grouping
47+
* - **Dangerous Substitution Protection**: Detects dangerous parameter expansions and escape sequences that could execute commands
4848
* - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately to prevent bypassing via chaining
4949
* - **Case Insensitive**: All pattern matching is case-insensitive for consistent behavior across different input styles
5050
* - **Whitespace Handling**: Commands are trimmed and normalized before matching to prevent whitespace-based bypasses
@@ -58,6 +58,53 @@ type ShellToken = string | { op: string } | { command: string }
5858
* This allows users to have personal defaults while projects can define specific restrictions.
5959
*/
6060

61+
/**
62+
* Detect dangerous parameter substitutions that could lead to command execution.
63+
* These patterns are never auto-approved and always require explicit user approval.
64+
*
65+
* Detected patterns:
66+
* - ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
67+
* - ${var@Q} - Quote removal
68+
* - ${var@E} - Escape sequence expansion
69+
* - ${var@A} - Assignment statement
70+
* - ${var@a} - Attribute flags
71+
* - ${var=value} with escape sequences - Can embed commands via \140 (backtick) or \x60
72+
* - ${!var} - Indirect variable references
73+
* - <<<$(...) or <<<`...` - Here-strings with command substitution
74+
*
75+
* @param source - The command string to analyze
76+
* @returns true if dangerous substitution patterns are detected, false otherwise
77+
*/
78+
export function containsDangerousSubstitution(source: string): boolean {
79+
// Check for dangerous parameter expansion operators that can execute commands
80+
// ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
81+
// ${var@Q} - Quote removal
82+
// ${var@E} - Escape sequence expansion
83+
// ${var@A} - Assignment statement
84+
// ${var@a} - Attribute flags
85+
const dangerousParameterExpansion = /\$\{[^}]*@[PQEAa][^}]*\}/.test(source)
86+
87+
// Check for parameter expansions with assignments that could contain escape sequences
88+
// ${var=value} or ${var:=value} can embed commands via escape sequences like \140 (backtick)
89+
// Also check for ${var+value}, ${var:-value}, ${var:+value}, ${var:?value}
90+
const parameterAssignmentWithEscapes =
91+
/\$\{[^}]*[=+\-?][^}]*\\[0-7]{3}[^}]*\}/.test(source) || // octal escapes
92+
/\$\{[^}]*[=+\-?][^}]*\\x[0-9a-fA-F]{2}[^}]*\}/.test(source) // hex escapes
93+
94+
// Check for indirect variable references that could execute commands
95+
// ${!var} performs indirect expansion which can be dangerous with crafted variable names
96+
const indirectExpansion = /\$\{![^}]+\}/.test(source)
97+
98+
// Check for here-strings with command substitution
99+
// <<<$(...) or <<<`...` can execute commands
100+
const hereStringWithSubstitution = /<<<\s*(\$\(|`)/.test(source)
101+
102+
// Return true if any dangerous pattern is detected
103+
return (
104+
dangerousParameterExpansion || parameterAssignmentWithEscapes || indirectExpansion || hereStringWithSubstitution
105+
)
106+
}
107+
61108
/**
62109
* Split a command string into individual sub-commands by
63110
* chaining operators (&&, ||, ;, |, or &) and newlines.
@@ -412,22 +459,26 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user"
412459
* to resolve conflicts between allowlist and denylist patterns.
413460
*
414461
* **Decision Logic:**
415-
* 1. **Subshell Protection**: If subshells ($() or ``) are present and denylist exists → auto-deny
462+
* 1. **Dangerous Substitution Protection**: Commands with dangerous parameter expansions are never auto-approved
416463
* 2. **Command Parsing**: Split command chains (&&, ||, ;, |, &) into individual commands
417464
* 3. **Individual Validation**: For each sub-command, apply longest prefix match rule
418465
* 4. **Aggregation**: Combine decisions using "any denial blocks all" principle
419466
*
420467
* **Return Values:**
421-
* - `"auto_approve"`: All sub-commands are explicitly allowed
468+
* - `"auto_approve"`: All sub-commands are explicitly allowed and no dangerous patterns detected
422469
* - `"auto_deny"`: At least one sub-command is explicitly denied
423-
* - `"ask_user"`: Mixed or no matches found, requires user decision
470+
* - `"ask_user"`: Mixed or no matches found, requires user decision, or contains dangerous patterns
424471
*
425472
* **Examples:**
426473
* ```typescript
427474
* // Simple approval
428475
* getCommandDecision("git status", ["git"], [])
429476
* // Returns "auto_approve"
430477
*
478+
* // Dangerous pattern - never auto-approved
479+
* getCommandDecision('echo "${var@P}"', ["echo"], [])
480+
* // Returns "ask_user"
481+
*
431482
* // Longest prefix match - denial wins
432483
* getCommandDecision("git push origin", ["git"], ["git push"])
433484
* // Returns "auto_deny"
@@ -469,6 +520,11 @@ export function getCommandDecision(
469520
return "auto_deny"
470521
}
471522

523+
// Require explicit user approval for dangerous patterns
524+
if (containsDangerousSubstitution(command)) {
525+
return "ask_user"
526+
}
527+
472528
// If all sub-commands are approved, approve the whole command
473529
if (decisions.every((decision) => decision === "auto_approve")) {
474530
return "auto_approve"
@@ -622,8 +678,10 @@ export class CommandValidator {
622678
subCommands: string[]
623679
allowedMatches: Array<{ command: string; match: string | null }>
624680
deniedMatches: Array<{ command: string; match: string | null }>
681+
hasDangerousSubstitution: boolean
625682
} {
626683
const subCommands = parseCommand(command)
684+
const hasDangerousSubstitution = containsDangerousSubstitution(command)
627685

628686
const allowedMatches = subCommands.map((cmd) => ({
629687
command: cmd,
@@ -640,6 +698,7 @@ export class CommandValidator {
640698
subCommands,
641699
allowedMatches,
642700
deniedMatches,
701+
hasDangerousSubstitution,
643702
}
644703
}
645704

0 commit comments

Comments
 (0)