review: fix all findings from comprehensive code review

Security fixes:
- newgrp: add initgroups() before execv to reset supplementary groups (H-3)
- pam: use Zeroizing<String> for password buffers on all exit paths (M-5)
- atomic: retry-once on stale tmp file from crashed run (M-3)
- atomic: UmaskGuard opts out of Send/Sync via PhantomData (L-2)
- lock: flush + fsync in write_pid_file before hard_link (L-7)

Deduplication (~300 lines removed):
- Centralize caller_is_root(), current_username(), SignalBlocker in
  shadow_core::hardening — remove 10+5+5 private copies across tools
- Add SignalBlocker to all 6 remaining mutating tools (M-4)
- Remove dead CantSort variant from grpck, fix pwck allow (M-8)
- Remove dead read_passwd from test_chfn (M-8)

Binary cleanup:
- shadow-rs multicall: use ExitCode instead of process::exit (M-6)
- completions: use ExitCode + Result pattern, remove unwrap/exit (H-3)

Documentation:
- crypt: thread-safety warning on public API (H-1)
- shadow: clarify unlock() behavior for * password (H-2)
- validate: document $ rejection deviation from GNU (L-4)
- selinux/audit: note shell-out implementation status (L-6)
- chpasswd/chage tests: TODO for --prefix integration (M-8)
- userdel: document inline skip_unless_root reason (L-9)

