refactor(daemon): centralize username path-segment sanitizer
Extract the inline path-traversal check from PendingStore into a shared storage::safe_user::join_user_segment helper. RecoveryStore (next) reuses it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
pub(crate) mod credentials;
|
pub(crate) mod credentials;
|
||||||
pub(crate) mod pending;
|
pub(crate) mod pending;
|
||||||
pub(crate) mod policy;
|
pub(crate) mod policy;
|
||||||
|
pub(crate) mod safe_user;
|
||||||
pub(crate) mod userdb;
|
pub(crate) mod userdb;
|
||||||
|
|||||||
@@ -24,15 +24,9 @@ impl PendingStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user_path(&self, user: &str) -> Result<PathBuf, PendingError> {
|
fn user_path(&self, user: &str) -> Result<PathBuf, PendingError> {
|
||||||
if user.is_empty()
|
super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e {
|
||||||
|| user.contains('/')
|
super::safe_user::SegmentError::Invalid(s) => PendingError::InvalidUser(s),
|
||||||
|| user.contains('\0')
|
})
|
||||||
|| user == "."
|
|
||||||
|| user == ".."
|
|
||||||
{
|
|
||||||
return Err(PendingError::InvalidUser(user.to_string()));
|
|
||||||
}
|
|
||||||
Ok(self.dir.join(user))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&self, user: &str, flag: &PendingFlag) -> Result<(), PendingError> {
|
pub fn set(&self, user: &str, flag: &PendingFlag) -> Result<(), PendingError> {
|
||||||
|
|||||||
45
daemon/src/storage/safe_user.rs
Normal file
45
daemon/src/storage/safe_user.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! Shared username sanitizer for path-segment use across pending/recovery stores.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub(crate) enum SegmentError {
|
||||||
|
#[error("invalid username segment: {0:?}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject any username that could escape a single path segment. Centralized
|
||||||
|
/// so pending / recovery / future stores don't drift on what's "safe."
|
||||||
|
pub(crate) fn join_user_segment(dir: &Path, user: &str) -> Result<PathBuf, SegmentError> {
|
||||||
|
if user.is_empty()
|
||||||
|
|| user == "."
|
||||||
|
|| user == ".."
|
||||||
|
|| user.contains('/')
|
||||||
|
|| user.contains('\0')
|
||||||
|
{
|
||||||
|
return Err(SegmentError::Invalid(user.to_string()));
|
||||||
|
}
|
||||||
|
Ok(dir.join(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_normal_username() {
|
||||||
|
let p = join_user_segment(Path::new("/tmp/x"), "alice").unwrap();
|
||||||
|
assert_eq!(p, PathBuf::from("/tmp/x/alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_traversal_and_separators() {
|
||||||
|
for evil in ["", ".", "..", "a/b", "../etc", "x\0y"] {
|
||||||
|
assert!(
|
||||||
|
join_user_segment(Path::new("/tmp/x"), evil).is_err(),
|
||||||
|
"{evil:?} should be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user