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
+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)))?;
}