From 0ad0ea1a437b759b2fe67f805893f6d53269751d Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Wed, 1 Jul 2026 22:54:48 +0200 Subject: [PATCH] fix(webdav): reject PROPPATCH on protected DAV:/oc:/nc:/ocs: props DeadPropertyStore let PROPPATCH set any namespace/name verbatim, incl. names the server itself emits as live state (DAV: entirely, plus oc:/nc:/ocs: names used by write_file_response / write_folder_response). That either forges a live prop or stores dead rows nothing ever reads. is_protected_property() denylists them; both PROPPATCH handlers (native + NC) now return per-property 403 instead of storing. oc:favorite stays writable via its existing special-case, which runs before the protection check. --- src/application/adapters/webdav_adapter.rs | 40 +++++++++++++++++++ src/interfaces/api/handlers/webdav_handler.rs | 8 +++- src/interfaces/nextcloud/webdav_handler.rs | 8 +++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 79071376..a29c682b 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -72,6 +72,46 @@ impl std::fmt::Display for QualifiedName { } } +/// Whether PROPPATCH must refuse to set/remove this property as a dead +/// property (RFC 4918 §9.2 — server MAY reject a PROPPATCH attempt on a +/// live property; DeadPropertyStore has no business holding a value that +/// PROPFIND / REPORT already emit from live server state). +pub fn is_protected_property(qn: &QualifiedName) -> bool { + match qn.namespace.as_str() { + // RFC 4918 §15 — the DAV: namespace is server-owned in its + // entirety. Any PROPPATCH into it either forges a live + // property (dual-emission) or accumulates unread garbage + // (silent litter). + "DAV:" => true, + + // Every name below appears verbatim in write_folder_response + // / write_file_response in the NC handler. Adding a new + // live emitter → add its name here. + "http://owncloud.org/ns" => matches!( + qn.name.as_str(), + "favorite" + | "fileid" + | "id" + | "owner-id" + | "owner-display-name" + | "permissions" + | "share-types" + | "size" + ), + + "http://nextcloud.org/ns" => matches!( + qn.name.as_str(), + "has-preview" | "is-encrypted" | "mount-type" | "creation_time" | "upload_time" + ), + + "http://open-collaboration-services.org/ns" => { + matches!(qn.name.as_str(), "share-permissions") + } + + _ => false, + } +} + /// PROPFIND request type #[derive(Debug, PartialEq)] pub enum PropFindType { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 62e82929..b32c9f58 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -18,7 +18,7 @@ use quick_xml::Writer; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ - LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, + LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; @@ -807,6 +807,12 @@ async fn handle_proppatch( let mut results: Vec<(&QualifiedName, bool)> = Vec::new(); for op in &ops { match op { + PropPatchOp::Set(pv) if is_protected_property(&pv.name) => { + results.push((&pv.name, false)); + } + PropPatchOp::Remove(name) if is_protected_property(name) => { + results.push((name, false)); + } PropPatchOp::Set(pv) => { dead_props .set(resource_ref, pv.name.clone(), pv.value.clone()) diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 964f26a0..26fc379c 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ - PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, + PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, }; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::favorites_ports::FavoritesUseCase; @@ -556,6 +556,12 @@ async fn handle_proppatch( } results.push((name, true)); } + PropPatchOp::Set(pv) if is_protected_property(&pv.name) => { + results.push((&pv.name, false)); + } + PropPatchOp::Remove(name) if is_protected_property(name) => { + results.push((name, false)); + } PropPatchOp::Set(pv) => { dead_props .set(resource_ref, pv.name.clone(), pv.value.clone())