mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
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:
+11
-1
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user