fix(security): patch 3 vulnerabilities — IDOR, ownership bypass, XSS

V1: Add owner-scoped folder pagination (list_folders_by_owner_paginated)
  - New method in FolderRepository trait, PG implementation, service & handler
  - Prevents IDOR by filtering folder listings to authenticated user

V2: Enforce ownership checks on folder mutations
  - rename_folder, move_folder, delete_folder now require caller_id
  - Service verifies folder.owner_id == caller_id (returns 404 on mismatch)
  - Propagated to folder_handler, batch_handler, batch_operations, webdav_handler
  - delete_folder_with_trash upgraded from OptionalAuthUser to AuthUser
  - download_folder_zip now checks ownership before streaming

V3: Fix XSS in frontend via DOM APIs
  - sharedView.js: innerHTML → createElement + textContent
  - contextMenus.js: innerHTML → DOM construction for share dialog

Cleanup: removed unused OptionalAuthUser import, updated all stubs/mocks
This commit is contained in:
Dionisio
2026-02-16 00:22:42 +01:00
parent 66b4acd9d6
commit 5a679dfc90
25 changed files with 684 additions and 269 deletions
+14 -6
View File
@@ -38,15 +38,23 @@ pub trait FolderUseCase: Send + Sync + 'static {
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Renames a folder
async fn rename_folder(&self, id: &str, dto: RenameFolderDto)
/// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_for_owner_paginated(
&self,
parent_id: Option<&str>,
owner_id: &str,
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Renames a folder (ownership verified against caller_id)
async fn rename_folder(&self, id: &str, dto: RenameFolderDto, caller_id: &str)
-> Result<FolderDto, DomainError>;
/// Moves a folder to another parent
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError>;
/// Moves a folder to another parent (ownership verified against caller_id)
async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError>;
/// Deletes a folder
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
/// Deletes a folder (ownership verified against caller_id)
async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError>;
}
/**
+6 -4
View File
@@ -408,6 +408,7 @@ impl BatchOperationService {
&self,
folder_ids: Vec<String>,
_recursive: bool,
caller_id: &str,
) -> Result<BatchResult<String>, BatchOperationError> {
info!("Starting batch deletion of {} folders", folder_ids.len());
let start_time = std::time::Instant::now();
@@ -427,14 +428,13 @@ impl BatchOperationService {
let folder_service = self.folder_service.clone();
let semaphore = self.semaphore.clone();
let id_clone = folder_id.clone();
let caller = caller_id.to_string();
async move {
// Acquire semaphore permit
let permit = semaphore.acquire().await.unwrap();
// For both recursive and non-recursive, use the standard delete_folder method
// since FolderUseCase only has a single delete_folder method
let delete_result = folder_service.delete_folder(&folder_id).await;
let delete_result = folder_service.delete_folder(&folder_id, &caller).await;
// Release the permit explicitly
drop(permit);
@@ -616,6 +616,7 @@ impl BatchOperationService {
&self,
folder_ids: Vec<String>,
target_folder_id: Option<String>,
caller_id: &str,
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
info!("Starting batch move of {} folders", folder_ids.len());
let start_time = std::time::Instant::now();
@@ -633,11 +634,12 @@ impl BatchOperationService {
let folder_service = self.folder_service.clone();
let target = target_folder_id.clone();
let semaphore = self.semaphore.clone();
let caller = caller_id.to_string();
async move {
let permit = semaphore.acquire().await.unwrap();
let dto = MoveFolderDto { parent_id: target };
let move_result = folder_service.move_folder(&folder_id, dto).await;
let move_result = folder_service.move_folder(&folder_id, dto, &caller).await;
drop(permit);
(folder_id, move_result)
}
+98 -16
View File
@@ -71,10 +71,30 @@ impl FolderService {
)
}
async fn list_folders_for_owner_paginated(
&self,
_parent_id: Option<&str>,
_owner_id: &str,
_pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<
crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>,
DomainError,
> {
Ok(
crate::application::dtos::pagination::PaginatedResponseDto::new(
vec![],
0,
10,
0,
),
)
}
async fn rename_folder(
&self,
_id: &str,
_dto: RenameFolderDto,
_caller_id: &str,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
@@ -83,11 +103,12 @@ impl FolderService {
&self,
_id: &str,
_dto: MoveFolderDto,
_caller_id: &str,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> {
Ok(())
}
}
@@ -211,17 +232,15 @@ impl FolderUseCase for FolderService {
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>
{
// Validate and adjust pagination
let pagination = pagination.validate_and_adjust();
// Get paginated folders and total count
let (folders, total_items) = self
.folder_storage
.list_folders_paginated(
parent_id,
pagination.offset(),
pagination.limit(),
true, // Always include total for better UX
true,
)
.await
.map_err(|e| {
@@ -234,10 +253,8 @@ impl FolderUseCase for FolderService {
)
})?;
// The total is needed to calculate pagination
let total = total_items.unwrap_or(folders.len());
// Convert to PaginatedResponseDto
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
folders.into_iter().map(FolderDto::from).collect(),
pagination.page,
@@ -248,11 +265,54 @@ impl FolderUseCase for FolderService {
Ok(response)
}
/// Renames a folder
/// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_for_owner_paginated(
&self,
parent_id: Option<&str>,
owner_id: &str,
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>
{
let pagination = pagination.validate_and_adjust();
let (folders, total_items) = self
.folder_storage
.list_folders_by_owner_paginated(
parent_id,
owner_id,
pagination.offset(),
pagination.limit(),
true,
)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!(
"Failed to list folders for owner '{}' with pagination in parent {:?}: {}",
owner_id, parent_id, e
),
)
})?;
let total = total_items.unwrap_or(folders.len());
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
folders.into_iter().map(FolderDto::from).collect(),
pagination.page,
pagination.page_size,
total,
);
Ok(response)
}
/// Renames a folder after verifying ownership.
async fn rename_folder(
&self,
id: &str,
dto: RenameFolderDto,
caller_id: &str,
) -> Result<FolderDto, DomainError> {
// Input validation
if dto.name.is_empty() {
@@ -263,7 +323,7 @@ impl FolderUseCase for FolderService {
));
}
// Verify the folder exists
// Verify the folder exists and belongs to the caller
let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
@@ -271,6 +331,14 @@ impl FolderUseCase for FolderService {
)
})?;
if existing_folder.owner_id() != Some(caller_id) {
tracing::warn!(
"rename_folder: user '{}' attempted to rename folder '{}' owned by '{:?}'",
caller_id, id, existing_folder.owner_id()
);
return Err(DomainError::not_found("Folder", id));
}
// Create transaction for renaming
let mut transaction = StorageTransaction::new("rename_folder");
@@ -323,9 +391,9 @@ impl FolderUseCase for FolderService {
Ok(FolderDto::from(folder))
}
/// Moves a folder to a new parent
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError> {
// Verify the source folder exists
/// Moves a folder to a new parent after verifying ownership.
async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError> {
// Verify the source folder exists and belongs to the caller
let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
@@ -333,6 +401,14 @@ impl FolderUseCase for FolderService {
)
})?;
if source_folder.owner_id() != Some(caller_id) {
tracing::warn!(
"move_folder: user '{}' attempted to move folder '{}' owned by '{:?}'",
caller_id, id, source_folder.owner_id()
);
return Err(DomainError::not_found("Folder", id));
}
// If a parent_id is specified, verify it exists
if let Some(parent_id) = &dto.parent_id {
// Verify we are not trying to move the folder into itself or one of its descendants
@@ -408,17 +484,23 @@ impl FolderUseCase for FolderService {
Ok(FolderDto::from(folder))
}
/// Deletes a folder
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
// Verify the folder exists
let _folder = self.folder_storage.get_folder(id).await.map_err(|e| {
/// Deletes a folder after verifying ownership.
async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError> {
// Verify the folder exists and belongs to the caller
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("Failed to get folder with ID: {} for deletion: {}", id, e),
)
})?;
// In a real implementation, we could verify permissions, dependencies, etc.
if folder.owner_id() != Some(caller_id) {
tracing::warn!(
"delete_folder: user '{}' attempted to delete folder '{}' owned by '{:?}'",
caller_id, id, folder.owner_id()
);
return Err(DomainError::not_found("Folder", id));
}
// Delete the folder
self.folder_storage.delete_folder(id).await.map_err(|e| {
+12
View File
@@ -544,6 +544,18 @@ mod tests {
unimplemented!()
}
async fn list_folders_by_owner_paginated(
&self,
_parent_id: Option<&str>,
_owner_id: &str,
_offset: usize,
_limit: usize,
_include_total: bool,
) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError>
{
unimplemented!()
}
async fn rename_folder(
&self,
_id: &str,
@@ -373,6 +373,17 @@ impl FolderRepository for MockFolderRepository {
Ok((vec![], Some(0)))
}
async fn list_folders_by_owner_paginated(
&self,
_parent_id: Option<&str>,
_owner_id: &str,
_offset: usize,
_limit: usize,
_include_total: bool,
) -> std::result::Result<(Vec<Folder>, Option<usize>), DomainError> {
Ok((vec![], Some(0)))
}
async fn rename_folder(
&self,
_id: &str,
+23 -2
View File
@@ -268,6 +268,17 @@ impl FolderRepository for StubFolderStoragePort {
Ok((Vec::new(), Some(0)))
}
async fn list_folders_by_owner_paginated(
&self,
_parent_id: Option<&str>,
_owner_id: &str,
_offset: usize,
_limit: usize,
_include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
Ok((Vec::new(), Some(0)))
}
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, DomainError> {
Ok(Folder::default())
}
@@ -374,19 +385,29 @@ impl FolderUseCase for StubFolderUseCase {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
}
async fn list_folders_for_owner_paginated(
&self,
_parent_id: Option<&str>,
_owner_id: &str,
_pagination: &PaginationRequestDto,
) -> Result<PaginatedResponseDto<FolderDto>, DomainError> {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
}
async fn rename_folder(
&self,
_id: &str,
_dto: RenameFolderDto,
_caller_id: &str,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default())
}
async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result<FolderDto, DomainError> {
async fn move_folder(&self, _id: &str, _dto: MoveFolderDto, _caller_id: &str) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default())
}
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> {
Ok(())
}
}
@@ -54,6 +54,18 @@ pub trait FolderRepository: Send + Sync + 'static {
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Lists folders with pagination, scoped to a specific owner.
/// Combines the owner filtering of `list_folders_by_owner` with
/// the pagination of `list_folders_paginated`.
async fn list_folders_by_owner_paginated(
&self,
parent_id: Option<&str>,
owner_id: &str,
offset: usize,
limit: usize,
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Renames a folder
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
@@ -369,6 +369,82 @@ impl FolderRepository for FolderDbRepository {
Ok((folders, total))
}
async fn list_folders_by_owner_paginated(
&self,
parent_id: Option<&str>,
owner_id: &str,
offset: usize,
limit: usize,
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
let total = if include_total {
let count: i64 = if let Some(pid) = parent_id {
sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed",
)
.bind(pid)
.bind(owner_id)
.fetch_one(self.pool())
.await
} else {
sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed",
)
.bind(owner_id)
.fetch_one(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("count_by_owner: {e}")))?;
Some(count as usize)
} else {
None
};
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
ORDER BY name
LIMIT $3 OFFSET $4
"#,
)
.bind(pid)
.bind(owner_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(
r#"
SELECT id::text, name, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
ORDER BY name
LIMIT $2 OFFSET $3
"#,
)
.bind(owner_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
let mut folders = Vec::with_capacity(rows.len());
for (id, name, pid, uid, ca, ma) in rows {
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
}
Ok((folders, total))
}
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
sqlx::query(
r#"
+4 -2
View File
@@ -258,6 +258,7 @@ pub async fn delete_files_batch(
/// Handler for deleting multiple folders in batch
pub async fn delete_folders_batch(
State(state): State<BatchHandlerState>,
auth_user: AuthUser,
Json(request): Json<BatchFolderOperationRequest>,
) -> ApiResult<impl IntoResponse> {
// Verify there are folders to process
@@ -274,7 +275,7 @@ pub async fn delete_folders_batch(
// Execute batch operation
let result = state
.batch_service
.delete_folders(request.folder_ids, request.recursive)
.delete_folders(request.folder_ids, request.recursive, &auth_user.id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -555,6 +556,7 @@ pub async fn trash_batch(
/// Handler for moving multiple folders in batch
pub async fn move_folders_batch(
State(state): State<BatchHandlerState>,
auth_user: AuthUser,
Json(request): Json<BatchFolderOperationRequest>,
) -> ApiResult<impl IntoResponse> {
if request.folder_ids.is_empty() {
@@ -569,7 +571,7 @@ pub async fn move_folders_batch(
let result = state
.batch_service
.move_folders(request.folder_ids, request.target_folder_id)
.move_folders(request.folder_ids, request.target_folder_id, &auth_user.id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+32 -21
View File
@@ -13,7 +13,7 @@ use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState;
use crate::common::errors::ErrorKind;
use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser};
use crate::interfaces::middleware::auth::AuthUser;
type AppState = Arc<FolderService>;
@@ -136,15 +136,14 @@ impl FolderHandler {
}
/// Lists contents of a specific folder with pagination.
/// Scoped to the authenticated user — only returns folders owned by this user.
pub async fn list_folder_contents_paginated(
State(service): State<AppState>,
_auth_user: AuthUser,
auth_user: AuthUser,
Path(id): Path<String>,
pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
// For sub-folder pagination, use the standard paginated path
// (owner filtering is implicit — sub-folders inherit ownership)
match service.list_folders_paginated(Some(&id), &pagination).await {
match service.list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination).await {
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
Err(err) => {
let status = match err.kind {
@@ -184,13 +183,14 @@ impl FolderHandler {
}
}
/// Renames a folder
/// Renames a folder (ownership enforced by service layer)
pub async fn rename_folder(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<RenameFolderDto>,
) -> impl IntoResponse {
match service.rename_folder(&id, dto).await {
match service.rename_folder(&id, dto, &auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => {
let status = match err.kind {
@@ -211,13 +211,14 @@ impl FolderHandler {
}
}
/// Moves a folder to a new parent
/// Moves a folder to a new parent (ownership enforced by service layer)
pub async fn move_folder(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<MoveFolderDto>,
) -> impl IntoResponse {
match service.move_folder(&id, dto).await {
match service.move_folder(&id, dto, &auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => {
let status = match err.kind {
@@ -231,13 +232,13 @@ impl FolderHandler {
}
}
/// Deletes a folder (with trash support)
/// Deletes a folder (ownership enforced by service layer)
pub async fn delete_folder(
State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
// For folder deletion without trash functionality
match service.delete_folder(&id).await {
match service.delete_folder(&id, &auth_user.id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => {
let status = match err.kind {
@@ -250,16 +251,13 @@ impl FolderHandler {
}
}
/// Deletes a folder with trash functionality
/// Deletes a folder with trash functionality (ownership enforced by service layer)
pub async fn delete_folder_with_trash(
State(state): State<GlobalAppState>,
OptionalAuthUser(auth_user): OptionalAuthUser,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let user_id = auth_user
.as_ref()
.map(|u| u.id.as_str())
.unwrap_or("anonymous");
let user_id = &auth_user.id;
// Check if trash service is available
if let Some(trash_service) = &state.trash_service {
tracing::info!("Moving folder to trash: {}", id);
@@ -282,7 +280,7 @@ impl FolderHandler {
// Fallback to permanent delete if trash is unavailable or failed
let folder_service = &state.applications.folder_service;
match folder_service.delete_folder(&id).await {
match folder_service.delete_folder(&id, user_id).await {
Ok(_) => {
tracing::info!("Folder permanently deleted: {}", id);
StatusCode::NO_CONTENT.into_response()
@@ -306,19 +304,32 @@ impl FolderHandler {
}
}
/// Downloads a folder as a ZIP file
/// Downloads a folder as a ZIP file (ownership enforced)
pub async fn download_folder_zip(
State(state): State<GlobalAppState>,
auth_user: AuthUser,
Path(id): Path<String>,
Query(_params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
tracing::info!("Downloading folder as ZIP: {}", id);
// Get folder information first to check it exists and get name
// Get folder information and verify ownership
let folder_service = &state.applications.folder_service;
match folder_service.get_folder(&id).await {
Ok(folder) => {
// Access check: folder must belong to the requesting user
if folder.owner_id.as_deref() != Some(&auth_user.id) {
tracing::warn!(
"download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'",
auth_user.id, id, folder.owner_id
);
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Folder not found" })),
)
.into_response();
}
tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
// Use ZIP service from DI container
@@ -645,9 +645,10 @@ async fn handle_delete(
let folder_result = folder_service.get_folder_by_path(&path).await;
if let Ok(folder) = folder_result {
// Delete folder
// Delete folder — use the folder's own owner as caller_id
let caller_id = folder.owner_id.as_deref().unwrap_or("webdav");
folder_service
.delete_folder(&folder.id)
.delete_folder(&folder.id, caller_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
} else {
@@ -761,7 +762,7 @@ async fn handle_move(
};
folder_service
.move_folder(&folder.id, move_dto)
.move_folder(&folder.id, move_dto, folder.owner_id.as_deref().unwrap_or("webdav"))
.await
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
@@ -771,7 +772,7 @@ async fn handle_move(
};
folder_service
.rename_folder(&folder.id, rename_dto)
.rename_folder(&folder.id, rename_dto, folder.owner_id.as_deref().unwrap_or("webdav"))
.await
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
}