diff --git a/src/executor.rs b/src/executor.rs index f938cd7..840be16 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -516,6 +516,13 @@ const RC_MARKER: &str = "__BOOTLER_RC__:"; /// command that merely exited non-zero — even one whose own stderr mentions a /// password — rather than classifying by scanning combined stderr afterwards. const SUDO_OK_SENTINEL: &str = "__BOOTLER_SUDO_OK__"; +/// Marker the descent script prints on stderr, in place of +/// [`SUDO_OK_SENTINEL`], when the target identity cannot enter the profile's +/// working directory: `sudo` descended, but the command was never started. +const NO_WORKING_DIRECTORY_MARKER: &str = "__BOOTLER_NO_WORKING_DIRECTORY__"; +/// The `$0` the descent script runs under, so a shell diagnostic — one for an +/// `exec` that fails, say — names what printed it. +const DESCENT_ARG0: &str = "bootler-descent"; /// Errors raised by an executor primitive. #[derive(Debug, thiserror::Error)] @@ -2285,6 +2292,45 @@ fn sudo_sentinel_script() -> String { format!("printf '%s' '{SUDO_OK_SENTINEL}' >&2; exec \"$0\" \"$@\"") } +/// The `sh -c` script [`InDaemonExecutor::descent_command`] descends through, +/// run as the target identity. +/// +/// Invoked as `sh -c SCRIPT … `: it enters +/// `` or prints [`NO_WORKING_DIRECTORY_MARKER`] and exits `1`, then +/// announces the start with [`SUDO_OK_SENTINEL`] and replaces itself with +/// `env -i`, which takes the `K=V` words as the whole environment and the first +/// word without `=` as the utility. Every value is a positional word, never +/// spliced into the script text. +/// +/// `cd`'s own diagnostic is discarded: it would echo ``, and a path that +/// contains the sentinel would then announce a start that never happened. +/// Nothing the script writes before its fixed words derives from the caller. +/// +/// Nor does the script's text contain either word it prints, since `sudo` +/// repeats the command line — this script among it — when sudoers denies it, +/// and a denial must not read as a start or a missing directory. Each word is +/// printed from two halves; [`check_descent`] keeps the caller's words from +/// holding one. +fn descent_script() -> String { + format!( + "cd -- \"$1\" 2>/dev/null || {{ {no_directory}; exit 1; }}; \ + shift; {started}; exec {env} -i \"$@\"", + no_directory = print_split(NO_WORKING_DIRECTORY_MARKER), + started = print_split(SUDO_OK_SENTINEL), + env = bounded::ENV, + ) +} + +/// A shell command printing `word` on standard error from two halves, so the +/// command's own text never contains `word`. +/// +/// Only called with this module's ASCII markers, so the midpoint is a +/// character boundary. +fn print_split(word: &str) -> String { + let (head, tail) = word.split_at(word.len() / 2); + format!("printf '%s%s' '{head}' '{tail}' >&2") +} + /// Removes the first [`SUDO_OK_SENTINEL`] from `stderr`, reporting whether it was /// present (i.e. whether `sudo` elevated and started the wrapped command). fn take_sudo_sentinel(stderr: &mut Vec) -> bool { @@ -3077,6 +3123,12 @@ impl Executor for SshExecutor { /// /// It does not implement [`Executor::open_channel`]: that call returns the /// trait default's [`ChannelError::Unsupported`] here. +/// +/// A root caller that spawns, bounds and kills its own children — and knows +/// an operator's ids from a session it authenticated itself — builds its +/// descents with [`InDaemonExecutor::descent_command`] instead, which applies +/// an execution profile after `sudo` has selected the identity. That is not an +/// [`Identity`], and does not change what [`Identity::Operator`] does here. #[derive(Debug, Clone)] pub struct InDaemonExecutor { host: String, @@ -3138,19 +3190,37 @@ impl InDaemonExecutor { Ok((cmd, false)) } Identity::Service(account) => { - let mut cmd = Command::new(&self.sudo_bin); - cmd.arg("-u") - .arg(account.as_str()) - .arg(shell) - .arg("-c") - .arg(script) - .arg(command) - .args(args); + let mut cmd = self.sudo_descent(Descent::Service(account)); + cmd.arg(shell).arg("-c").arg(script).arg(command).args(args); Ok((cmd, true)) } } } + /// Returns `sudo` with the words that select `who` and nothing after them: + /// the one site building a descent from root, for [`Executor::run`] and its + /// siblings on [`Identity::Service`] and for + /// [`InDaemonExecutor::descent_command`] alike. + /// + /// A service account is `-u `, one word whatever it contains. An + /// operator is `-u # -g #`, the numeric forms `sudo` reads as ids + /// rather than names. + fn sudo_descent(&self, who: Descent) -> Command { + let mut cmd = Command::new(&self.sudo_bin); + match who { + Descent::Service(account) => { + cmd.arg("-u").arg(account.as_str()); + } + Descent::Operator(ids) => { + cmd.arg("-u") + .arg(format!("#{}", ids.uid)) + .arg("-g") + .arg(format!("#{}", ids.gid)); + } + } + cmd + } + /// Spawns a resolved invocation, feeding `payload` verbatim — there is no /// password line to prepend — and settling the sudo sentinel on descent. fn spawn_resolved( @@ -3213,6 +3283,374 @@ impl InDaemonExecutor { } } +/// Who a root process descends to through +/// [`InDaemonExecutor::descent_command`]. +/// +/// Not an [`Identity`]: [`Identity::Root`] has no place here, because a root +/// caller spawns its own root children, and [`Descent::Operator`] names an +/// operator by the ids a session already authenticated, where +/// [`Identity::Operator`] inside the daemon has none to name and refuses. +/// +/// Like [`Identity`], it has no `FromStr`, `Deserialize` or `From` impl: +/// a service account is still one of the closed [`ServiceAccount`] set, so a +/// configured account name cannot become a descent (RFC 0003 §9.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Descent { + /// A bootler-managed service account, reached by `sudo -u ` + /// exactly as [`Identity::Service`] is inside the daemon. + Service(ServiceAccount), + /// An already-authenticated operator, reached by + /// `sudo -u # -g #` — never root. + Operator(OperatorIds), +} + +/// An already-authenticated operator's numeric user and group ids. +/// +/// Built only by [`OperatorIds::new`], which refuses the ids that would not +/// descend: `0`, root's, and `u32::MAX`, `(uid_t)-1`, which the kernel reads +/// as "leave unchanged". There is no `From<(u32, u32)>`, so every pair goes +/// through that check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OperatorIds { + uid: u32, + gid: u32, +} + +impl OperatorIds { + /// Creates the ids of an operator whose session authenticated `uid` and + /// `gid`. + /// + /// # Errors + /// + /// Returns [`DescentError::InvalidOperator`] when either id is `0` or + /// `u32::MAX`. + pub fn new(uid: u32, gid: u32) -> Result { + let descendable = |id: u32| id != 0 && id != u32::MAX; + if descendable(uid) && descendable(gid) { + Ok(Self { uid, gid }) + } else { + Err(DescentError::InvalidOperator { uid, gid }) + } + } + + /// Returns the operator's user id. + #[must_use] + pub fn uid(self) -> u32 { + self.uid + } + + /// Returns the operator's primary group id. + #[must_use] + pub fn gid(self) -> u32 { + self.gid + } +} + +/// The execution profile [`InDaemonExecutor::descent_command`] applies to the +/// command after `sudo` has selected the identity. +/// +/// **No secret belongs in `env`.** Each pair travels as a `K=V` argument word +/// of `sudo` and of the shell it starts, so it is readable in the process +/// table by any local user until the command replaces them. +#[derive(Clone, Copy)] +pub struct DescentProfile<'a> { + /// The command's whole environment, as `(name, value)` pairs. Each name + /// matches `[A-Za-z_][A-Za-z0-9_]*` and appears once; no value contains + /// a NUL byte. + pub env: &'a [(&'a str, &'a str)], + /// The command's working directory: absolute, with no NUL byte. + pub cwd: &'a Path, +} + +/// Errors raised building a descent, before anything is built. +/// +/// No variant carries an environment value, so none can reach a log through +/// this error. +#[derive(Debug, thiserror::Error)] +pub enum DescentError { + /// `command` is not an absolute path, contains `=`, or holds a word the + /// descent reports its outcome with. + /// + /// The command runs with only the profile's environment, and is started + /// through `env -i`, which reads an operand containing `=` as an + /// assignment rather than the utility. The outcome words are reserved + /// because `sudo` repeats the command line when sudoers denies it, and a + /// denial repeating one would read as a start or a missing directory. + #[error( + "command `{command}` is not an absolute path free of `=` and of the descent's outcome words" + )] + InvalidCommand { + /// The command as the caller named it. + command: String, + }, + /// An argument of the command holds a word the descent reports its + /// outcome with, which `sudo` would repeat were sudoers to deny the + /// descent, making the denial read as a start or a missing directory. + #[error("argument {index} holds a word the descent reports its outcome with")] + InvalidArgument { + /// The argument's position in `args`, from `0`. + index: usize, + }, + /// An entry of [`DescentProfile::env`] was refused: its name is not + /// `[A-Za-z_][A-Za-z0-9_]*`, it is given twice, its value contains a + /// NUL byte, or its name or value holds a word the descent reports its + /// outcome with. The value is never carried. + #[error("environment variable `{name}` {reason}")] + InvalidEnvironment { + /// The variable's name. + name: String, + /// Why it was refused. + reason: &'static str, + }, + /// [`DescentProfile::cwd`] is not absolute, contains a NUL byte, or holds + /// a word the descent reports its outcome with. + #[error("working directory `{}` is not an absolute path free of NUL and of the descent's outcome words", cwd.display())] + InvalidWorkingDirectory { + /// The directory as the caller named it. + cwd: PathBuf, + }, + /// An operator's uid or gid is `0` or `u32::MAX`. + #[error("operator uid {uid} and gid {gid} cannot be descended to: neither may be 0 or {max}", max = u32::MAX)] + InvalidOperator { + /// The uid given. + uid: u32, + /// The gid given. + gid: u32, + }, +} + +/// What [`InDaemonExecutor::settle_descent`] reads in the standard error a +/// descent has written so far. +#[derive(Debug)] +pub enum DescentSettle { + /// Undecided: read more, and ask again. + Pending, + /// `sudo` descended and the command has been started in the profile's + /// directory. Standard error from `command_stderr_from` on is the + /// command's; everything before it is not. + Started { + /// The offset just past the start announcement. + command_stderr_from: usize, + }, + /// `sudo` descended, but the target identity could not enter the + /// profile's working directory, so the command was never started. + NoWorkingDirectory { + /// What preceded the failure on standard error — `sudo`'s and its + /// PAM session's, if anything — capped at 64 KiB and trimmed. The + /// shell's own `cd` diagnostic is not among it, since it would echo + /// the directory; the caller already knows which one it asked for. + reason: String, + }, + /// `sudo` refused before the command could start — or wrote more than + /// 64 KiB before announcing a start or a missing directory, which is + /// treated the same way — with + /// the error [`Executor::run`] reports for that refusal. The caller kills + /// the child. + Refused(ExecutorError), +} + +/// Refuses a descent [`InDaemonExecutor::descent_command`] cannot build as +/// asked. +/// +/// Every word the caller supplies reaches `sudo`'s command line, which `sudo` +/// repeats on standard error when sudoers denies it; none may therefore hold +/// a word [`InDaemonExecutor::settle_descent`] reads as an outcome. +fn check_descent( + command: &str, + args: &[&str], + profile: &DescentProfile<'_>, +) -> Result<(), DescentError> { + if !is_absolute_and_plain(command) || holds_outcome_word(command.as_bytes()) { + return Err(DescentError::InvalidCommand { + command: command.to_string(), + }); + } + if let Some(index) = args + .iter() + .position(|arg| holds_outcome_word(arg.as_bytes())) + { + return Err(DescentError::InvalidArgument { index }); + } + let mut seen = std::collections::HashSet::new(); + for &(name, value) in profile.env { + let refuse = |reason| DescentError::InvalidEnvironment { + name: name.to_string(), + reason, + }; + if !is_env_name(name) { + return Err(refuse("is not a name of the form [A-Za-z_][A-Za-z0-9_]*")); + } + if !seen.insert(name) { + return Err(refuse("is given more than once")); + } + if value.contains('\0') { + return Err(refuse("has a value containing a NUL byte")); + } + if holds_outcome_word(name.as_bytes()) || holds_outcome_word(value.as_bytes()) { + return Err(refuse("holds a word the descent reports its outcome with")); + } + } + let cwd = profile.cwd; + let cwd_bytes = cwd.as_os_str().as_encoded_bytes(); + if !cwd.is_absolute() || cwd_bytes.contains(&0) || holds_outcome_word(cwd_bytes) { + return Err(DescentError::InvalidWorkingDirectory { + cwd: cwd.to_path_buf(), + }); + } + Ok(()) +} + +/// Reports whether `word` holds [`SUDO_OK_SENTINEL`] or +/// [`NO_WORKING_DIRECTORY_MARKER`]. +fn holds_outcome_word(word: &[u8]) -> bool { + [SUDO_OK_SENTINEL, NO_WORKING_DIRECTORY_MARKER] + .iter() + .any(|outcome| bounded::find(word, outcome.as_bytes()).is_some()) +} + +/// Reports whether `name` matches `[A-Za-z_][A-Za-z0-9_]*`, the portable form +/// of an environment variable name. +fn is_env_name(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +impl InDaemonExecutor { + /// Builds, without spawning, the command that descends from this root + /// process to `who` and runs `command` with `args` under `profile`. + /// + /// The command is + /// `sudo /bin/sh -c