Skip to content

Commit 3dd3d7b

Browse files
authored
Handle substitution patterns in command validation (#7390)
1 parent 3c233f3 commit 3dd3d7b

File tree

2 files changed

+265
-125
lines changed

2 files changed

+265
-125
lines changed

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

Lines changed: 206 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
getSingleCommandDecision,
1212
CommandValidator,
1313
createCommandValidator,
14-
containsSubshell,
14+
containsDangerousSubstitution,
1515
} from "../command-validation"
1616

1717
describe("Command Validation", () => {
@@ -43,66 +43,6 @@ describe("Command Validation", () => {
4343
expect(parseCommand("diff <(sort f1) <(sort f2)")).toEqual(["diff", "sort f1", "sort f2"])
4444
})
4545

46-
it("detects additional subshell patterns", () => {
47-
// Test $[] arithmetic expansion detection
48-
expect(parseCommand("echo $[1 + 2]")).toEqual(["echo $[1 + 2]"])
49-
50-
// Verify containsSubshell detects all subshell patterns
51-
expect(containsSubshell("echo $[1 + 2]")).toBe(true) // $[] arithmetic expansion
52-
expect(containsSubshell("echo $((1 + 2))")).toBe(true) // $(()) arithmetic expansion
53-
expect(containsSubshell("echo $(date)")).toBe(true) // $() command substitution
54-
expect(containsSubshell("echo `date`")).toBe(true) // backtick substitution
55-
expect(containsSubshell("diff <(sort f1) <(sort f2)")).toBe(true) // process substitution
56-
expect(containsSubshell("echo hello")).toBe(false) // no subshells
57-
})
58-
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-
10646
it("handles empty and whitespace input", () => {
10747
expect(parseCommand("")).toEqual([])
10848
expect(parseCommand(" ")).toEqual([])
@@ -261,6 +201,120 @@ ls -la || echo "Failed"`
261201
expect(isAutoApprovedSingleCommand("npm test", [])).toBe(false)
262202
})
263203
})
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 parameter assignments with unicode escape sequences", () => {
250+
// Unicode \u0060 is backtick
251+
expect(containsDangerousSubstitution('echo "${var=\\u0060whoami\\u0060}"')).toBe(true)
252+
expect(containsDangerousSubstitution('echo "${var:=\\u0060ls\\u0060}"')).toBe(true)
253+
expect(containsDangerousSubstitution('echo "${var+\\u0060pwd\\u0060}"')).toBe(true)
254+
expect(containsDangerousSubstitution('echo "${var:-\\u0060date\\u0060}"')).toBe(true)
255+
// Test various unicode patterns
256+
expect(containsDangerousSubstitution('echo "${var=\\u0000\\u0060\\u0061}"')).toBe(true)
257+
expect(containsDangerousSubstitution('echo "${var=\\uFFFF}"')).toBe(true)
258+
})
259+
260+
it("detects indirect variable references", () => {
261+
// ${!var} performs indirect expansion which can be dangerous
262+
expect(containsDangerousSubstitution("echo ${!var}")).toBe(true)
263+
expect(containsDangerousSubstitution("result=${!indirect}")).toBe(true)
264+
expect(containsDangerousSubstitution("${!prefix*}")).toBe(true)
265+
expect(containsDangerousSubstitution("${!prefix@}")).toBe(true)
266+
})
267+
268+
it("detects here-strings with command substitution", () => {
269+
expect(containsDangerousSubstitution("cat <<<$(whoami)")).toBe(true)
270+
expect(containsDangerousSubstitution("read <<<`date`")).toBe(true)
271+
expect(containsDangerousSubstitution("grep pattern <<< $(ls)")).toBe(true)
272+
expect(containsDangerousSubstitution("sort <<< `pwd`")).toBe(true)
273+
})
274+
275+
it("does NOT flag safe parameter expansions", () => {
276+
// Regular parameter expansions without dangerous operators
277+
expect(containsDangerousSubstitution("echo ${var}")).toBe(false)
278+
expect(containsDangerousSubstitution("echo ${var:-default}")).toBe(false)
279+
expect(containsDangerousSubstitution("echo ${var:+alternative}")).toBe(false)
280+
expect(containsDangerousSubstitution("echo ${#var}")).toBe(false)
281+
expect(containsDangerousSubstitution("echo ${var%pattern}")).toBe(false)
282+
expect(containsDangerousSubstitution("echo ${var#pattern}")).toBe(false)
283+
expect(containsDangerousSubstitution("echo ${var/old/new}")).toBe(false)
284+
expect(containsDangerousSubstitution("echo ${var^^}")).toBe(false)
285+
expect(containsDangerousSubstitution("echo ${var,,}")).toBe(false)
286+
expect(containsDangerousSubstitution("echo ${var:0:5}")).toBe(false)
287+
288+
// Parameter assignments without escape sequences
289+
expect(containsDangerousSubstitution('echo "${var=normal text}"')).toBe(false)
290+
expect(containsDangerousSubstitution('echo "${var:-default value}"')).toBe(false)
291+
expect(containsDangerousSubstitution('echo "${var:+alternative}"')).toBe(false)
292+
293+
// Here-strings without command substitution
294+
expect(containsDangerousSubstitution("cat <<<plain_text")).toBe(false)
295+
expect(containsDangerousSubstitution('read <<<"static string"')).toBe(false)
296+
expect(containsDangerousSubstitution("grep <<<$var")).toBe(false) // Plain variable, not command substitution
297+
})
298+
299+
it("handles complex combinations of dangerous patterns", () => {
300+
// Multiple dangerous patterns in one command
301+
expect(containsDangerousSubstitution('echo "${var1=\\140ls\\140}${var1@P}" && ${!indirect}')).toBe(true)
302+
// Nested patterns
303+
expect(containsDangerousSubstitution('echo "${outer=${inner@P}}"')).toBe(true)
304+
// Mixed with safe patterns
305+
expect(containsDangerousSubstitution("echo ${safe:-default} ${dangerous@P}")).toBe(true)
306+
})
307+
308+
it("detects the exact exploit from the security report", () => {
309+
// The exact pattern reported in the vulnerability
310+
const exploit = 'echo "${var1=aa\\140whoami\\140c}${var1@P}"'
311+
expect(containsDangerousSubstitution(exploit)).toBe(true)
312+
313+
// Variations of the exploit
314+
expect(containsDangerousSubstitution('echo "${x=\\140id\\140}${x@P}"')).toBe(true)
315+
expect(containsDangerousSubstitution('result="${cmd=\\x60pwd\\x60}${cmd@P}"')).toBe(true)
316+
})
317+
})
264318
})
265319

266320
/**
@@ -771,6 +825,97 @@ describe("Unified Command Decision Functions", () => {
771825
expect(getCommandDecision("npm install && echo done", allowedCommands, deniedCommands)).toBe("auto_approve")
772826
})
773827

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

9001045
expect(details.decision).toBe("auto_approve")
9011046
expect(details.subCommands).toEqual(["npm install", "echo done"])
902-
expect(details.hasSubshells).toBe(false)
9031047
expect(details.allowedMatches).toHaveLength(2)
9041048
expect(details.deniedMatches).toHaveLength(2)
9051049

@@ -912,12 +1056,10 @@ describe("Unified Command Decision Functions", () => {
9121056

9131057
it("detects subshells correctly", () => {
9141058
const details = validator.getValidationDetails("npm install $(echo test)")
915-
expect(details.hasSubshells).toBe(true)
9161059
expect(details.decision).toBe("auto_approve") // all commands are allowed
9171060

9181061
// Test with denied prefix in subshell
9191062
const detailsWithDenied = validator.getValidationDetails("npm install $(npm test)")
920-
expect(detailsWithDenied.hasSubshells).toBe(true)
9211063
expect(detailsWithDenied.decision).toBe("auto_deny") // npm test is denied
9221064
})
9231065

0 commit comments

Comments
 (0)