fix(security): patch critical IDOR & auth vulnerabilities

- Fix logout no-op: extract refresh token from cookie/body (auth_handler)
- Secure all 12 WebDAV handlers with AuthUser + resolve_path_for_user
- Secure all 7 batch handlers with caller_id ownership checks
- Add _owned variants: copy_file_owned, delete_file_owned, get_file_stream_owned, get_folder_owned
- Secure list_files_query: add AuthUser, SQL-level user_id filter, tenant-isolated ETag
- Remove deprecated unscoped resolve_path() and exists() from PathResolverService
- Remove dead list_files handler (unmounted, no auth)
- Add list_files_for_owner (SQL) and list_files_owned across trait chain
This commit is contained in:
Dionisio
2026-03-05 10:30:39 +01:00
parent ee86c3a128
commit fdbb2bf60a
14 changed files with 585 additions and 174 deletions
@@ -260,6 +260,62 @@ impl FileReadPort for FileBlobReadRepository {
.collect()
}
/// User-scoped file listing — adds `AND fi.user_id = $2` to prevent
/// cross-user data leakage in the REST API (`list_files_query`).
async fn list_files_for_owner(
&self,
folder_id: Option<&str>,
owner_id: &str,
) -> Result<Vec<File>, DomainError> {
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
AND fi.user_id = $2
ORDER BY fi.name
"#,
)
.bind(fid)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
AND fi.user_id = $1
ORDER BY fi.name
"#,
)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
}
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}"))
})?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
})
.collect()
}
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
self.resolve_blob_hash(file_id).await
}
@@ -323,6 +379,70 @@ impl FileReadPort for FileBlobReadRepository {
.collect()
}
/// User-scoped paginated file listing — adds `AND fi.user_id = $4` to
/// prevent cross-user data leakage in WebDAV PROPFIND.
async fn list_files_batch_for_owner(
&self,
folder_id: Option<&str>,
owner_id: &str,
offset: i64,
limit: i64,
) -> Result<Vec<File>, DomainError> {
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
AND fi.user_id = $4
ORDER BY fi.name
LIMIT $2 OFFSET $3
"#,
)
.bind(fid)
.bind(limit)
.bind(offset)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
AND fi.user_id = $3
ORDER BY fi.name
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
}
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("list_batch_for_owner: {e}"))
})?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
})
.collect()
}
async fn get_file_stream(
&self,
id: &str,
@@ -32,17 +32,20 @@ impl PathResolverService {
Self { pool }
}
/// Resolve `path` (without leading `/`) to either a folder or a file.
/// Resolve `path` to a folder or file **owned by `user_id`**.
///
/// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated
/// first, and PG short-circuits if it produces a row.
pub async fn resolve_path(&self, path: &str) -> Result<ResolvedResource, DomainError> {
/// Adds `AND fo.user_id = $4` / `AND fi.user_id = $4` so that one
/// user can never resolve another user's resources.
pub async fn resolve_path_for_user(
&self,
path: &str,
user_id: &str,
) -> Result<ResolvedResource, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
return Err(DomainError::not_found("Resource", "empty path"));
}
// Split into folder_path + filename for the file branch
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let filename = segments[segments.len() - 1];
let folder_path = if segments.len() > 1 {
@@ -51,9 +54,6 @@ impl PathResolverService {
String::new()
};
// Single round-trip: folder branch ∪ file branch, LIMIT 1.
// Column order: resource_type, id, name, path, parent_id, user_id,
// created_at, modified_at, size, mime_type, folder_id
let row = sqlx::query_as::<
_,
(
@@ -61,13 +61,13 @@ impl PathResolverService {
String, // id
String, // name
String, // path
Option<String>, // parent_id (folder) / NULL (file)
Option<String>, // parent_id
Option<String>, // user_id
i64, // created_at epoch
i64, // modified_at epoch
Option<i64>, // size (NULL for folder)
Option<String>, // mime_type (NULL for folder)
Option<String>, // folder_id (NULL for folder)
i64, // created_at
i64, // modified_at
Option<i64>, // size
Option<String>, // mime_type
Option<String>, // folder_id
),
>(
r#"
@@ -87,6 +87,7 @@ impl PathResolverService {
NULL::text AS folder_id
FROM storage.folders fo
WHERE fo.path = $1 AND NOT fo.is_trashed
AND fo.user_id = $4
UNION ALL
@@ -113,16 +114,18 @@ impl PathResolverService {
OR fo.path = $3
)
AND NOT fi.is_trashed
AND fi.user_id = $4
) sub
LIMIT 1
"#,
)
.bind(path) // $1 — full path for folder lookup
.bind(filename) // $2 — filename for file lookup
.bind(&folder_path) // $3 — parent folder path for file lookup
.bind(path) // $1
.bind(filename) // $2
.bind(&folder_path) // $3
.bind(user_id) // $4
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))?
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
.ok_or_else(|| DomainError::not_found("Resource", path))?;
let (
@@ -131,7 +134,7 @@ impl PathResolverService {
name,
res_path,
parent_id,
user_id,
uid,
created_at,
modified_at,
size,
@@ -145,7 +148,7 @@ impl PathResolverService {
name: name.clone(),
path: res_path,
parent_id,
owner_id: user_id,
owner_id: uid,
created_at: created_at as u64,
modified_at: modified_at as u64,
is_root: false,
@@ -169,16 +172,14 @@ impl PathResolverService {
icon_special_class: Arc::from(icon_special_class_for(&name, &mime)),
category: Arc::from(category_for(&name, &mime)),
size_formatted: format_file_size(sz),
owner_id: user_id,
owner_id: uid,
}))
}
}
}
/// Check whether *any* resource (folder or file) exists at the given path.
///
/// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO.
pub async fn exists(&self, path: &str) -> Result<bool, DomainError> {
/// Returns `true` if the resource at `path` belongs to `user_id`.
pub async fn exists_for_user(&self, path: &str, user_id: &str) -> Result<bool, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
return Ok(false);
@@ -196,7 +197,7 @@ impl PathResolverService {
r#"
SELECT EXISTS(
SELECT 1 FROM storage.folders
WHERE path = $1 AND NOT is_trashed
WHERE path = $1 AND NOT is_trashed AND user_id = $4
) OR EXISTS(
SELECT 1
FROM storage.files fi
@@ -204,15 +205,17 @@ impl PathResolverService {
WHERE fi.name = $2
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
AND NOT fi.is_trashed
AND fi.user_id = $4
)
"#,
)
.bind(path)
.bind(filename)
.bind(&folder_path)
.bind(user_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?;
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists_for_user: {e}")))?;
Ok(exists)
}