denoland/deno · #36433
fix(process): clear supplementary groups before changing identity
ext/process/lib.rs303 + / 24 −
@@ -4,9 +4,15 @@ use std::borrow::Cow; use std::cell::Cell; use std::cell::RefCell; use std::collections::HashMap;+#[cfg(unix)]+use std::ffi::CStr;+#[cfg(unix)]+use std::ffi::CString; use std::ffi::OsString; use std::io::Write; #[cfg(unix)]+use std::os::unix::ffi::OsStrExt;+#[cfg(unix)] use std::os::unix::io::FromRawFd; #[cfg(unix)] use std::os::unix::prelude::ExitStatusExt;@@ -173,6 +179,104 @@ impl<'de> Deserialize<'de> for StdioOrFd { } } +#[cfg(unix)]+fn should_clear_supplementary_groups(+ uid: Option<u32>,+ gid: Option<u32>,+) -> bool {+ uid.is_some() || gid.is_some()+}++#[cfg(unix)]+fn should_change_cwd_in_pre_exec(+ uid: Option<u32>,+ gid: Option<u32>,+ set_cwd_on_command: bool,+) -> bool {+ set_cwd_on_command && should_clear_supplementary_groups(uid, gid)+}++#[cfg(unix)]+fn unix_child_effective_identity_will_change(+ uid: Option<u32>,+ gid: Option<u32>,+) -> bool {+ // SAFETY: getegid and geteuid are async-signal-safe and cannot fail.+ let (current_uid, current_gid) =+ unsafe { (libc::geteuid(), libc::getegid()) };+ uid.is_some_and(|uid| uid as libc::uid_t != current_uid)+ || gid.is_some_and(|gid| gid as libc::gid_t != current_gid)+}++#[cfg(unix)]+fn check_identity_syscall_result(result: libc::c_int) -> std::io::Result<()> {+ if result == -1 {+ Err(std::io::Error::last_os_error())+ } else {+ Ok(())+ }+}++#[cfg(unix)]+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+enum UnixChildSetupStep<'a> {+ ClearSupplementaryGroups,+ SetPrimaryGroupId(u32),+ SetUserId(u32),+ ChangeCurrentDirectory(&'a CStr),+}++#[cfg(unix)]+fn run_unix_child_setup_step(step: UnixChildSetupStep) -> std::io::Result<()> {+ // SAFETY: these calls are async-signal-safe and run from pre_exec. The cwd+ // is converted to a C string before spawning, and a null groups pointer is+ // valid when the group count is zero.+ let result = unsafe {+ match step {+ UnixChildSetupStep::ClearSupplementaryGroups => {+ libc::setgroups(0, std::ptr::null())+ }+ UnixChildSetupStep::SetPrimaryGroupId(gid) => {+ libc::setgid(gid as libc::gid_t)+ }+ UnixChildSetupStep::SetUserId(uid) => libc::setuid(uid as libc::uid_t),+ UnixChildSetupStep::ChangeCurrentDirectory(cwd) => {+ libc::chdir(cwd.as_ptr())+ }+ }+ };+ check_identity_syscall_result(result)+}++#[cfg(unix)]+fn apply_unix_child_setup<'a>(+ uid: Option<u32>,+ gid: Option<u32>,+ cwd: Option<&'a CStr>,+ effective_identity_will_change: bool,+ mut run_step: impl FnMut(UnixChildSetupStep<'a>) -> std::io::Result<()>,+) -> std::io::Result<()> {+ // Preserve same-ID spawns for unprivileged callers, which can reapply their+ // effective IDs even though they cannot alter supplementary groups. Never+ // ignore a group-clearing failure before an effective identity transition.+ if let Err(err) = run_step(UnixChildSetupStep::ClearSupplementaryGroups)+ && (effective_identity_will_change+ || err.raw_os_error() != Some(libc::EPERM))+ {+ return Err(err);+ }+ if let Some(gid) = gid {+ run_step(UnixChildSetupStep::SetPrimaryGroupId(gid))?;+ }+ if let Some(uid) = uid {+ run_step(UnixChildSetupStep::SetUserId(uid))?;+ }+ if let Some(cwd) = cwd {+ run_step(UnixChildSetupStep::ChangeCurrentDirectory(cwd))?;+ }+ Ok(())+}+ impl StdioOrFd { pub fn as_stdio(&self) -> Result<StdStdio, ProcessError> { match &self {@@ -661,21 +765,41 @@ fn create_command( command.args(args.args); } + #[cfg(unix)]+ let uid = args.uid;+ #[cfg(unix)]+ let gid = args.gid;+ #[cfg(unix)]+ let move_cwd_to_pre_exec =+ should_change_cwd_in_pre_exec(uid, gid, run_env.set_cwd_on_command);+ // Rust applies built-in uid/gid changes before current_dir, but applies+ // current_dir before user pre_exec callbacks. Defer it into our callback so+ // replacing the built-in identity setup preserves uid/gid-before-cwd order.+ #[cfg(unix)]+ let pre_exec_cwd = if move_cwd_to_pre_exec {+ Some(+ CString::new(run_env.cwd.as_os_str().as_bytes()).map_err(|err| {+ ProcessError::Io(std::io::Error::new(+ std::io::ErrorKind::InvalidInput,+ err,+ ))+ })?,+ )+ } else {+ None+ };++ #[cfg(unix)]+ if run_env.set_cwd_on_command && !move_cwd_to_pre_exec {+ command.current_dir(&run_env.cwd);+ }+ #[cfg(windows)] if run_env.set_cwd_on_command { command.current_dir(&run_env.cwd); } command.env_clear(); command.envs(run_env.envs.into_iter().map(|(k, v)| (k.into_inner(), v))); - #[cfg(unix)]- if let Some(gid) = args.gid {- command.gid(gid);- }- #[cfg(unix)]- if let Some(uid) = args.uid {- command.uid(uid);- }- if args.stdio.stdin.is_ipc() { args.ipc = Some(0); } else if args.input.is_some() {@@ -777,8 +901,22 @@ fn create_command( } let detached = args.detached;- if detached || !fds_to_dup.is_empty() || args.gid.is_some() {+ let should_clear_groups = should_clear_supplementary_groups(uid, gid);+ if detached || !fds_to_dup.is_empty() || should_clear_groups { command.pre_exec(move || {+ if should_clear_groups {+ // Keep the identity change in one checked sequence. Command::uid+ // clears groups itself, but does not report every failure.+ let effective_identity_will_change =+ unix_child_effective_identity_will_change(uid, gid);+ apply_unix_child_setup(+ uid,+ gid,+ pre_exec_cwd.as_deref(),+ effective_identity_will_change,+ run_unix_child_setup_step,+ )?;+ } if detached { libc::setsid(); }@@ -801,7 +939,6 @@ fn create_command( } } }- libc::setgroups(0, std::ptr::null()); Ok(()) }); }@@ -1994,19 +2131,6 @@ mod deprecated { c.env(key.inner, value); } - #[cfg(unix)]- // TODO(bartlomieju):- #[allow(- clippy::undocumented_unsafe_blocks,- reason = "TODO: add safety comment"- )]- unsafe {- c.pre_exec(|| {- libc::setgroups(0, std::ptr::null());- Ok(())- });- }- // TODO: make this work with other resources, eg. sockets c.stdin(run_args.stdin.as_stdio()?); c.stdout(match run_args.stdout {@@ -2270,3 +2394,158 @@ mod deprecated { kill(pid, &signal) } }++#[cfg(all(test, unix))]+mod tests {+ use std::ffi::CString;++ use super::UnixChildSetupStep;+ use super::apply_unix_child_setup;+ use super::check_identity_syscall_result;+ use super::should_change_cwd_in_pre_exec;+ use super::should_clear_supplementary_groups;+ use super::unix_child_effective_identity_will_change;++ #[test]+ fn supplementary_groups_are_only_cleared_for_identity_options() {+ assert!(!should_clear_supplementary_groups(None, None));+ assert!(should_clear_supplementary_groups(Some(1000), None));+ assert!(should_clear_supplementary_groups(None, Some(1000)));+ assert!(should_clear_supplementary_groups(Some(1000), Some(1000)));+ assert!(!should_change_cwd_in_pre_exec(None, None, true));+ assert!(!should_change_cwd_in_pre_exec(Some(1000), None, false));+ assert!(should_change_cwd_in_pre_exec(Some(1000), None, true));+ assert!(should_change_cwd_in_pre_exec(None, Some(1000), true));++ // SAFETY: getegid and geteuid are async-signal-safe and cannot fail.+ let (current_uid, current_gid) =+ unsafe { (libc::geteuid() as u32, libc::getegid() as u32) };+ assert!(!unix_child_effective_identity_will_change(+ Some(current_uid),+ Some(current_gid)+ ));+ assert!(unix_child_effective_identity_will_change(+ Some(current_uid.wrapping_add(1)),+ Some(current_gid)+ ));+ assert!(unix_child_effective_identity_will_change(+ Some(current_uid),+ Some(current_gid.wrapping_add(1))+ ));+ }++ #[test]+ fn identity_syscall_failure_returns_os_error() {+ // SAFETY: -1 is not a valid file descriptor, so this only sets errno.+ assert_eq!(unsafe { libc::close(-1) }, -1);+ let error = check_identity_syscall_result(-1).unwrap_err();+ assert_eq!(error.raw_os_error(), Some(libc::EBADF));+ }++ #[test]+ fn identity_change_clears_groups_before_setting_ids() {+ let cwd = CString::new("/work").unwrap();+ let mut steps = Vec::new();+ apply_unix_child_setup(+ Some(1000),+ Some(2000),+ Some(cwd.as_c_str()),+ true,+ |step| {+ steps.push(step);+ Ok(())+ },+ )+ .unwrap();+ assert_eq!(+ steps,+ [+ UnixChildSetupStep::ClearSupplementaryGroups,+ UnixChildSetupStep::SetPrimaryGroupId(2000),+ UnixChildSetupStep::SetUserId(1000),+ UnixChildSetupStep::ChangeCurrentDirectory(cwd.as_c_str()),+ ]+ );+ }++ #[test]+ fn identity_transition_stops_when_group_clear_fails() {+ let mut steps = Vec::new();+ let error =+ apply_unix_child_setup(Some(1000), Some(2000), None, true, |step| {+ steps.push(step);+ Err(std::io::Error::from_raw_os_error(libc::EPERM))+ })+ .unwrap_err();+ assert_eq!(steps, [UnixChildSetupStep::ClearSupplementaryGroups]);+ assert_eq!(error.raw_os_error(), Some(libc::EPERM));+ }++ #[test]+ fn unchanged_identity_tolerates_group_clear_permission_denied() {+ let mut steps = Vec::new();+ apply_unix_child_setup(Some(1000), Some(2000), None, false, |step| {+ steps.push(step);+ if matches!(step, UnixChildSetupStep::ClearSupplementaryGroups) {+ Err(std::io::Error::from_raw_os_error(libc::EPERM))+ } else {+ Ok(())+ }+ })+ .unwrap();+ assert_eq!(+ steps,+ [+ UnixChildSetupStep::ClearSupplementaryGroups,+ UnixChildSetupStep::SetPrimaryGroupId(2000),+ UnixChildSetupStep::SetUserId(1000),+ ]+ );+ }++ #[test]+ fn unchanged_identity_stops_on_other_group_clear_errors() {+ let mut steps = Vec::new();+ let error =+ apply_unix_child_setup(Some(1000), Some(2000), None, false, |step| {+ steps.push(step);+ Err(std::io::Error::from_raw_os_error(libc::EIO))+ })+ .unwrap_err();+ assert_eq!(steps, [UnixChildSetupStep::ClearSupplementaryGroups]);+ assert_eq!(error.raw_os_error(), Some(libc::EIO));+ }++ #[test]+ fn identity_change_returns_chdir_error_after_setting_ids() {+ let cwd = CString::new("/missing").unwrap();+ let mut steps = Vec::new();+ let error = apply_unix_child_setup(+ Some(1000),+ Some(2000),+ Some(cwd.as_c_str()),+ true,+ |step| {+ let is_chdir =+ matches!(step, UnixChildSetupStep::ChangeCurrentDirectory(_));+ steps.push(step);+ if is_chdir {+ Err(std::io::Error::from_raw_os_error(libc::ENOENT))+ } else {+ Ok(())+ }+ },+ )+ .unwrap_err();+ assert_eq!(+ steps,+ [+ UnixChildSetupStep::ClearSupplementaryGroups,+ UnixChildSetupStep::SetPrimaryGroupId(2000),+ UnixChildSetupStep::SetUserId(1000),+ UnixChildSetupStep::ChangeCurrentDirectory(cwd.as_c_str()),+ ]+ );+ assert_eq!(error.raw_os_error(), Some(libc::ENOENT));+ }+}tests/unit/command_test.ts24 + / 4 −
@@ -848,7 +848,12 @@ Deno.test( args: ["-u"], }).output(); - const currentUid = new TextDecoder().decode(stdout);+ const currentUid = new TextDecoder().decode(stdout).trim();+ const sameUid = await new Deno.Command("id", {+ args: ["-u"],+ uid: Number(currentUid),+ }).output();+ assertEquals(new TextDecoder().decode(sameUid.stdout).trim(), currentUid); if (currentUid !== "0") { await assertRejects(async () => {@@ -902,7 +907,12 @@ Deno.test( args: ["-u"], }).outputSync(); - const currentUid = new TextDecoder().decode(stdout);+ const currentUid = new TextDecoder().decode(stdout).trim();+ const sameUid = new Deno.Command("id", {+ args: ["-u"],+ uid: Number(currentUid),+ }).outputSync();+ assertEquals(new TextDecoder().decode(sameUid.stdout).trim(), currentUid); if (currentUid !== "0") { assertThrows(() => {@@ -925,7 +935,12 @@ Deno.test( args: ["-g"], }).output(); - const currentGid = new TextDecoder().decode(stdout);+ const currentGid = new TextDecoder().decode(stdout).trim();+ const sameGid = await new Deno.Command("id", {+ args: ["-g"],+ gid: Number(currentGid),+ }).output();+ assertEquals(new TextDecoder().decode(sameGid.stdout).trim(), currentGid); if (currentGid !== "0") { await assertRejects(async () => {@@ -948,7 +963,12 @@ Deno.test( args: ["-g"], }).outputSync(); - const currentGid = new TextDecoder().decode(stdout);+ const currentGid = new TextDecoder().decode(stdout).trim();+ const sameGid = new Deno.Command("id", {+ args: ["-g"],+ gid: Number(currentGid),+ }).outputSync();+ assertEquals(new TextDecoder().decode(sameGid.stdout).trim(), currentGid); if (currentGid !== "0") { assertThrows(() => {