security(search): ensure that search suggenstion returns answer the user has access to
This commit is contained in:
@@ -1404,12 +1404,20 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
folder_id: Option<&str>,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
// Scope by drive membership: `CALLER_CAN_READ_DRIVE` (`$1` =
|
||||
// caller_id) restricts the result set to files whose owning drive
|
||||
// the caller has any active `role_grants` on — direct or via a
|
||||
// transitive group cascade. Pre-fix, the query only filtered on
|
||||
// `NOT is_trashed AND name ILIKE $pattern`, exposing names + paths
|
||||
// across every tenant on the instance (AuthZ audit finding #1,
|
||||
// 2026-07-12).
|
||||
let pattern = super::like_escape(query);
|
||||
let limit_i64 = limit as i64;
|
||||
|
||||
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
|
||||
sqlx::query_as(
|
||||
sqlx::query_as(&format!(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
@@ -1420,7 +1428,40 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id = $1::uuid
|
||||
WHERE {CALLER_CAN_READ_DRIVE}
|
||||
AND fi.folder_id = $2::uuid
|
||||
AND NOT fi.is_trashed
|
||||
AND fi.name ILIKE $3
|
||||
ORDER BY CASE
|
||||
WHEN fi.name ILIKE $4 THEN 0
|
||||
WHEN fi.name ILIKE $4 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
fi.name
|
||||
LIMIT $5
|
||||
"#
|
||||
))
|
||||
.bind(caller_id)
|
||||
.bind(fid)
|
||||
.bind(&pattern)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
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.blob_hash,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE {CALLER_CAN_READ_DRIVE}
|
||||
AND fi.folder_id IS NULL
|
||||
AND NOT fi.is_trashed
|
||||
AND fi.name ILIKE $2
|
||||
ORDER BY CASE
|
||||
@@ -1430,38 +1471,9 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
END,
|
||||
fi.name
|
||||
LIMIT $4
|
||||
"#,
|
||||
)
|
||||
.bind(fid)
|
||||
.bind(&pattern)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
.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.blob_hash,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
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.name ILIKE $1
|
||||
ORDER BY CASE
|
||||
WHEN fi.name ILIKE $2 THEN 0
|
||||
WHEN fi.name ILIKE $2 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
fi.name
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
"#
|
||||
))
|
||||
.bind(caller_id)
|
||||
.bind(&pattern)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
|
||||
@@ -1178,31 +1178,39 @@ impl FolderRepository for FolderDbRepository {
|
||||
parent_id: Option<&str>,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
caller_id: uuid::Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
// Same drive-scope filter as `suggest_files_by_name` — closed as
|
||||
// AuthZ audit finding #1 (2026-07-12). `CALLER_CAN_READ_DRIVE`
|
||||
// aliases `storage.folders` as `fo`; the pre-fix query aliased it
|
||||
// as an unqualified `storage.folders`, so this rewrite adds the
|
||||
// `fo` alias in every branch.
|
||||
let pattern = super::like_escape(query);
|
||||
let limit_i64 = limit as i64;
|
||||
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
sqlx::query_as(&format!(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid
|
||||
AND NOT is_trashed
|
||||
AND name ILIKE $2
|
||||
SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id,
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
|
||||
fo.created_by, fo.updated_by
|
||||
FROM storage.folders fo
|
||||
WHERE {CALLER_CAN_READ_DRIVE}
|
||||
AND fo.parent_id = $2::uuid
|
||||
AND NOT fo.is_trashed
|
||||
AND fo.name ILIKE $3
|
||||
ORDER BY CASE
|
||||
WHEN name ILIKE $3 THEN 0
|
||||
WHEN name ILIKE $3 || '%' THEN 1
|
||||
WHEN fo.name ILIKE $4 THEN 0
|
||||
WHEN fo.name ILIKE $4 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
name
|
||||
LIMIT $4
|
||||
"#,
|
||||
)
|
||||
fo.name
|
||||
LIMIT $5
|
||||
"#
|
||||
))
|
||||
.bind(caller_id)
|
||||
.bind(pid)
|
||||
.bind(&pattern)
|
||||
.bind(query)
|
||||
@@ -1210,26 +1218,28 @@ impl FolderRepository for FolderDbRepository {
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
sqlx::query_as(&format!(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL
|
||||
AND NOT is_trashed
|
||||
AND name ILIKE $1
|
||||
SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id,
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
|
||||
fo.created_by, fo.updated_by
|
||||
FROM storage.folders fo
|
||||
WHERE {CALLER_CAN_READ_DRIVE}
|
||||
AND fo.parent_id IS NULL
|
||||
AND NOT fo.is_trashed
|
||||
AND fo.name ILIKE $2
|
||||
ORDER BY CASE
|
||||
WHEN name ILIKE $2 THEN 0
|
||||
WHEN name ILIKE $2 || '%' THEN 1
|
||||
WHEN fo.name ILIKE $3 THEN 0
|
||||
WHEN fo.name ILIKE $3 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
name
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
fo.name
|
||||
LIMIT $4
|
||||
"#
|
||||
))
|
||||
.bind(caller_id)
|
||||
.bind(&pattern)
|
||||
.bind(query)
|
||||
.bind(limit_i64)
|
||||
|
||||
Reference in New Issue
Block a user