fix(audit-2026-06-11/F-FS-002): belt-and-suspenders collection validation on files read/delete

The admin files endpoints hit FsFilesRepo directly (not via
FilesServiceImpl, which validates), so only create/update guarded the
collection — head/get/list/delete built FS paths from an unvalidated
value. Today nothing can store a traversal-shaped collection, but one
future bad migration / restore tool inserting collection='../../etc'
would give the read/delete paths arbitrary host-file reach.

- FsFilesRepo::{head,get,list,delete} now call guard_collection (create
  and update already did).
- The three admin endpoints (list/get/delete) call
  validate_files_collection up front for a clean 422 instead of the
  repo guard surfacing as an opaque 500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-12 18:26:46 +02:00
parent e676eb9ba7
commit d9fb0e7f85
2 changed files with 14 additions and 6 deletions

View File

@@ -22,7 +22,7 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use axum::routing::get; use axum::routing::get;
use axum::{Extension, Router}; use axum::{Extension, Router};
use picloud_shared::{AppId, Principal}; use picloud_shared::{validate_files_collection, AppId, Principal};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@@ -88,11 +88,13 @@ async fn list_files(
Capability::AppFilesRead(app_id), Capability::AppFilesRead(app_id),
) )
.await?; .await?;
if q.collection.trim().is_empty() { // Audit 2026-06-11 (F-FS-002) — the admin endpoints hit the repo
return Err(FilesApiError::Invalid( // directly (not via FilesServiceImpl, which validates), so a
"collection must not be empty".into(), // traversal-shaped collection would otherwise only be caught by the
)); // repo's `guard_collection` as an opaque 500. Validate here for a
} // clean 422 and as the outer layer of the belt-and-suspenders guard.
validate_files_collection(&q.collection)
.map_err(|e| FilesApiError::Invalid(e.to_string()))?;
let page = s let page = s
.files .files
.list( .list(
@@ -149,6 +151,7 @@ async fn get_file(
Capability::AppFilesRead(app_id), Capability::AppFilesRead(app_id),
) )
.await?; .await?;
validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?; let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
let meta = s let meta = s
.files .files
@@ -193,6 +196,7 @@ async fn delete_file(
Capability::AppFilesWrite(app_id), Capability::AppFilesWrite(app_id),
) )
.await?; .await?;
validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?; let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
if s.files.delete(app_id, &collection, id).await?.is_none() { if s.files.delete(app_id, &collection, id).await?.is_none() {
return Err(FilesApiError::NotFound); return Err(FilesApiError::NotFound);

View File

@@ -350,6 +350,7 @@ impl FilesRepo for FsFilesRepo {
collection: &str, collection: &str,
id: Uuid, id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> { ) -> Result<Option<FileMeta>, FilesRepoError> {
Self::guard_collection(collection)?;
let row: Option<FileRow> = sqlx::query_as( let row: Option<FileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \ "SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \ checksum_sha256, created_at, updated_at \
@@ -369,6 +370,7 @@ impl FilesRepo for FsFilesRepo {
collection: &str, collection: &str,
id: Uuid, id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError> { ) -> Result<Option<Vec<u8>>, FilesRepoError> {
Self::guard_collection(collection)?;
let row: Option<(String,)> = sqlx::query_as( let row: Option<(String,)> = sqlx::query_as(
"SELECT checksum_sha256 FROM files \ "SELECT checksum_sha256 FROM files \
WHERE app_id = $1 AND collection = $2 AND id = $3", WHERE app_id = $1 AND collection = $2 AND id = $3",
@@ -434,6 +436,7 @@ impl FilesRepo for FsFilesRepo {
collection: &str, collection: &str,
id: Uuid, id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> { ) -> Result<Option<FileMeta>, FilesRepoError> {
Self::guard_collection(collection)?;
// SELECT + DELETE in one tx; unlink afterwards (outside the tx). // SELECT + DELETE in one tx; unlink afterwards (outside the tx).
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
let row: Option<FileRow> = sqlx::query_as( let row: Option<FileRow> = sqlx::query_as(
@@ -479,6 +482,7 @@ impl FilesRepo for FsFilesRepo {
cursor: Option<&str>, cursor: Option<&str>,
limit: u32, limit: u32,
) -> Result<FilesListPage, FilesRepoError> { ) -> Result<FilesListPage, FilesRepoError> {
Self::guard_collection(collection)?;
let limit = if limit == 0 { let limit = if limit == 0 {
FILES_LIST_DEFAULT_LIMIT FILES_LIST_DEFAULT_LIMIT
} else { } else {