fix(security): block single-ampersand command chaining bypass
This commit is contained in:
parent
4d4c1e4965
commit
0f56211892
1 changed files with 34 additions and 1 deletions
|
|
@ -158,6 +158,25 @@ fn skip_env_assignments(s: &str) -> &str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect a single `&` operator (background/chain). `&&` is allowed.
|
||||||
|
///
|
||||||
|
/// We treat any standalone `&` as unsafe in policy validation because it can
|
||||||
|
/// chain hidden sub-commands and escape foreground timeout expectations.
|
||||||
|
fn contains_single_ampersand(s: &str) -> bool {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
for (i, b) in bytes.iter().enumerate() {
|
||||||
|
if *b != b'&' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let prev_is_amp = i > 0 && bytes[i - 1] == b'&';
|
||||||
|
let next_is_amp = i + 1 < bytes.len() && bytes[i + 1] == b'&';
|
||||||
|
if !prev_is_amp && !next_is_amp {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
impl SecurityPolicy {
|
impl SecurityPolicy {
|
||||||
/// Classify command risk. Any high-risk segment marks the whole command high.
|
/// Classify command risk. Any high-risk segment marks the whole command high.
|
||||||
pub fn command_risk_level(&self, command: &str) -> CommandRiskLevel {
|
pub fn command_risk_level(&self, command: &str) -> CommandRiskLevel {
|
||||||
|
|
@ -165,7 +184,7 @@ impl SecurityPolicy {
|
||||||
for sep in ["&&", "||"] {
|
for sep in ["&&", "||"] {
|
||||||
normalized = normalized.replace(sep, "\x00");
|
normalized = normalized.replace(sep, "\x00");
|
||||||
}
|
}
|
||||||
for sep in ['\n', ';', '|'] {
|
for sep in ['\n', ';', '|', '&'] {
|
||||||
normalized = normalized.replace(sep, "\x00");
|
normalized = normalized.replace(sep, "\x00");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,6 +358,12 @@ impl SecurityPolicy {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block background command chaining (`&`), which can hide extra
|
||||||
|
// sub-commands and outlive timeout expectations. Keep `&&` allowed.
|
||||||
|
if contains_single_ampersand(command) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Split on command separators and validate each sub-command.
|
// Split on command separators and validate each sub-command.
|
||||||
// We collect segments by scanning for separator characters.
|
// We collect segments by scanning for separator characters.
|
||||||
let mut normalized = command.to_string();
|
let mut normalized = command.to_string();
|
||||||
|
|
@ -933,6 +958,14 @@ mod tests {
|
||||||
assert!(p.is_command_allowed("ls || echo fallback"));
|
assert!(p.is_command_allowed("ls || echo fallback"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_injection_background_chain_blocked() {
|
||||||
|
let p = default_policy();
|
||||||
|
assert!(!p.is_command_allowed("ls & rm -rf /"));
|
||||||
|
assert!(!p.is_command_allowed("ls&rm -rf /"));
|
||||||
|
assert!(!p.is_command_allowed("echo ok & python3 -c 'print(1)'"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_injection_redirect_blocked() {
|
fn command_injection_redirect_blocked() {
|
||||||
let p = default_policy();
|
let p = default_policy();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue