fix(security): apply 4 vulnerability fixes from security audit

1. Share password bypass (HIGH): enforce password check in get_shared_link_by_token,
   verify_shared_link_password now returns ShareDto only on correct password.
2. WebDAV MOVE ownership (MEDIUM): add assert_owner on destination parent folder
   for file moves in both PathResolver and legacy branches.
3. Path traversal defense-in-depth (LOW): add reject_path_traversal() to WebDAV,
   CalDAV, and CardDAV handlers rejecting '..' segments at HTTP boundary.
4. Setup race condition (LOW): atomic INSERT ... ON CONFLICT DO NOTHING in
   try_claim_initialization prevents duplicate admin creation.
This commit is contained in:
Dionisio
2026-03-05 16:09:37 +01:00
parent 33cfb0faef
commit 4197cc3b7b
9 changed files with 198 additions and 14 deletions
+4 -2
View File
@@ -52,12 +52,14 @@ pub trait ShareUseCase: Send + Sync + 'static {
per_page: usize,
) -> Result<PaginatedResponseDto<ShareDto>, DomainError>;
/// Verify a password for a password-protected shared link
/// Verify a password for a password-protected shared link.
/// On success, returns the full share metadata (`ShareDto`).
/// On failure (wrong password), returns `AccessDenied`.
async fn verify_shared_link_password(
&self,
token: &str,
password: &str,
) -> Result<bool, DomainError>;
) -> Result<ShareDto, DomainError>;
/// Register an access to a shared link
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError>;
@@ -378,6 +378,21 @@ impl AdminSettingsService {
.await
}
/// Atomically try to claim system initialization.
///
/// Returns `Ok(true)` if this call was the one that marked the system as
/// initialized (the caller "won" the race), or `Ok(false)` if another
/// request already did it. This eliminates the race-condition window
/// between `is_system_initialized()` and `mark_system_initialized()`.
pub async fn try_claim_initialization(
&self,
admin_user_id: &str,
) -> Result<bool, DomainError> {
self.settings_repo
.try_claim_initialization(admin_user_id)
.await
}
// ========================================================================
// Registration Control
// ========================================================================
+27 -4
View File
@@ -20,7 +20,7 @@ use crate::{
storage_ports::FileReadPort,
},
},
common::{config::AppConfig, errors::DomainError},
common::{config::AppConfig, errors::{DomainError, ErrorKind}},
domain::entities::share::{Share, ShareItemType, SharePermissions},
};
@@ -239,6 +239,17 @@ impl ShareUseCase for ShareService {
return Err(ShareServiceError::Expired.into());
}
// SECURITY: If the share is password-protected, do NOT return
// the full metadata. Force the caller to verify the password
// first via `verify_shared_link_password`.
if share.has_password() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Share",
"This share is password protected",
));
}
// Convert the entity to DTO for the response
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
@@ -357,7 +368,7 @@ impl ShareUseCase for ShareService {
&self,
token: &str,
password: &str,
) -> Result<bool, DomainError> {
) -> Result<ShareDto, DomainError> {
// Find the shared link by its token
let share = self
.share_repository
@@ -374,9 +385,21 @@ impl ShareUseCase for ShareService {
// Verify the password using the infrastructure port
match share.password_hash() {
Some(hash) => self.password_hasher.verify_password(password, hash).await,
None => Ok(true), // No password required
Some(hash) => {
let is_valid = self.password_hasher.verify_password(password, hash).await?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Share",
"Invalid share password",
));
}
}
None => { /* No password required — allow access */ }
}
// Password verified (or not required) — return full share metadata
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {