security(nc-uploads+trash): close #12 chunked-upload create bypass; graduated denial on empty-trash-for-drive
- nc chunked-upload MOVE assembly (#12): both branches now funnel through update_file_streaming_with_perms, whose internal fork enforces Update on the existing file OR Create on the parent folder / drive root. Pre-fix, the create branch went through plain upload_file_streaming with no authz.require — a Viewer on a shared drive could MKCOL → PUT chunks → MOVE and land a brand-new file. Error mapping switched to AppError::from so denials keep the graduated 403/404 shape. - trash empty-for-drive: route through authz.require(Delete, Drive) instead of the bespoke drives_with_delete_for check + hardcoded not_found. Viewer now gets 403 (has Read), outsider stays 404 (no Read, anti-enum). Emits the standard authz.denied event with visibility field instead of the ad-hoc trash.empty_drive_rejected. - tests/api/trash_per_drive.hurl: flip Viewer/Editor asserts 404 → 403; new Step 11b regression pin for finding #10 (Editor restore + delete attempts must 403 AND body must not contain "success":true — trips if the historical substring-match-on-"not found" hack ever comes back).
This commit is contained in:
@@ -662,22 +662,25 @@ impl TrashUseCase for TrashService {
|
|||||||
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||||
// Per-drive trash empty — the Drive group-by on `/trash` exposes
|
// Per-drive trash empty — the Drive group-by on `/trash` exposes
|
||||||
// this as a per-row affordance so multi-drive owners can clear
|
// this as a per-row affordance so multi-drive owners can clear
|
||||||
// one drive without touching the others. Refuses with
|
// one drive without touching the others.
|
||||||
// `NotFound` (anti-enum) when the caller lacks Delete on the
|
//
|
||||||
// named drive — same shape as the user-facing drive listing
|
// Route through `authz.require(Delete, Drive)` so the denial
|
||||||
// would emit for an unknown id.
|
// shape stays consistent with every other write verb: 403 when
|
||||||
let allowed = self.drives_with_delete_for(user_id).await?;
|
// the caller has Read on the drive (viewer/editor holding no
|
||||||
if !allowed.contains(&drive_id) {
|
// Delete), 404 when they don't (anti-enum). Before 2026-07-16
|
||||||
tracing::info!(
|
// this method rolled its own `drives_with_delete_for` check +
|
||||||
target: "audit",
|
// hardcoded `NotFound` — that predated the graduated-denial
|
||||||
event = "trash.empty_drive_rejected",
|
// engine change and returned 404 unconditionally even for a
|
||||||
reason = "no_delete_on_drive",
|
// Viewer who could see the drive in `/api/drives`. The engine
|
||||||
user_id = %user_id,
|
// now emits `authz.denied` with `visibility="visible"|"hidden"`
|
||||||
drive_id = %drive_id,
|
// and the standard mapping renders it as 403 or 404.
|
||||||
"👮🏻♂️ refused per-drive empty — caller lacks Delete on this drive",
|
self.authz
|
||||||
);
|
.require(
|
||||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
Subject::User(user_id),
|
||||||
}
|
Permission::Delete,
|
||||||
|
Resource::Drive(drive_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
info!("Emptying trash for drive {} (user {})", drive_id, user_id);
|
info!("Emptying trash for drive {} (user {})", drive_id, user_id);
|
||||||
self.clear_trash_in(&[drive_id], user_id).await
|
self.clear_trash_in(&[drive_id], user_id).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
use crate::application::ports::file_ports::FileUploadUseCase;
|
||||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::common::mime_detect::filename_from_path;
|
use crate::common::mime_detect::filename_from_path;
|
||||||
@@ -402,8 +402,6 @@ async fn handle_assemble(
|
|||||||
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?;
|
||||||
|
|
||||||
let upload_service = &state.applications.file_upload_service;
|
let upload_service = &state.applications.file_upload_service;
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
|
||||||
let folder_service = &state.applications.folder_service;
|
|
||||||
|
|
||||||
// Path-based lookups below scope by `drive_id`. The NC session's
|
// Path-based lookups below scope by `drive_id`. The NC session's
|
||||||
// chroot is always populated for path-scoped handlers (see
|
// chroot is always populated for path-scoped handlers (see
|
||||||
@@ -433,64 +431,41 @@ async fn handle_assemble(
|
|||||||
.await?;
|
.await?;
|
||||||
let content_type = ingested.content_type.clone();
|
let content_type = ingested.content_type.clone();
|
||||||
|
|
||||||
// Check if file exists (update vs create).
|
// AuthZ audit #12 (2026-07-12): the previous shape branched on
|
||||||
let existing = file_service
|
// file existence — `update_file_streaming_with_perms` on the
|
||||||
.get_file_by_path(&internal_path, drive_id)
|
// overwrite path (correct), plain `upload_file_streaming` on
|
||||||
.await;
|
// the create path (NO `authz.require`). Viewer/Commenter on a
|
||||||
|
// shared drive could MKCOL → PUT chunks → MOVE and land a
|
||||||
let etag: Option<String> = if existing.is_ok() {
|
// brand-new file, skipping the `Create`-on-parent-folder gate.
|
||||||
let dto = upload_service
|
//
|
||||||
.update_file_streaming_with_perms(
|
// `update_file_streaming_with_perms` handles both branches
|
||||||
&internal_path,
|
// atomically: `Update` on the existing file OR `Create` on the
|
||||||
drive_id,
|
// parent folder / drive root (per the service's own internal
|
||||||
ingested.stored(),
|
// fork). Funneling everything through the one method also
|
||||||
&content_type,
|
// deletes the duplicated parent-folder lookup that used to
|
||||||
oc_mtime,
|
// live here.
|
||||||
user.id,
|
//
|
||||||
)
|
// AuthZ audit #2 (2026-07-12): route DomainError through
|
||||||
.await
|
// `AppError::from` so authz denials keep the graduated 403/404
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
|
// shape instead of collapsing into 500.
|
||||||
|
let dto = match upload_service
|
||||||
Some(dto.etag)
|
.update_file_streaming_with_perms(
|
||||||
} else {
|
&internal_path,
|
||||||
// New-file branch: resolve the parent folder by path and register
|
drive_id,
|
||||||
// the file row against the already-ingested blob.
|
ingested.stored(),
|
||||||
let (parent_sub, filename) = match dest_subpath.rsplit_once('/') {
|
&content_type,
|
||||||
Some((p, n)) => (p, n),
|
oc_mtime,
|
||||||
None => ("", dest_subpath.as_str()),
|
user.id,
|
||||||
};
|
)
|
||||||
let parent_internal =
|
.await
|
||||||
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, parent_sub)?;
|
{
|
||||||
let parent_internal = parent_internal.trim_end_matches('/');
|
Ok(dto) => dto,
|
||||||
|
Err(e) => {
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
discard_ingested(&state.core.dedup_service, &ingested).await;
|
||||||
let parent_folder = match folder_service
|
return Err(AppError::from(e));
|
||||||
.get_folder_by_path(parent_internal, drive_id)
|
}
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(folder) => folder,
|
|
||||||
Err(e) => {
|
|
||||||
discard_ingested(&state.core.dedup_service, &ingested).await;
|
|
||||||
return Err(AppError::internal_error(format!(
|
|
||||||
"Parent folder lookup failed: {}",
|
|
||||||
e
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let dto = upload_service
|
|
||||||
.upload_file_streaming(
|
|
||||||
filename.to_string(),
|
|
||||||
Some(parent_folder.id),
|
|
||||||
content_type.to_string(),
|
|
||||||
ingested.stored(),
|
|
||||||
user.id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
|
|
||||||
|
|
||||||
Some(dto.etag)
|
|
||||||
};
|
};
|
||||||
|
let etag: Option<String> = Some(dto.etag);
|
||||||
|
|
||||||
// Cleanup session.
|
// Cleanup session.
|
||||||
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
|
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
|
||||||
|
|||||||
@@ -190,8 +190,12 @@ HTTP 404
|
|||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 9 — Provision a Viewer of the shared drive (`tpd_viewer`),
|
# Step 9 — Provision a Viewer of the shared drive (`tpd_viewer`),
|
||||||
# then assert the per-drive empty refuses for Viewer / Editor
|
# then assert the per-drive empty refuses for Viewer /
|
||||||
# / non-member callers. Each refusal is 404 (anti-enum).
|
# Editor / non-member callers. Graduated denial (see
|
||||||
|
# [[project_authz_require_graduated_denial]]): the Viewer
|
||||||
|
# and Editor tests get 403 because they hold Read on the
|
||||||
|
# drive; the non-member fallback keeps the 404 anti-enum
|
||||||
|
# shape (no Read = no existence oracle).
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
POST {{base_url}}/api/admin/users
|
POST {{base_url}}/api/admin/users
|
||||||
Authorization: Bearer {{admin_token}}
|
Authorization: Bearer {{admin_token}}
|
||||||
@@ -249,17 +253,19 @@ Authorization: Bearer {{owner_token}}
|
|||||||
HTTP 204
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
# Test 4 — Viewer cannot empty the drive's trash.
|
# Test 4 — Viewer cannot empty the drive's trash. Viewer has Read
|
||||||
|
# on the drive → graduated denial returns 403.
|
||||||
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
Authorization: Bearer {{viewer_token}}
|
Authorization: Bearer {{viewer_token}}
|
||||||
|
|
||||||
HTTP 404
|
HTTP 403
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 10 — Test 5: Editor cannot either.
|
# Step 10 — Test 5: Editor cannot either.
|
||||||
# Promote tpd_viewer to Editor; same refusal. Confirms
|
# Promote tpd_viewer to Editor; same refusal. Confirms
|
||||||
# `Delete` isn't in the Editor bundle.
|
# `Delete` isn't in the Editor bundle. Editor has Read →
|
||||||
|
# graduated denial returns 403.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{viewer_user_id}}
|
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{viewer_user_id}}
|
||||||
Authorization: Bearer {{owner_token}}
|
Authorization: Bearer {{owner_token}}
|
||||||
@@ -272,7 +278,7 @@ HTTP 200
|
|||||||
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
Authorization: Bearer {{viewer_token}}
|
Authorization: Bearer {{viewer_token}}
|
||||||
|
|
||||||
HTTP 404
|
HTTP 403
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -315,6 +321,74 @@ HTTP 200
|
|||||||
jsonpath "$.items[*].drive_id" contains "{{shared_drive_id}}"
|
jsonpath "$.items[*].drive_id" contains "{{shared_drive_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 11b — Regression pin for AuthZ audit #10 (2026-07-12).
|
||||||
|
# `POST /api/trash/{id}/restore` and `DELETE /api/trash/{id}`
|
||||||
|
# once did `err_str.contains("not found")` to decide "already
|
||||||
|
# gone" vs real failure — an authz denial (which returns a
|
||||||
|
# `NotFound`-shaped DomainError to preserve anti-enum on the
|
||||||
|
# listing side) matched the substring and got synthesised
|
||||||
|
# into a 200 `{"success": true}` response. Response lied;
|
||||||
|
# no mutation happened.
|
||||||
|
#
|
||||||
|
# Post-fix: both handlers route through
|
||||||
|
# `AppError::from(e).into_response()`, so authz denials
|
||||||
|
# surface as the graduated 403 / 404 shape and body is
|
||||||
|
# never a success envelope.
|
||||||
|
#
|
||||||
|
# The Editor (from Step 10 promotion) holds Read on the
|
||||||
|
# canary — graduated denial returns 403 with a
|
||||||
|
# `AccessDenied`-shape body, NOT a success envelope. If a
|
||||||
|
# future refactor reintroduces the substring hack this
|
||||||
|
# assertion trips before it lands in prod.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/trash/resources
|
||||||
|
Authorization: Bearer {{viewer_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
# The shared drive's trash holds exactly one item at this point (the
|
||||||
|
# canary owner trashed after Step 9), so `$.items[0]` is unambiguous
|
||||||
|
# — no filter needed. `TrashResourceItemDto` wraps the underlying
|
||||||
|
# resource in `.resource` (untagged File | Folder | Drive enum) and
|
||||||
|
# the trash key equals the original resource id (see
|
||||||
|
# `storage.trash_items` view), so `.resource.id` is exactly what
|
||||||
|
# `POST /api/trash/{id}/restore` and `DELETE /api/trash/{id}` accept.
|
||||||
|
# The `[?(...)]` + `nth 0` shape (see the sibling
|
||||||
|
# feedback_hurl_jsonpath_filter_empty memory) collapses on a single
|
||||||
|
# match and returns a scalar hurl can't index, so we avoid it here.
|
||||||
|
canary_trash_id: jsonpath "$.items[0].resource.id"
|
||||||
|
|
||||||
|
|
||||||
|
POST {{base_url}}/api/trash/{{canary_trash_id}}/restore
|
||||||
|
Authorization: Bearer {{viewer_token}}
|
||||||
|
|
||||||
|
HTTP 403
|
||||||
|
[Asserts]
|
||||||
|
body not contains "\"success\":true"
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/trash/{{canary_trash_id}}
|
||||||
|
Authorization: Bearer {{viewer_token}}
|
||||||
|
|
||||||
|
HTTP 403
|
||||||
|
[Asserts]
|
||||||
|
body not contains "\"success\":true"
|
||||||
|
|
||||||
|
|
||||||
|
# The canary is still there — the two Editor attempts didn't mutate.
|
||||||
|
GET {{base_url}}/api/trash/resources
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
# Owner sees TWO trash items at this point — the shared drive's
|
||||||
|
# canary (from Step 9) plus their personal drive's leftover from
|
||||||
|
# Step 4 (owner emptied only the shared drive's trash at Step 6).
|
||||||
|
# `contains` avoids depending on the sort order between them.
|
||||||
|
jsonpath "$.items[*].resource.id" contains "{{canary_trash_id}}"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 12 — Cleanup: drop the canary, then the shared drive itself
|
# Step 12 — Cleanup: drop the canary, then the shared drive itself
|
||||||
# (D3b's delete-drive guard refuses non-empty drives, so
|
# (D3b's delete-drive guard refuses non-empty drives, so
|
||||||
|
|||||||
Reference in New Issue
Block a user