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:
@@ -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()))?;
|
||||
|
||||
|
||||
@@ -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)))?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user