security(nextcloud): chroot-aware display paths + recent race fix

strip_chroot_prefix replaces the hardcoded "Personal/" strip
    in NC trashbin PROPFIND, OCS unified search, and REPORT
    (favorites + search). Handles composed chroots, drops
    cross-chroot items instead of surfacing malformed paths, and
    fixes the leading-slash mismatch (FolderDto path has '/', DB
    paths don't) that silently dropped every NC trashbin item
    post-D3. OCS keeps a first-segment fallback (results
    legitimately span drives, no single chroot).

    uploads_handler switches to nc_to_internal_path(chroot, …)
    for the two remaining hardcoded "Personal/" sites, closing
    the D1 TODO markers.

    RecentService::record_item_access is split from a new
    record_item_access_internal (no authz) used by
    RecentRecordingHook. Round 1's authz.require widened the
    tokio::spawn race past tests/api/recent.hurl step 7; the
    internal path skips the redundant Read gate — upstream
    _with_perms already enforced it.

    Tests: 8 unit tests pin strip_chroot_prefix (leading slash,
    composed chroots, sibling-leak rejection, partial-prefix,
    empty-chroot). drives_membership.hurl step 21b/22b cover
    Editor upload → 201 / Viewer upload → 404 fresh + overwrite
    with fixture cleanup at 30c. test_nc_move_copy_delete_trash
    K1 pins the actual original-location value.
This commit is contained in:
Edouard Vanbelle
2026-07-05 22:52:26 +02:00
parent 0342bae300
commit 1786fe4111
8 changed files with 446 additions and 54 deletions
+57 -15
View File
@@ -81,6 +81,14 @@ async fn handle_propfind(
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
// Chroot-scope the trashbin view: `get_trash_items(user.id)`
// spans every drive the caller is a member of, but NC's
// trashbin surface is a single-drive concept from the client's
// POV. Items outside the chroot are dropped from the multistatus
// (see `write_trashbin_multistatus` → `strip_home_prefix` →
// `webdav_handler::strip_chroot_prefix`) and remain reachable
// via REST `/api/trash/resources`.
let chroot = session.require_chroot()?;
let trash_svc = state
.trash_service
.as_ref()
@@ -95,7 +103,7 @@ async fn handle_propfind(
let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new();
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
write_trashbin_multistatus(&mut buf, &items, &user.username, chroot, file_id_svc)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
@@ -259,18 +267,23 @@ fn mime_from_name(name: &str) -> String {
.to_string()
}
/// Strip the home-folder prefix from an original path to produce the
/// Nextcloud-relative original location.
/// Strip the caller's chroot prefix from an original path to produce
/// the Nextcloud-relative original-location value.
///
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
/// default-drive root folder name read from `drives.root_folder_id`.
/// Correct for D0-provisioned default drives; secondary drives keep
/// their original root name. The `_username` arg stays for now so the
/// upcoming dynamic lookup has a way to identify the caller.
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
original_path
.strip_prefix("Personal/")
.unwrap_or(original_path)
/// Delegates to `webdav_handler::strip_chroot_prefix` — chroot-aware,
/// multi-segment safe, and returns `None` when the item is outside
/// the chroot (e.g. a trashed item in another drive the caller is a
/// member of). The `_username` arg stays for signature stability
/// with call sites that thread it; the strip itself no longer uses it.
///
/// See the doc on `strip_chroot_prefix` for the AuthZ caveat — this
/// is a display helper, not an ownership check.
fn strip_home_prefix<'a>(
original_path: &'a str,
_username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
) -> Option<&'a str> {
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, original_path)
}
// ────────────── Trashbin PROPFIND XML Generation ──────────────
@@ -280,10 +293,16 @@ use crate::application::services::nextcloud_file_id_service::NextcloudFileIdServ
use std::collections::HashMap;
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
///
/// `chroot` scopes the response — items whose original path is outside
/// the chroot (other drives the caller is a member of) are dropped
/// silently. NC's trashbin surface is single-drive from the client's
/// perspective; cross-drive items remain reachable via REST.
async fn write_trashbin_multistatus<W: std::io::Write>(
writer: W,
items: &[TrashedItemDto],
username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
) -> Result<(), String> {
let mut xml = Writer::new(writer);
@@ -315,9 +334,24 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
id_map.extend(folder_id_map);
// Individual trashed items.
// Individual trashed items — skip those whose original path is
// outside the chroot (other-drive trash reachable via REST).
for item in items {
write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
if crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(
chroot,
&item.original_path,
)
.is_none()
{
tracing::debug!(
target: "oxicloud::nc",
"trashbin PROPFIND: dropping cross-chroot item '{}' at '{}'",
item.id,
item.original_path,
);
continue;
}
write_trash_item_response(&mut xml, item, username, chroot, file_id_svc, &id_map)?;
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
@@ -363,10 +397,18 @@ fn write_trash_root_response<W: std::io::Write>(
}
/// Write a single trashed item as a `<d:response>` element.
///
/// Caller is expected to have already verified the item is inside
/// `chroot` — see the guard in `write_trashbin_multistatus`. This
/// function trusts the invariant and expects `strip_home_prefix` to
/// return `Some(_)`; if it ever returns `None` (chroot drift between
/// the guard and the emit, defensive-only), the original-location
/// falls back to an empty string.
fn write_trash_item_response<W: std::io::Write>(
xml: &mut Writer<W>,
item: &TrashedItemDto,
username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
id_map: &HashMap<String, i64>,
) -> Result<(), String> {
@@ -427,7 +469,7 @@ fn write_trash_item_response<W: std::io::Write>(
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
// nc:trashbin-original-location
let original_location = strip_home_prefix(&item.original_path, username);
let original_location = strip_home_prefix(&item.original_path, username, chroot).unwrap_or("");
write_text_element(xml, "nc:trashbin-original-location", original_location)?;
// nc:trashbin-deletion-time