fix(security): VULN-01 admin escalation + VULN-02 path traversal hardening
VULN-01 - Admin privilege escalation: - Harden register() to reject is_admin=true - Add /api/setup endpoint with setup_token for initial admin creation - Add SetupAdminDto and setup_token to AppState - Remove dead code from auth handler VULN-02 - Path traversal (CVSS ~8.6): - Solution A+E: Harden StoragePath constructors (from_string, new, join) to strip '..' and '.' segments and reject slash injection - Solution B: resolve_path() now returns Result<PathBuf>, calls validate_path() internally, and verifies resolved path stays under root - Update StoragePort trait signature to return Result<PathBuf, DomainError> - Remove dead code: FilePathResolutionPort, StorageVerificationPort, DirectoryManagementPort (declared but never implemented) - Add 17 security tests covering traversal attack vectors
This commit is contained in:
@@ -46,7 +46,16 @@ pub struct RegisterDto {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// DTO for the one-time initial admin setup endpoint (`/api/setup`).
|
||||
/// Requires the setup token printed to the server log on first boot.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SetupAdminDto {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub setup_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -10,8 +10,10 @@ use super::storage_ports::{FileReadPort, FileWritePort};
|
||||
|
||||
/// Secondary port for storage operations
|
||||
pub trait StoragePort: Send + Sync + 'static {
|
||||
/// Resolves a domain path to a physical path
|
||||
fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf;
|
||||
/// Resolves a domain path to a physical path.
|
||||
///
|
||||
/// Returns an error if the path contains unsafe segments (defense-in-depth).
|
||||
fn resolve_path(&self, storage_path: &StoragePath) -> Result<PathBuf, DomainError>;
|
||||
|
||||
/// Creates directories if they don't exist
|
||||
async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError>;
|
||||
|
||||
@@ -303,30 +303,6 @@ pub trait FileWritePort: Send + Sync + 'static {
|
||||
// Auxiliary ports (unchanged)
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
/// Secondary port for file path resolution
|
||||
pub trait FilePathResolutionPort: Send + Sync + 'static {
|
||||
/// Gets the storage path of a file
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
/// Resolves a domain path to a physical path
|
||||
fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf;
|
||||
}
|
||||
|
||||
/// Secondary port for file/directory existence verification
|
||||
pub trait StorageVerificationPort: Send + Sync + 'static {
|
||||
/// Checks whether a file exists at the given path
|
||||
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
|
||||
|
||||
/// Checks whether a directory exists at the given path
|
||||
async fn directory_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
/// Secondary port for directory management
|
||||
pub trait DirectoryManagementPort: Send + Sync + 'static {
|
||||
/// Creates directories if they do not exist
|
||||
async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Secondary port for storage usage management
|
||||
pub trait StorageUsagePort: Send + Sync + 'static {
|
||||
/// Updates storage usage statistics for a user
|
||||
|
||||
@@ -352,6 +352,32 @@ impl AdminSettingsService {
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// System Initialization
|
||||
// ========================================================================
|
||||
|
||||
/// Check if the system has been initialized (first admin created).
|
||||
/// Returns `true` if the `system_initialized` flag is set to `"true"` in the DB.
|
||||
pub async fn is_system_initialized(&self) -> bool {
|
||||
match self.settings_repo.get("system_initialized").await {
|
||||
Ok(Some(val)) => val == "true",
|
||||
_ => false, // fail-closed: if DB error or key absent, system is NOT initialized
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the system as initialized after the first admin is created.
|
||||
pub async fn mark_system_initialized(&self, admin_user_id: &str) -> Result<(), DomainError> {
|
||||
self.settings_repo
|
||||
.set(
|
||||
"system_initialized",
|
||||
"true",
|
||||
"system",
|
||||
false,
|
||||
Some(admin_user_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Registration Control
|
||||
// ========================================================================
|
||||
|
||||
@@ -227,73 +227,11 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Check if the user wants to create an admin
|
||||
let is_admin_request = dto.username.to_lowercase() == "admin"
|
||||
|| (dto.role.is_some() && dto.role.as_ref().unwrap().to_lowercase() == "admin");
|
||||
|
||||
// If trying to create an admin, check if admins already exist in the system
|
||||
if is_admin_request {
|
||||
match self.count_admin_users().await {
|
||||
Ok(admin_count) => {
|
||||
// If there are already admins in the system and this is not a clean install,
|
||||
// we do not allow creating another admin from registration
|
||||
if admin_count > 0 {
|
||||
// Check if this is a clean install (only the default admin)
|
||||
match self.count_all_users().await {
|
||||
Ok(user_count) => {
|
||||
// If there are more than 2 users (admin + test), it is not a clean install
|
||||
if user_count > 2 {
|
||||
tracing::warn!(
|
||||
"Attempt to create additional admin rejected: at least one admin already exists"
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"User",
|
||||
"Creating additional admin users from the registration page is not allowed",
|
||||
));
|
||||
}
|
||||
// Otherwise, it is a clean install and the first admin is allowed
|
||||
tracing::info!("Allowing admin creation on clean install");
|
||||
}
|
||||
Err(e) => {
|
||||
// Cannot verify user count — treat as bootstrap scenario
|
||||
tracing::warn!(
|
||||
"Could not count users ({}). Allowing admin creation for bootstrap.",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Any DB error (table missing, connection issue, etc.) means we
|
||||
// cannot verify admin state. Allow admin creation so the user can
|
||||
// bootstrap the system. If the DB is truly broken the INSERT will
|
||||
// fail anyway with a clear error.
|
||||
tracing::warn!(
|
||||
"Could not count admin users ({}). Allowing admin creation for bootstrap.",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine role and quota based on user type
|
||||
// If an explicit "admin" role is provided, use the administrator role
|
||||
let role = if let Some(role_str) = &dto.role {
|
||||
if role_str.to_lowercase() == "admin" {
|
||||
UserRole::Admin
|
||||
} else {
|
||||
UserRole::User
|
||||
}
|
||||
} else {
|
||||
// Special case: if the username is "admin", assign admin role even if not specified
|
||||
if dto.username.to_lowercase() == "admin" {
|
||||
UserRole::Admin
|
||||
} else {
|
||||
UserRole::User
|
||||
}
|
||||
};
|
||||
// SECURITY: Public registration ALWAYS creates regular users.
|
||||
// Admin users can only be created via:
|
||||
// 1. The one-time /api/setup endpoint (first boot)
|
||||
// 2. The admin panel (admin_create_user)
|
||||
let role = UserRole::User;
|
||||
|
||||
// Quota based on role, capped to available disk space
|
||||
let quota = self.capped_quota(&role);
|
||||
@@ -332,6 +270,92 @@ impl AuthApplicationService {
|
||||
Ok(UserDto::from(created_user))
|
||||
}
|
||||
|
||||
/// Create the first admin user during initial system setup.
|
||||
///
|
||||
/// This is called by the `/api/setup` endpoint after verifying the setup
|
||||
/// token. It unconditionally creates an admin user. The caller (handler)
|
||||
/// is responsible for:
|
||||
/// 1. Verifying the setup token
|
||||
/// 2. Checking that the system is not already initialized
|
||||
/// 3. Marking the system as initialized after this call succeeds
|
||||
pub async fn setup_create_admin(
|
||||
&self,
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
// Validate username
|
||||
if username.len() < 3 || username.len() > 32 {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Username must be between 3 and 32 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for duplicate username
|
||||
if self
|
||||
.user_storage
|
||||
.get_user_by_username(&username)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AlreadyExists,
|
||||
"User",
|
||||
format!("User '{}' already exists", username),
|
||||
));
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
if self
|
||||
.user_storage
|
||||
.get_user_by_email(&email)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AlreadyExists,
|
||||
"User",
|
||||
format!("Email '{}' is already registered", email),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if password.len() < 8 {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Password must be at least 8 characters long".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let role = UserRole::Admin;
|
||||
let quota = self.capped_quota(&role);
|
||||
let password_hash = self.password_hasher.hash_password(&password).await?;
|
||||
|
||||
let user = User::new(username.clone(), email, password_hash, role, quota).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
format!("Error creating admin user: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// Create personal folder for the admin
|
||||
self.create_personal_folder(&username, created_user.id())
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
"Initial admin created via setup: {} ({})",
|
||||
username,
|
||||
created_user.id()
|
||||
);
|
||||
Ok(UserDto::from(created_user))
|
||||
}
|
||||
|
||||
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
|
||||
// Find user
|
||||
let mut user = self
|
||||
@@ -585,106 +609,12 @@ impl AuthApplicationService {
|
||||
Ok(admin_users.len() as i64)
|
||||
}
|
||||
|
||||
// Method to count all users in the system
|
||||
// Used to determine if this is a fresh install
|
||||
pub async fn count_all_users(&self) -> Result<i64, DomainError> {
|
||||
// Get all users with large limit and 0 offset
|
||||
let all_users = self.user_storage.list_users(1000, 0).await.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"User",
|
||||
format!("Error counting users: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(all_users.len() as i64)
|
||||
}
|
||||
|
||||
// Method to delete the default admin user created by migrations
|
||||
// Used in fresh installations before creating a custom admin
|
||||
pub async fn delete_default_admin(&self) -> Result<(), DomainError> {
|
||||
// Find the default admin user (created by migrations)
|
||||
match self.get_user_by_username("admin").await {
|
||||
Ok(default_admin) => {
|
||||
// Delete the default admin user
|
||||
self.user_storage
|
||||
.delete_user(&default_admin.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"User",
|
||||
format!("Error deleting default admin user: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
// Admin user doesn't exist, nothing to do
|
||||
tracing::info!("Default admin user not found, nothing to delete");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method to replace the default admin user with a custom one
|
||||
// Used in fresh installations to allow users to set their own admin credentials
|
||||
pub async fn replace_default_admin(&self, dto: &RegisterDto) -> Result<UserDto, DomainError> {
|
||||
// 1. Get the default admin user
|
||||
let default_admin = self.get_user_by_username("admin").await?;
|
||||
|
||||
// 2. Delete the default admin user
|
||||
self.user_storage
|
||||
.delete_user(&default_admin.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"User",
|
||||
format!("Error deleting default admin user: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 3. Create new admin user with the provided credentials but admin role
|
||||
let admin_role = UserRole::Admin;
|
||||
|
||||
// Admin quota, capped to available disk space
|
||||
let admin_quota = self.capped_quota(&admin_role);
|
||||
|
||||
// Hash the password (same as register / admin_create_user)
|
||||
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
|
||||
|
||||
// Create the new admin user
|
||||
let user = User::new(
|
||||
dto.username.clone(),
|
||||
dto.email.clone(),
|
||||
password_hash,
|
||||
admin_role,
|
||||
admin_quota,
|
||||
)
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
format!("Error creating admin user: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 4. Save the new admin user
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// 5. Create personal folder for the new admin
|
||||
self.create_personal_folder(&dto.username, created_user.id())
|
||||
.await;
|
||||
|
||||
tracing::info!("Custom admin created: {}", created_user.id());
|
||||
Ok(UserDto::from(created_user))
|
||||
}
|
||||
|
||||
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
|
||||
let users = self.user_storage.list_users(limit, offset).await?;
|
||||
Ok(users.into_iter().map(UserDto::from).collect())
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Admin User Management Methods
|
||||
// ========================================================================
|
||||
|
||||
+28
-1
@@ -550,6 +550,7 @@ impl AppServiceFactory {
|
||||
app_password_service: None,
|
||||
path_resolver: None,
|
||||
webdav_lock_store: crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
|
||||
setup_token: None,
|
||||
};
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
@@ -598,7 +599,29 @@ impl AppServiceFactory {
|
||||
}
|
||||
}
|
||||
|
||||
app_state.admin_settings_service = Some(admin_svc);
|
||||
app_state.admin_settings_service = Some(admin_svc.clone());
|
||||
|
||||
// 9b-2. Generate one-time setup token if system is NOT yet initialized
|
||||
if !admin_svc.is_system_initialized().await {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut token_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut token_bytes);
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
tracing::warn!("╔══════════════════════════════════════════════════════════╗");
|
||||
tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║");
|
||||
tracing::warn!("║ ║");
|
||||
tracing::warn!("║ POST /api/setup with this one-time token: ║");
|
||||
tracing::warn!("║ {} ║", token);
|
||||
tracing::warn!("║ ║");
|
||||
tracing::warn!("║ This token is valid until the server restarts or the ║");
|
||||
tracing::warn!("║ first admin is created. Keep it secret! ║");
|
||||
tracing::warn!("╚══════════════════════════════════════════════════════════╝");
|
||||
|
||||
app_state.setup_token = Some(token);
|
||||
} else {
|
||||
tracing::info!("System already initialized — setup endpoint disabled");
|
||||
}
|
||||
|
||||
// 9c. Wire Device Authorization Grant (RFC 8628) service
|
||||
{
|
||||
@@ -857,6 +880,10 @@ pub struct AppState {
|
||||
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
|
||||
pub webdav_lock_store:
|
||||
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
|
||||
/// One-time setup token generated on startup when the system is not yet
|
||||
/// initialized. Printed to the server log so the operator can create the
|
||||
/// first admin user via `POST /api/setup`.
|
||||
pub setup_token: Option<String>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
|
||||
@@ -13,9 +13,16 @@ pub struct StoragePath {
|
||||
}
|
||||
|
||||
impl StoragePath {
|
||||
/// Creates a new storage path
|
||||
/// Checks whether a single segment is safe (no traversal, no slashes)
|
||||
fn is_safe_segment(s: &str) -> bool {
|
||||
!s.is_empty() && s != "." && s != ".." && !s.contains('/')
|
||||
}
|
||||
|
||||
/// Creates a new storage path, silently dropping any traversal segments
|
||||
pub fn new(segments: Vec<String>) -> Self {
|
||||
Self { segments }
|
||||
Self {
|
||||
segments: segments.into_iter().filter(|s| Self::is_safe_segment(s)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty path (root)
|
||||
@@ -26,10 +33,13 @@ impl StoragePath {
|
||||
}
|
||||
|
||||
/// Creates a path from a string with segments separated by /
|
||||
///
|
||||
/// Traversal segments (`.`, `..`) are silently stripped to prevent
|
||||
/// path-traversal attacks.
|
||||
pub fn from_string(path: &str) -> Self {
|
||||
let segments = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| Self::is_safe_segment(s))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
Self { segments }
|
||||
@@ -47,10 +57,15 @@ impl StoragePath {
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
/// Appends a segment to the path
|
||||
/// Appends a segment to the path.
|
||||
///
|
||||
/// Traversal segments (`.`, `..`) and segments containing `/` are
|
||||
/// silently ignored to prevent path-traversal attacks.
|
||||
pub fn join(&self, segment: &str) -> Self {
|
||||
let mut new_segments = self.segments.clone();
|
||||
new_segments.push(segment.to_string());
|
||||
if Self::is_safe_segment(segment) {
|
||||
new_segments.push(segment.to_string());
|
||||
}
|
||||
Self {
|
||||
segments: new_segments,
|
||||
}
|
||||
@@ -141,4 +156,81 @@ mod tests {
|
||||
let path = StoragePath::from_string("folder/file.txt");
|
||||
assert_eq!(path.file_name(), Some("file.txt".to_string()));
|
||||
}
|
||||
|
||||
// ── Path-traversal hardening tests (VULN-02) ──────────────
|
||||
|
||||
#[test]
|
||||
fn test_from_string_strips_dot_dot() {
|
||||
let path = StoragePath::from_string("../../etc/passwd");
|
||||
assert_eq!(path.segments(), &["etc", "passwd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_string_strips_single_dot() {
|
||||
let path = StoragePath::from_string("folder/./file.txt");
|
||||
assert_eq!(path.segments(), &["folder", "file.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_string_strips_mixed_traversal() {
|
||||
let path = StoragePath::from_string("a/../b/./c/../../d");
|
||||
assert_eq!(path.segments(), &["a", "b", "c", "d"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_string_all_traversal_yields_root() {
|
||||
let path = StoragePath::from_string("../../..");
|
||||
assert!(path.is_empty());
|
||||
assert_eq!(path.to_string(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_strips_traversal_segments() {
|
||||
let path = StoragePath::new(vec![
|
||||
"..".into(),
|
||||
"etc".into(),
|
||||
".".into(),
|
||||
"passwd".into(),
|
||||
]);
|
||||
assert_eq!(path.segments(), &["etc", "passwd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_strips_empty_segments() {
|
||||
let path = StoragePath::new(vec!["a".into(), "".into(), "b".into()]);
|
||||
assert_eq!(path.segments(), &["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_join_rejects_dot_dot() {
|
||||
let base = StoragePath::from_string("folder");
|
||||
let joined = base.join("..");
|
||||
// ".." is silently ignored — path stays unchanged
|
||||
assert_eq!(joined.segments(), &["folder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_join_rejects_single_dot() {
|
||||
let base = StoragePath::from_string("folder");
|
||||
let joined = base.join(".");
|
||||
assert_eq!(joined.segments(), &["folder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_join_rejects_slash_in_segment() {
|
||||
let base = StoragePath::from_string("folder");
|
||||
let joined = base.join("sub/../../etc/passwd");
|
||||
// Segment contains '/' → silently ignored
|
||||
assert_eq!(joined.segments(), &["folder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_pathbuf_strips_traversal() {
|
||||
let path = StoragePath::from(PathBuf::from("a/../b/./c"));
|
||||
// PathBuf Component::Normal only yields the normal parts
|
||||
// On most platforms this strips . and ..
|
||||
// but regardless, our from() only accepts Component::Normal
|
||||
assert!(!path.segments().contains(&"..".to_string()));
|
||||
assert!(!path.segments().contains(&".".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,24 @@ impl PathService {
|
||||
Self { root_path }
|
||||
}
|
||||
|
||||
/// Converts a domain path to an absolute physical path
|
||||
pub fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf {
|
||||
/// Converts a domain path to an absolute physical path.
|
||||
///
|
||||
/// Returns an error if validation fails (defense-in-depth against traversal).
|
||||
pub fn resolve_path(&self, storage_path: &StoragePath) -> Result<PathBuf, DomainError> {
|
||||
self.validate_path(storage_path)?;
|
||||
let mut path = self.root_path.clone();
|
||||
for segment in storage_path.segments() {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
// Final safety check: the resolved path must remain under root
|
||||
if !path.starts_with(&self.root_path) {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Path",
|
||||
format!("Resolved path escapes storage root: {}", path.display()),
|
||||
));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Converts a physical path to a domain path
|
||||
@@ -116,20 +127,14 @@ impl PathService {
|
||||
}
|
||||
|
||||
impl StoragePort for PathService {
|
||||
fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf {
|
||||
let mut path = self.root_path.clone();
|
||||
for segment in storage_path.segments() {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
fn resolve_path(&self, storage_path: &StoragePath) -> Result<PathBuf, DomainError> {
|
||||
// Delegate to inherent method which validates + bounds-checks
|
||||
self.resolve_path(storage_path)
|
||||
}
|
||||
|
||||
async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError> {
|
||||
// First validate the path
|
||||
self.validate_path(storage_path)?;
|
||||
|
||||
// Resolve to physical path
|
||||
let physical_path = self.resolve_path(storage_path);
|
||||
// resolve_path already calls validate_path internally
|
||||
let physical_path = self.resolve_path(storage_path)?;
|
||||
|
||||
// Check current state with a single async stat() — no worker blocking.
|
||||
match fs::metadata(&physical_path).await {
|
||||
@@ -169,7 +174,7 @@ impl StoragePort for PathService {
|
||||
}
|
||||
|
||||
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
let physical_path = self.resolve_path(storage_path);
|
||||
let physical_path = self.resolve_path(storage_path)?;
|
||||
|
||||
// Single async stat() — no worker blocking, one syscall instead of two.
|
||||
match fs::metadata(&physical_path).await {
|
||||
@@ -184,7 +189,7 @@ impl StoragePort for PathService {
|
||||
}
|
||||
|
||||
async fn directory_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
let physical_path = self.resolve_path(storage_path);
|
||||
let physical_path = self.resolve_path(storage_path)?;
|
||||
|
||||
// Single async stat() — no worker blocking, one syscall instead of two.
|
||||
match fs::metadata(&physical_path).await {
|
||||
@@ -208,7 +213,7 @@ mod tests {
|
||||
let service = PathService::new(PathBuf::from("/storage"));
|
||||
|
||||
let storage_path = StoragePath::from_string("test/file.txt");
|
||||
let absolute = service.resolve_path(&storage_path);
|
||||
let absolute = service.resolve_path(&storage_path).unwrap();
|
||||
|
||||
assert_eq!(absolute, PathBuf::from("/storage/test/file.txt"));
|
||||
}
|
||||
@@ -255,4 +260,65 @@ mod tests {
|
||||
|
||||
assert_eq!(file_path.to_string(), "/folder/file.txt");
|
||||
}
|
||||
|
||||
// ── Path-traversal hardening tests (VULN-02) ──────────────
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_traversal_stripped_by_domain() {
|
||||
// StoragePath::from_string already strips ".." segments (Solution A+E),
|
||||
// so resolve_path receives a clean path.
|
||||
let service = PathService::new(PathBuf::from("/storage"));
|
||||
let path = StoragePath::from_string("../../etc/passwd");
|
||||
let resolved = service.resolve_path(&path).unwrap();
|
||||
assert_eq!(resolved, PathBuf::from("/storage/etc/passwd"));
|
||||
assert!(resolved.starts_with("/storage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_normal_path_ok() {
|
||||
let service = PathService::new(PathBuf::from("/storage"));
|
||||
let path = StoragePath::from_string("users/alice/documents/report.pdf");
|
||||
let resolved = service.resolve_path(&path).unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("/storage/users/alice/documents/report.pdf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_path_root_ok() {
|
||||
let service = PathService::new(PathBuf::from("/storage"));
|
||||
let path = StoragePath::root();
|
||||
let resolved = service.resolve_path(&path).unwrap();
|
||||
assert_eq!(resolved, PathBuf::from("/storage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_rejects_dot_prefix() {
|
||||
let service = PathService::new(PathBuf::from("/storage"));
|
||||
// Manually construct a path with a dot-prefixed segment
|
||||
// (from_string strips ".." but allows ".hidden")
|
||||
let path = StoragePath::from_string("folder/.hidden/file.txt");
|
||||
assert!(service.validate_path(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_allows_well_known() {
|
||||
let service = PathService::new(PathBuf::from("/storage"));
|
||||
let path = StoragePath::from_string("folder/.well-known/caldav");
|
||||
assert!(service.validate_path(&path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_rejects_dangerous_chars() {
|
||||
let service = PathService::new(PathBuf::from("/storage"));
|
||||
for dangerous in &["file:name", "file*name", "file?name", "file<name", "file>name", "file|name", "file\"name"] {
|
||||
let path = StoragePath::new(vec![dangerous.to_string()]);
|
||||
assert!(
|
||||
service.validate_path(&path).is_err(),
|
||||
"validate_path should reject segment: {}",
|
||||
dangerous
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::user_dto::{
|
||||
ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto,
|
||||
RefreshTokenDto, RegisterDto,
|
||||
RefreshTokenDto, RegisterDto, SetupAdminDto,
|
||||
};
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
@@ -51,6 +51,11 @@ pub fn refresh_route() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/refresh", post(refresh_token))
|
||||
}
|
||||
|
||||
/// Public setup route — only active before the first admin is created.
|
||||
pub fn setup_route() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/setup", post(setup_admin))
|
||||
}
|
||||
|
||||
async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(dto): Json<RegisterDto>,
|
||||
@@ -343,6 +348,103 @@ async fn logout(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// POST /api/setup — One-time endpoint to create the first admin user.
|
||||
///
|
||||
/// Requires the setup token that was printed to the server log on first boot.
|
||||
/// Once the admin is created, the system is marked as initialized and this
|
||||
/// endpoint returns 403 for all subsequent requests.
|
||||
async fn setup_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(dto): Json<SetupAdminDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
tracing::info!("Setup admin request received for user: {}", dto.username);
|
||||
|
||||
// 1. Verify auth service exists
|
||||
let auth_service = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
// 2. Check if system is already initialized (fail-closed: DB error → deny)
|
||||
let admin_svc = state
|
||||
.admin_settings_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Admin settings service not configured"))?;
|
||||
|
||||
if admin_svc.is_system_initialized().await {
|
||||
tracing::warn!(
|
||||
"Setup admin rejected: system already initialized (user: {})",
|
||||
dto.username
|
||||
);
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"System is already initialized. Use the admin panel to manage users.",
|
||||
"SystemAlreadyInitialized",
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Verify the one-time setup token
|
||||
let expected_token = state.setup_token.as_deref().ok_or_else(|| {
|
||||
AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"No setup token available. The system may already be initialized or the server needs to be restarted.",
|
||||
"NoSetupToken",
|
||||
)
|
||||
})?;
|
||||
|
||||
if !constant_time_eq(dto.setup_token.as_bytes(), expected_token.as_bytes()) {
|
||||
tracing::warn!(
|
||||
"Setup admin rejected: invalid setup token (user: {})",
|
||||
dto.username
|
||||
);
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid setup token. Check the server log for the correct token.",
|
||||
"InvalidSetupToken",
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Create the first admin user
|
||||
let user = auth_service
|
||||
.auth_application_service
|
||||
.setup_create_admin(dto.username.clone(), dto.email, dto.password)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Setup admin creation failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
// 5. Mark system as initialized
|
||||
if let Err(e) = admin_svc.mark_system_initialized(&user.id).await {
|
||||
// Admin was created but we couldn't mark as initialized.
|
||||
// This is not fatal — the setup token check prevents re-use, and
|
||||
// on next restart the system will detect the admin in DB.
|
||||
tracing::error!(
|
||||
"Created admin but failed to mark system as initialized: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"System initialized: first admin '{}' created successfully",
|
||||
dto.username
|
||||
);
|
||||
|
||||
Ok((StatusCode::CREATED, Json(user)))
|
||||
}
|
||||
|
||||
/// Constant-time byte comparison to prevent timing attacks on the setup token.
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Get system status - returns whether admin is configured
|
||||
/// This is a public endpoint used to determine if setup is needed
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -363,7 +465,14 @@ async fn get_system_status(
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
// Count admin users to determine if system is initialized
|
||||
// Use the DB flag as the authoritative source for initialization status
|
||||
let db_initialized = if let Some(admin_svc) = state.admin_settings_service.as_ref() {
|
||||
admin_svc.is_system_initialized().await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Count admin users for additional info
|
||||
let admin_count = auth_service
|
||||
.auth_application_service
|
||||
.count_admin_users()
|
||||
@@ -371,9 +480,9 @@ async fn get_system_status(
|
||||
.unwrap_or(0);
|
||||
|
||||
let status = SystemStatus {
|
||||
initialized: admin_count > 0,
|
||||
initialized: db_initialized || admin_count > 0,
|
||||
admin_count,
|
||||
registration_allowed: admin_count > 0, // Only allow registration if admin exists
|
||||
registration_allowed: db_initialized || admin_count > 0,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
|
||||
+10
-1
@@ -171,7 +171,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
if config.features.enable_auth {
|
||||
use interfaces::api::handlers::auth_handler::{
|
||||
auth_routes, login_route, refresh_route, register_route,
|
||||
auth_routes, login_route, refresh_route, register_route, setup_route,
|
||||
};
|
||||
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||
@@ -229,6 +229,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_state(app_state.clone());
|
||||
// Remaining auth routes (status, OIDC, protected /me, /logout, etc.)
|
||||
let auth_router = auth_routes().with_state(app_state.clone());
|
||||
// One-time setup route — public, rate-limited like register
|
||||
let setup_router = setup_route()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
register_limiter.clone(),
|
||||
rate_limit_register,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
// Device Authorization Grant (RFC 8628)
|
||||
// Public endpoints: /api/auth/device/authorize + /api/auth/device/token
|
||||
@@ -281,6 +288,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.nest("/api/auth", auth_refresh)
|
||||
// Other auth endpoints (status, OIDC, protected /me, /logout)
|
||||
.nest("/api/auth", auth_router)
|
||||
// One-time setup endpoint — public, rate-limited
|
||||
.nest("/api", setup_router)
|
||||
// Device Auth Grant public endpoints (authorize + token polling)
|
||||
.nest("/api/auth/device", device_public)
|
||||
// Device Auth Grant protected endpoints (verify + device management)
|
||||
|
||||
Reference in New Issue
Block a user