use std::{
collections::{HashMap, HashSet},
ffi::{OsStr, OsString},
os::unix::prelude::OsStrExt,
};
use crate::common::{CommandAndArguments, Context, Error, context::LaunchType};
use crate::sudoers::Restrictions;
use crate::system::{PATH_MAX, audit::zoneinfo_path};
use super::wildcard_match::wildcard_match;
pub(crate) const PATH_DEFAULT: &str = "/usr/bin:/bin:/usr/sbin:/sbin";
pub type Environment = HashMap<OsString, OsString>;
pub fn system_environment() -> Environment {
std::env::vars_os().collect()
}
fn contains_subsequence(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn format_command(command_and_arguments: &CommandAndArguments) -> OsString {
let mut formatted: OsString = command_and_arguments.command.clone().into();
for arg in &command_and_arguments.arguments {
if formatted.len() + arg.len() < 4096 {
formatted.push(" ");
formatted.push(arg);
}
}
formatted
}
fn add_extra_env(
context: &Context,
cfg: &Restrictions,
sudo_ps1: Option<OsString>,
environment: &mut Environment,
) {
environment.insert("SUDO_COMMAND".into(), format_command(&context.command));
environment.insert(
"SUDO_UID".into(),
context.current_user.uid.to_string().into(),
);
environment.insert(
"SUDO_GID".into(),
context.current_user.gid.to_string().into(),
);
environment.insert("SUDO_USER".into(), context.current_user.name.clone().into());
environment.insert("SUDO_HOME".into(), context.current_user.home.clone().into());
environment
.entry("SHELL".into())
.or_insert_with(|| context.target_user.shell.clone().into());
environment
.entry("HOME".into())
.or_insert_with(|| context.target_user.home.clone().into());
match (
environment.get(OsStr::new("LOGNAME")),
environment.get(OsStr::new("USER")),
) {
(None, None) => {
environment.insert("LOGNAME".into(), context.target_user.name.clone().into());
environment.insert("USER".into(), context.target_user.name.clone().into());
}
(None, Some(user)) => {
environment.insert("LOGNAME".into(), user.clone());
}
(Some(logname), None) => {
environment.insert("USER".into(), logname.clone());
}
(Some(_), Some(_)) => {}
}
if let Some(secure_path) = &cfg.path {
environment.insert("PATH".into(), secure_path.into());
}
environment
.entry("PATH".into())
.or_insert_with(|| PATH_DEFAULT.into());
environment
.entry("TERM".into())
.or_insert_with(|| "unknown".into());
if let Some(sudo_ps1_value) = sudo_ps1 {
environment.insert("PS1".into(), sudo_ps1_value);
}
}
fn is_printable(input: &[u8]) -> bool {
input
.iter()
.all(|c| c.is_ascii_alphanumeric() || c.is_ascii_punctuation())
}
fn is_safe_tz(value: &[u8]) -> bool {
let check_value = if value.starts_with(b":") {
&value[1..]
} else {
value
};
if check_value.starts_with(b"/") {
if let Some(path_zoneinfo) = zoneinfo_path() {
if !check_value.starts_with(path_zoneinfo.as_bytes())
|| check_value.get(path_zoneinfo.len()) != Some(&b'/')
{
return false;
}
} else {
return false;
}
}
!contains_subsequence(check_value, "..".as_bytes())
&& is_printable(check_value)
&& check_value.len() < PATH_MAX as usize
}
fn in_table(needle: (&OsStr, &OsStr), haystack: &HashSet<String>) -> bool {
haystack.iter().any(|pattern| {
if let Some((key, value)) = pattern.split_once('=') {
wildcard_match(needle.0.as_bytes(), key.as_bytes())
&& wildcard_match(needle.1.as_bytes(), value.as_bytes())
} else {
wildcard_match(needle.0.as_bytes(), pattern.as_bytes())
}
})
}
fn should_keep(key: &OsStr, value: &OsStr, cfg: &Restrictions) -> bool {
if value.as_bytes().starts_with("()".as_bytes()) {
return false;
}
if cfg.path.is_some() && key == "PATH" {
return false;
}
if key == "TZ" {
return in_table((key, value), cfg.env_keep)
|| (in_table((key, value), cfg.env_check) && is_safe_tz(value.as_bytes()));
}
if in_table((key, value), cfg.env_check) {
return !value.as_bytes().iter().any(|c| *c == b'%' || *c == b'/');
}
in_table((key, value), cfg.env_keep)
}
pub fn get_target_environment(
current_env: Environment,
additional_env: impl IntoIterator<Item = (OsString, OsString)>,
user_override: Vec<(String, String)>,
context: &Context,
settings: &Restrictions,
) -> Result<Environment, Error> {
let sudo_ps1 = current_env.get(OsStr::new("SUDO_PS1")).cloned();
let mut environment: HashMap<_, _> = additional_env.into_iter().collect();
let login_vars: &[_] = if context.launch == LaunchType::Login {
&["HOME", "SHELL", "USER", "LOGNAME"].map(OsStr::new)
} else {
&[]
};
environment.extend(current_env.into_iter().filter(|(key, value)| {
!login_vars.contains(&key.as_os_str()) && should_keep(key, value, settings)
}));
add_extra_env(context, settings, sudo_ps1, &mut environment);
let mut rejected_vars = Vec::new();
for (key, value) in user_override {
if should_keep(OsStr::new(&key), OsStr::new(&value), settings) {
environment.insert(key.into(), value.into());
} else {
rejected_vars.push(key);
}
}
if !rejected_vars.is_empty() {
return Err(Error::EnvironmentVar(rejected_vars));
}
Ok(environment)
}
pub fn dangerous_extend<S>(env: &mut Environment, user_override: impl IntoIterator<Item = (S, S)>)
where
S: Into<OsString>,
{
env.extend(
user_override
.into_iter()
.map(|(key, value)| (key.into(), value.into())),
)
}
#[cfg(test)]
mod tests {
use super::{is_safe_tz, should_keep, zoneinfo_path};
use std::{collections::HashSet, ffi::OsStr};
struct TestConfiguration {
keep: HashSet<String>,
check: HashSet<String>,
path: Option<String>,
}
impl TestConfiguration {
pub fn check_should_keep(&self, key: &str, value: &str, expected: bool) {
assert_eq!(
should_keep(
OsStr::new(key),
OsStr::new(value),
&crate::sudoers::Restrictions {
env_keep: &self.keep,
env_check: &self.check,
path: self.path.as_deref(),
chdir: crate::sudoers::DirChange::Strict(None),
trust_environment: false,
use_pty: true,
umask: crate::exec::Umask::Preserve,
#[cfg(feature = "apparmor")]
apparmor_profile: None,
noexec: false,
}
),
expected,
"{} should {}",
key,
if expected { "be kept" } else { "not be kept" }
);
}
}
#[test]
fn test_filtering() {
let mut config = TestConfiguration {
keep: HashSet::from(["AAP".to_string(), "NOOT".to_string()]),
check: HashSet::from(["MIES".to_string(), "TZ".to_string()]),
path: Some("/bin".to_string()),
};
config.check_should_keep("AAP", "FOO", true);
config.check_should_keep("MIES", "BAR", true);
config.check_should_keep("AAP", "()=foo", false);
config.check_should_keep("TZ", "Europe/Amsterdam", true);
config.check_should_keep("TZ", "../Europe/Berlin", false);
config.check_should_keep("MIES", "FOO/BAR", false);
config.check_should_keep("MIES", "FOO/BAR", false);
config.keep.insert("PATH".to_string());
config.check_should_keep("PATH", "FOO", false);
config.path = None;
config.check_should_keep("PATH", "FOO", true);
}
#[allow(clippy::bool_assert_comparison)]
#[test]
fn test_tzinfo() {
let path_zoneinfo = zoneinfo_path().unwrap();
assert_eq!(is_safe_tz(b"Europe/Amsterdam"), true);
assert_eq!(
is_safe_tz(format!("{path_zoneinfo}/Europe/London").as_bytes()),
true
);
assert_eq!(
is_safe_tz(format!(":{path_zoneinfo}/Europe/Amsterdam").as_bytes()),
true
);
assert_eq!(is_safe_tz(b"/Europe/Amsterdam"), false);
assert_eq!(is_safe_tz(b"/schaap/Europe/Amsterdam"), false);
assert_eq!(
is_safe_tz(format!("{path_zoneinfo}/../Europe/London").as_bytes()),
false
);
assert_eq!(
is_safe_tz(format!("{path_zoneinfo}/../Europe/London").as_bytes()),
false
);
}
}