Testing:
- Add fuzz targets for group and gshadow parsers (M-7)
- skel: use create_dir for tighter error detection (L-8)
- crypt: compile-time modulo bias assertion (L-1)
This commit is contained in:
Pierre Warnier
2026-04-03 19:27:25 +02:00
parent 046ec1de2b
commit 92783375aa
32 changed files with 363 additions and 432 deletions
+11 -1
View File
@@ -9,7 +9,7 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
shadow-core = { path = "../src/shadow-core", features = ["shadow", "login-defs"] }
shadow-core = { path = "../src/shadow-core", features = ["shadow", "login-defs", "group", "gshadow"] }
tempfile = "3"
[workspace]
@@ -34,3 +34,13 @@ doc = false
name = "fuzz_validate_username"
path = "fuzz_targets/fuzz_validate_username.rs"
doc = false
[[bin]]
name = "fuzz_group_parse"
path = "fuzz_targets/fuzz_group_parse.rs"
doc = false
[[bin]]
name = "fuzz_gshadow_parse"
path = "fuzz_targets/fuzz_gshadow_parse.rs"
doc = false
+18
View File
@@ -0,0 +1,18 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Fuzz target for `/etc/group` line parsing.
//!
//! Ensures `GroupEntry::from_str` never panics on arbitrary input.
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
// Must not panic on any input — errors are fine.
let _ = s.parse::<shadow_core::group::GroupEntry>();
}
});
+18
View File
@@ -0,0 +1,18 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Fuzz target for `/etc/gshadow` line parsing.
//!
//! Ensures `GshadowEntry::from_str` never panics on arbitrary input.
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
// Must not panic on any input — errors are fine.
let _ = s.parse::<shadow_core::gshadow::GshadowEntry>();
}
});
+35 -23
View File
@@ -117,13 +117,15 @@ fn cli() -> Command {
)
}
fn generate_for_tool(name: &str, shell: Shell, out: &mut dyn io::Write) {
fn generate_for_tool(name: &str, shell: Shell, out: &mut dyn io::Write) -> Result<(), String> {
if let Some(mut cmd) = get_tool_app(name) {
generate(shell, &mut cmd, name, out);
Ok(())
} else {
eprintln!("unknown tool: {name}");
eprintln!("available: {}", all_tool_names().join(", "));
std::process::exit(1);
Err(format!(
"unknown tool: {name}\navailable: {}",
all_tool_names().join(", ")
))
}
}
@@ -138,32 +140,37 @@ fn shell_extension(shell: Shell) -> &'static str {
}
}
fn main() {
fn main() -> std::process::ExitCode {
match run() {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(msg) => {
eprintln!("{msg}");
std::process::ExitCode::FAILURE
}
}
}
fn run() -> Result<(), String> {
let matches = cli().get_matches();
let shell: Shell = matches
// clap enforces `--shell` is required, so the value is always present.
let shell_str = matches
.get_one::<String>("shell")
.expect("shell is required")
.parse()
.unwrap_or_else(|e| {
eprintln!("invalid shell: {e}");
eprintln!("supported: bash, zsh, fish, elvish, powershell");
std::process::exit(1);
});
.expect("clap guarantees --shell is present");
let shell: Shell = shell_str.parse().map_err(|e| {
format!("invalid shell: {e}\nsupported: bash, zsh, fish, elvish, powershell")
})?;
if matches.get_flag("all") {
let tools = all_tool_names();
if let Some(dir) = matches.get_one::<String>("dir") {
std::fs::create_dir_all(dir).unwrap_or_else(|e| {
eprintln!("cannot create directory '{dir}': {e}");
std::process::exit(1);
});
std::fs::create_dir_all(dir)
.map_err(|e| format!("cannot create directory '{dir}': {e}"))?;
let ext = shell_extension(shell);
for name in &tools {
let path = format!("{dir}/{name}.{ext}");
let mut file = std::fs::File::create(&path).unwrap_or_else(|e| {
eprintln!("cannot create '{path}': {e}");
std::process::exit(1);
});
let mut file = std::fs::File::create(&path)
.map_err(|e| format!("cannot create '{path}': {e}"))?;
if let Some(mut cmd) = get_tool_app(name) {
generate(shell, &mut cmd, *name, &mut file);
}
@@ -179,9 +186,14 @@ fn main() {
}
}
} else {
let tool = matches.get_one::<String>("tool").expect("tool is required");
// clap enforces `tool` is required when `--all` is absent.
let tool = matches
.get_one::<String>("tool")
.expect("clap guarantees tool is present when --all is not set");
let stdout = io::stdout();
let mut out = stdout.lock();
generate_for_tool(tool, shell, &mut out);
generate_for_tool(tool, shell, &mut out)?;
}
Ok(())
}
+13 -6
View File
@@ -9,8 +9,15 @@
//! When invoked as `shadow-rs <util>`, uses the first argument instead.
use std::path::Path;
use std::process::ExitCode;
fn main() {
/// Convert a tool's `i32` exit code to `ExitCode`.
#[allow(clippy::cast_sign_loss)] // clamp(0, 255) guarantees non-negative
fn to_exit_code(code: i32) -> ExitCode {
ExitCode::from(code.clamp(0, 255) as u8)
}
fn main() -> ExitCode {
let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
let binary_name = args
@@ -24,7 +31,7 @@ fn main() {
// Direct invocation via symlink (e.g., argv[0] = "passwd")
if let Some(code) = dispatch(&binary_name, &args) {
std::process::exit(code);
return to_exit_code(code);
}
// Multicall: `shadow-rs <util> [args...]`
@@ -33,21 +40,21 @@ fn main() {
if util_name == "--list" {
print_available_utils();
std::process::exit(0);
return ExitCode::SUCCESS;
}
if let Some(code) = dispatch(&util_name, &args[1..]) {
std::process::exit(code);
return to_exit_code(code);
}
eprintln!("shadow-rs: unknown utility '{util_name}'");
eprintln!("Run 'shadow-rs --list' for available utilities.");
std::process::exit(1);
return ExitCode::FAILURE;
}
eprintln!("Usage: shadow-rs <utility> [arguments...]");
eprintln!("Run 'shadow-rs --list' for available utilities.");
std::process::exit(1);
ExitCode::FAILURE
}
fn dispatch(name: &str, args: &[std::ffi::OsString]) -> Option<i32> {
+26 -3
View File
@@ -24,12 +24,21 @@ use crate::error::ShadowError;
/// On creation, sets the umask to zero so that file mode bits passed to
/// `OpenOptions::mode()` are applied exactly. The original umask is restored
/// when the guard is dropped, even on error or panic paths.
struct UmaskGuard(nix::sys::stat::Mode);
///
/// # Thread safety
///
/// `umask(2)` is a process-wide operation. This guard is NOT safe to use
/// from multiple threads concurrently. All shadow-rs tools are
/// single-threaded, so this is not an issue in practice.
struct UmaskGuard(nix::sys::stat::Mode, std::marker::PhantomData<*const ()>);
impl UmaskGuard {
/// Set umask to zero and return a guard that restores the original.
fn zero() -> Self {
Self(nix::sys::stat::umask(nix::sys::stat::Mode::empty()))
Self(
nix::sys::stat::umask(nix::sys::stat::Mode::empty()),
std::marker::PhantomData,
)
}
}
@@ -105,7 +114,21 @@ where
.create_new(true)
.mode(mode)
.open(&tmp_path)
.map_err(|e| ShadowError::IoPath(e, tmp_path.clone()))?;
.or_else(|e| {
if e.kind() == io::ErrorKind::AlreadyExists {
// Stale tmp file from a crashed run — remove and retry once.
fs::remove_file(&tmp_path)
.map_err(|re| ShadowError::IoPath(re, tmp_path.clone()))?;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(&tmp_path)
.map_err(|e2| ShadowError::IoPath(e2, tmp_path.clone()))
} else {
Err(ShadowError::IoPath(e, tmp_path.clone()))
}
})?;
f(&mut tmp_file)?;
+4
View File
@@ -11,6 +11,10 @@
//!
//! This module provides a best-effort logging interface that silently
//! succeeds when audit is not available.
//!
//! **Implementation note**: This module currently shells out to `logger`
//! rather than using native `libaudit` bindings. Native integration is
//! planned for a future release.
/// Log a user account event to the audit subsystem.
///
+16
View File
@@ -22,6 +22,10 @@ unsafe extern "C" {
/// crypt(3) salt alphabet (POSIX: [a-zA-Z0-9./]).
const SALT_CHARS: &[u8] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// Modulo bias check: random u8 values (0-255) mapped via `% len` have zero
// bias only when `len` divides 256 evenly. Assert this at compile time.
const _: () = assert!(256 % SALT_CHARS.len() == 0);
/// Supported crypt(3) hash methods.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CryptMethod {
@@ -80,6 +84,12 @@ fn generate_salt(method: CryptMethod, rounds: Option<u32>) -> Result<String, Sha
///
/// Returns `ShadowError` if the password contains null bytes, the salt
/// cannot be generated, or crypt(3) fails.
///
/// # Thread safety
///
/// This function is NOT thread-safe. `crypt(3)` uses a process-wide
/// static buffer on glibc. Concurrent calls from multiple threads will
/// corrupt each other's results. All shadow-rs tools are single-threaded.
pub fn hash_password(
password: &str,
method: CryptMethod,
@@ -125,6 +135,12 @@ pub fn hash_password(
/// # Errors
///
/// Returns `ShadowError` if the inputs contain null bytes.
///
/// # Thread safety
///
/// This function is NOT thread-safe. `crypt(3)` uses a process-wide
/// static buffer on glibc. Concurrent calls from multiple threads will
/// corrupt each other's results. All shadow-rs tools are single-threaded.
pub fn verify_password(password: &str, hash: &str) -> Result<bool, ShadowError> {
let c_password = CString::new(password)
.map_err(|_| ShadowError::Auth("password contains null byte".into()))?;
+71
View File
@@ -110,3 +110,74 @@ pub fn harden_process() -> Vec<(String, String)> {
raise_file_size_limit();
sanitized_env()
}
// ---------------------------------------------------------------------------
// Identity helpers
// ---------------------------------------------------------------------------
/// Check whether the *real* caller is root (not just setuid-root).
///
/// Uses `getuid()` (real UID). When a tool is installed setuid-root,
/// `geteuid()` is 0 for all callers, but the real UID identifies who
/// actually invoked the program.
pub fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
/// Return the current user's username from the real UID.
pub fn current_username() -> Result<String, crate::error::ShadowError> {
let uid = nix::unistd::getuid();
match nix::unistd::User::from_uid(uid) {
Ok(Some(user)) => Ok(user.name),
Ok(None) => Err(crate::error::ShadowError::Other(
format!("cannot determine username for uid {uid}").into(),
)),
Err(e) => Err(crate::error::ShadowError::Other(
format!("cannot determine username: {e}").into(),
)),
}
}
// ---------------------------------------------------------------------------
// Signal blocking
// ---------------------------------------------------------------------------
/// RAII guard that blocks critical signals during file modifications.
///
/// Prevents `SIGINT`/`SIGTERM`/`SIGHUP` from interrupting a
/// lock-modify-write sequence, which could leave password files in an
/// inconsistent state or holding a stale lock. The original signal mask
/// is restored when the guard is dropped.
pub struct SignalBlocker {
old_mask: nix::sys::signal::SigSet,
}
impl SignalBlocker {
/// Block `SIGINT`, `SIGTERM`, `SIGHUP` to prevent partial file writes.
pub fn block_critical() -> Result<Self, crate::error::ShadowError> {
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
let mut block_set = SigSet::empty();
block_set.add(Signal::SIGINT);
block_set.add(Signal::SIGTERM);
block_set.add(Signal::SIGHUP);
let mut old_mask = SigSet::empty();
nix::sys::signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&block_set), Some(&mut old_mask))
.map_err(|e| {
crate::error::ShadowError::Other(format!("cannot block signals: {e}").into())
})?;
Ok(Self { old_mask })
}
}
impl Drop for SignalBlocker {
fn drop(&mut self) {
let _ = nix::sys::signal::sigprocmask(
nix::sys::signal::SigmaskHow::SIG_SETMASK,
Some(&self.old_mask),
None,
);
}
}
+7
View File
@@ -192,6 +192,13 @@ fn write_pid_file(tmp_path: &Path) -> Result<(), ShadowError> {
ShadowError::Lock(format!("cannot write {}: {e}", tmp_path.display()).into())
})?;
file.flush().map_err(|e| {
ShadowError::Lock(format!("cannot flush {}: {e}", tmp_path.display()).into())
})?;
nix::unistd::fsync(&file).map_err(|e| {
ShadowError::Lock(format!("cannot fsync {}: {e}", tmp_path.display()).into())
})?;
Ok(())
}
+9 -8
View File
@@ -371,7 +371,7 @@ fn prompt_for_input(
/// to the real terminal even if stdin has been redirected. Uses
/// `nix::sys::termios` to disable `ECHO` for password prompts and restores
/// the original settings afterward (including on error, via a drop guard).
fn read_from_tty(message: &PamMessage, echo: bool) -> io::Result<String> {
fn read_from_tty(message: &PamMessage, echo: bool) -> io::Result<zeroize::Zeroizing<String>> {
let mut tty = File::options().read(true).write(true).open("/dev/tty")?;
// Show prompt.
@@ -391,7 +391,7 @@ fn read_from_tty(message: &PamMessage, echo: bool) -> io::Result<String> {
// Read one line from the tty.
let mut reader = io::BufReader::new(tty.try_clone()?);
let mut line = String::new();
let mut line = zeroize::Zeroizing::new(String::new());
reader.read_line(&mut line)?;
// Print a newline after hidden input so the cursor moves down.
@@ -412,9 +412,9 @@ fn read_from_tty(message: &PamMessage, echo: bool) -> io::Result<String> {
}
/// Read a line from stdin without prompting.
fn read_from_stdin() -> io::Result<String> {
fn read_from_stdin() -> io::Result<zeroize::Zeroizing<String>> {
let stdin = io::stdin();
let mut line = String::new();
let mut line = zeroize::Zeroizing::new(String::new());
stdin.lock().read_line(&mut line)?;
if line.ends_with('\n') {
@@ -725,12 +725,13 @@ impl Drop for PamContext {
}
}
// `PamContext` holds a raw pointer but is safe to send between threads — the
// PAM handle is only accessed through `&mut self` methods, and the pointer is
// not shared.
//
// SAFETY: The raw pointer `handle` is exclusively owned by `PamContext`. No
// concurrent access is possible because all mutating methods require `&mut self`.
//
// Note: While sending the handle between threads is memory-safe, PAM module
// implementations are not guaranteed to be thread-safe. In practice, all
// shadow-rs tools are single-threaded, so this is not an issue. Do not use
// `PamContext` across threads without verifying the PAM modules in use.
unsafe impl Send for PamContext {}
// ---------------------------------------------------------------------------
+4
View File
@@ -10,6 +10,10 @@
//! contexts during atomic file replacement.
//!
//! Feature-gated behind `selinux`. When disabled, all operations are no-ops.
//!
//! **Implementation note**: This module currently shells out to `getfattr`,
//! `chcon`, and `restorecon` rather than using native `libselinux` bindings.
//! Native integration is planned for a future release.
use std::path::Path;
+7 -2
View File
@@ -68,8 +68,13 @@ impl ShadowEntry {
/// Unlock the password by removing the leading `!`.
///
/// Returns `false` if the password is not locked or would become empty
/// after unlocking (GNU passwd refuses this — use `delete` instead).
/// Returns `false` if the password is not locked, would become empty
/// after unlocking, or is the system account marker `*` (which
/// cannot be unlocked — use `delete_password` + rehash instead).
///
/// Note: `is_locked()` returns `true` for `*`, but `unlock()` returns
/// `false` because removing one character from `*` yields an empty
/// password, which GNU passwd refuses.
pub fn unlock(&mut self) -> bool {
if !self.is_locked() {
return false;
+1 -2
View File
@@ -42,8 +42,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path, uid: u32, gid: u32) -> Result<(),
.map_err(|e| ShadowError::IoPath(e, src_path.clone()))?;
if file_type.is_dir() {
std::fs::create_dir_all(&dst_path)
.map_err(|e| ShadowError::IoPath(e, dst_path.clone()))?;
std::fs::create_dir(&dst_path).map_err(|e| ShadowError::IoPath(e, dst_path.clone()))?;
// Preserve the source directory's permissions.
let src_perms = std::fs::metadata(&src_path)
.map_err(|e| ShadowError::IoPath(e, src_path.clone()))?
+4
View File
@@ -23,6 +23,10 @@ const MAX_USERNAME_LEN: usize = 32;
/// - Must not end with a period (historically problematic)
/// - Must not consist of only dots
///
/// **Deviation from GNU shadow-utils**: GNU allows `$` as the final
/// character (used by Samba machine accounts, e.g., `MACHINE$`). This
/// implementation intentionally rejects `$` for stricter validation.
///
/// # Errors
///
/// Returns `ShadowError::Validation` if the username violates any rule.
+7 -71
View File
@@ -93,49 +93,6 @@ impl UError for ChageError {
}
}
// Hardening functions are now centralized in shadow_core::hardening.
// ---------------------------------------------------------------------------
// Signal blocking during critical sections
// ---------------------------------------------------------------------------
/// RAII guard that blocks signals during critical sections and restores on drop.
///
/// Prevents SIGINT/SIGTERM/SIGHUP from interrupting a lock-modify-write
/// sequence, which could leave the shadow file in an inconsistent state
/// or holding a stale lock.
struct SignalBlocker {
old_mask: nix::sys::signal::SigSet,
}
impl SignalBlocker {
/// Block `SIGINT`, `SIGTERM`, `SIGHUP` to prevent partial file writes.
fn block_critical() -> Result<Self, ChageError> {
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
let mut block_set = SigSet::empty();
block_set.add(Signal::SIGINT);
block_set.add(Signal::SIGTERM);
block_set.add(Signal::SIGHUP);
let mut old_mask = SigSet::empty();
nix::sys::signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&block_set), Some(&mut old_mask))
.map_err(|e| ChageError::UnexpectedFailure(format!("cannot block signals: {e}")))?;
Ok(Self { old_mask })
}
}
impl Drop for SignalBlocker {
fn drop(&mut self) {
let _ = nix::sys::signal::sigprocmask(
nix::sys::signal::SigmaskHow::SIG_SETMASK,
Some(&self.old_mask),
None,
);
}
}
// ---------------------------------------------------------------------------
// Date parsing
// ---------------------------------------------------------------------------
@@ -312,8 +269,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
if is_list {
// -l mode: non-root can view own aging info.
if !caller_is_root() {
let current_user = get_current_username()?;
if !shadow_core::hardening::caller_is_root() {
let current_user = shadow_core::hardening::current_username()
.map_err(|e| ChageError::UnexpectedFailure(e.to_string()))?;
if current_user != *login {
return Err(ChageError::PermissionDenied("Permission denied.".into()).into());
}
@@ -322,7 +280,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
}
// All modification flags require root.
if !caller_is_root() {
if !shadow_core::hardening::caller_is_root() {
return Err(ChageError::PermissionDenied("Permission denied.".into()).into());
}
@@ -537,35 +495,12 @@ fn compute_inactive_display(
// Helpers
// ---------------------------------------------------------------------------
/// Check if the *real* caller is root (not just setuid-root).
///
/// Uses `getuid()` (real UID). When chage is installed setuid-root,
/// euid is 0 for all callers, but real UID identifies who actually
/// invoked the program.
fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
/// Return the current user's username (from real UID).
fn get_current_username() -> Result<String, ChageError> {
let uid = nix::unistd::getuid();
match nix::unistd::User::from_uid(uid) {
Ok(Some(user)) => Ok(user.name),
Ok(None) => Err(ChageError::UnexpectedFailure(format!(
"cannot determine current username for uid {uid}"
))),
Err(e) => Err(ChageError::UnexpectedFailure(format!(
"cannot determine current username: {e}"
))),
}
}
/// Perform `chroot(2)` into the specified directory.
///
/// Must be root to call `chroot`. After `chroot`, chdir to `/` so the
/// working directory is valid inside the new root.
fn do_chroot(dir: &str) -> Result<(), ChageError> {
if !caller_is_root() {
if !shadow_core::hardening::caller_is_root() {
return Err(ChageError::PermissionDenied(
"only root may use --root".into(),
));
@@ -595,7 +530,8 @@ where
}
// Block signals for the entire critical section (lock -> write -> unlock).
let _signals = SignalBlocker::block_critical()?;
let _signals = shadow_core::hardening::SignalBlocker::block_critical()
.map_err(|e| ChageError::UnexpectedFailure(e.to_string()))?;
let shadow_path = root.shadow_path();
+8 -61
View File
@@ -104,71 +104,16 @@ impl Gecos {
// Hardening functions are now centralized in shadow_core::hardening.
// ---------------------------------------------------------------------------
// Signal blocking
// ---------------------------------------------------------------------------
/// RAII guard that blocks signals during critical sections.
struct SignalBlocker {
old_mask: nix::sys::signal::SigSet,
}
impl SignalBlocker {
fn block_critical() -> Result<Self, ChfnError> {
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
let mut block_set = SigSet::empty();
block_set.add(Signal::SIGINT);
block_set.add(Signal::SIGTERM);
block_set.add(Signal::SIGHUP);
let mut old_mask = SigSet::empty();
nix::sys::signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&block_set), Some(&mut old_mask))
.map_err(|e| ChfnError::Error(format!("cannot block signals: {e}")))?;
Ok(Self { old_mask })
}
}
impl Drop for SignalBlocker {
fn drop(&mut self) {
let _ = nix::sys::signal::sigprocmask(
nix::sys::signal::SigmaskHow::SIG_SETMASK,
Some(&self.old_mask),
None,
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Check if the *real* caller is root.
fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
/// Return the current user's username (from real UID).
fn get_current_username() -> Result<String, ChfnError> {
let uid = nix::unistd::getuid();
match nix::unistd::User::from_uid(uid) {
Ok(Some(user)) => Ok(user.name),
Ok(None) => Err(ChfnError::Error(format!(
"cannot determine current username for uid {uid}"
))),
Err(e) => Err(ChfnError::Error(format!(
"cannot determine current username: {e}"
))),
}
}
/// Resolve the target username from args or current user.
fn resolve_target_user(matches: &clap::ArgMatches) -> Result<String, ChfnError> {
if let Some(user) = matches.get_one::<String>(options::USER) {
return Ok(user.clone());
}
get_current_username()
shadow_core::hardening::current_username().map_err(|e| ChfnError::Error(e.to_string()))
}
/// Validate that a GECOS sub-field does not contain illegal characters.
@@ -190,7 +135,7 @@ fn validate_gecos_field(value: &str, field_name: &str, allow_comma: bool) -> Res
/// Perform `chroot(2)` into the specified directory.
fn do_chroot(dir: &str) -> Result<(), ChfnError> {
if !caller_is_root() {
if !shadow_core::hardening::caller_is_root() {
return Err(ChfnError::Error("only root may use --root".into()));
}
@@ -219,7 +164,8 @@ where
let _ = nix::unistd::setuid(nix::unistd::Uid::from_raw(0));
}
let _signals = SignalBlocker::block_critical()?;
let _signals = shadow_core::hardening::SignalBlocker::block_critical()
.map_err(|e| ChfnError::Error(e.to_string()))?;
let passwd_path = root.passwd_path();
@@ -301,8 +247,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let target_user = resolve_target_user(&matches)?;
// Non-root users can only change their own info.
if !caller_is_root() {
let current_user = get_current_username()?;
if !shadow_core::hardening::caller_is_root() {
let current_user = shadow_core::hardening::current_username()
.map_err(|e| ChfnError::Error(e.to_string()))?;
if current_user != target_user {
return Err(
ChfnError::Error("you may only change your own finger information".into()).into(),
@@ -349,7 +296,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
}
// Non-root users may not set the "other" field (matches GNU behavior).
if !caller_is_root() && new_other.is_some() {
if !shadow_core::hardening::caller_is_root() && new_other.is_some() {
return Err(ChfnError::Error("only root may change the 'other' field".into()).into());
}
+4 -47
View File
@@ -77,45 +77,6 @@ impl UError for ChpasswdError {
}
}
// Hardening functions are now centralized in shadow_core::hardening.
// ---------------------------------------------------------------------------
// Signal blocking during critical sections
// ---------------------------------------------------------------------------
/// RAII guard that blocks signals during critical sections and restores on drop.
struct SignalBlocker {
old_mask: nix::sys::signal::SigSet,
}
impl SignalBlocker {
/// Block `SIGINT`, `SIGTERM`, `SIGHUP` to prevent partial file writes.
fn block_critical() -> Result<Self, ChpasswdError> {
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
let mut block_set = SigSet::empty();
block_set.add(Signal::SIGINT);
block_set.add(Signal::SIGTERM);
block_set.add(Signal::SIGHUP);
let mut old_mask = SigSet::empty();
nix::sys::signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&block_set), Some(&mut old_mask))
.map_err(|e| ChpasswdError::UnexpectedFailure(format!("cannot block signals: {e}")))?;
Ok(Self { old_mask })
}
}
impl Drop for SignalBlocker {
fn drop(&mut self) {
let _ = nix::sys::signal::sigprocmask(
nix::sys::signal::SigmaskHow::SIG_SETMASK,
Some(&self.old_mask),
None,
);
}
}
// ---------------------------------------------------------------------------
// Input parsing
// ---------------------------------------------------------------------------
@@ -224,7 +185,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let root = SysRoot::default();
// chpasswd always requires root.
if !caller_is_root() {
if !shadow_core::hardening::caller_is_root() {
return Err(ChpasswdError::PermissionDenied("Permission denied.".into()).into());
}
@@ -348,7 +309,8 @@ fn apply_password_changes(
}
// Block signals for the entire critical section.
let _signals = SignalBlocker::block_critical()?;
let _signals = shadow_core::hardening::SignalBlocker::block_critical()
.map_err(|e| ChpasswdError::UnexpectedFailure(e.to_string()))?;
let shadow_path = root.shadow_path();
@@ -448,14 +410,9 @@ fn resolve_crypt_method(
}
}
/// Check if the *real* caller is root (not just setuid-root).
fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
/// Perform `chroot(2)` into the specified directory.
fn do_chroot(dir: &str) -> Result<(), ChpasswdError> {
if !caller_is_root() {
if !shadow_core::hardening::caller_is_root() {
return Err(ChpasswdError::PermissionDenied(
"only root may use --root".into(),
));
+8 -58
View File
@@ -63,71 +63,19 @@ impl UError for ChshError {
// Hardening functions are now centralized in shadow_core::hardening.
// ---------------------------------------------------------------------------
// Signal blocking
// ---------------------------------------------------------------------------
struct SignalBlocker {
old_mask: nix::sys::signal::SigSet,
}
impl SignalBlocker {
fn block_critical() -> Result<Self, ChshError> {
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
let mut block_set = SigSet::empty();
block_set.add(Signal::SIGINT);
block_set.add(Signal::SIGTERM);
block_set.add(Signal::SIGHUP);
let mut old_mask = SigSet::empty();
nix::sys::signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&block_set), Some(&mut old_mask))
.map_err(|e| ChshError::Error(format!("cannot block signals: {e}")))?;
Ok(Self { old_mask })
}
}
impl Drop for SignalBlocker {
fn drop(&mut self) {
let _ = nix::sys::signal::sigprocmask(
nix::sys::signal::SigmaskHow::SIG_SETMASK,
Some(&self.old_mask),
None,
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
fn get_current_username() -> Result<String, ChshError> {
let uid = nix::unistd::getuid();
match nix::unistd::User::from_uid(uid) {
Ok(Some(user)) => Ok(user.name),
Ok(None) => Err(ChshError::Error(format!(
"cannot determine current username for uid {uid}"
))),
Err(e) => Err(ChshError::Error(format!(
"cannot determine current username: {e}"
))),
}
}
fn resolve_target_user(matches: &clap::ArgMatches) -> Result<String, ChshError> {
if let Some(user) = matches.get_one::<String>(options::USER) {
return Ok(user.clone());
}
get_current_username()
shadow_core::hardening::current_username().map_err(|e| ChshError::Error(e.to_string()))
}
fn do_chroot(dir: &str) -> Result<(), ChshError> {
if !caller_is_root() {
if !shadow_core::hardening::caller_is_root() {
return Err(ChshError::Error("only root may use --root".into()));
}
@@ -191,7 +139,7 @@ fn validate_shell(shell: &str, shells_path: &Path) -> Result<(), ChshError> {
}
// Root can set any existing shell, bypassing the /etc/shells check.
if caller_is_root() {
if shadow_core::hardening::caller_is_root() {
return Ok(());
}
@@ -230,7 +178,8 @@ where
let _ = nix::unistd::setuid(nix::unistd::Uid::from_raw(0));
}
let _signals = SignalBlocker::block_critical()?;
let _signals = shadow_core::hardening::SignalBlocker::block_critical()
.map_err(|e| ChshError::Error(e.to_string()))?;
let passwd_path = root.passwd_path();
@@ -325,8 +274,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let target_user = resolve_target_user(&matches)?;
// Non-root users can only change their own shell.
if !caller_is_root() {
let current_user = get_current_username()?;
if !shadow_core::hardening::caller_is_root() {
let current_user = shadow_core::hardening::current_username()
.map_err(|e| ChshError::Error(e.to_string()))?;
if current_user != target_user {
return Err(ChshError::Error("you may only change your own login shell".into()).into());
}
+6 -6
View File
@@ -89,11 +89,6 @@ impl UError for GroupaddError {
// Hardening functions are now centralized in shadow_core::hardening.
/// Check whether the real UID is root.
fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
@@ -113,7 +108,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
}
};
if !caller_is_root() {
if !shadow_core::hardening::caller_is_root() {
uucore::show_error!("Permission denied.");
return Err(GroupaddError::AlreadyPrinted(1).into());
}
@@ -188,6 +183,11 @@ fn do_groupadd(matches: &clap::ArgMatches) -> UResult<()> {
&login_defs_overrides,
)?;
// Block signals for the duration of the critical section so a SIGINT
// between lock acquisition and atomic_write cannot leave stale lock files.
let _signals = shadow_core::hardening::SignalBlocker::block_critical()
.map_err(|e| GroupaddError::CantUpdate(format!("cannot block signals: {e}")))?;
// Write to /etc/group.
write_group_entry(&group_path, &group_name, gid)?;

Some files were not shown because too many files have changed in this diff Show More