Fix WebDAV: PROPFIND responses do not declare the namespace of every requested property on the DAV:multistatus element

This commit is contained in:
Maurus Decimus committed 2026-09-10 19:00:20 +01:00
1 parent 515d6a90a5
commit 4f7a73a423
6 files changed
+64 -18

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
## Changed
## Fixed
- WebDAV: `PROPFIND` responses do not declare the namespace of every requested property on the `DAV:multistatus` element.
- MTA: SMTP session state obtained before `STARTTLS` is discarded once the TLS handshake completes, as required by RFC 3207.
## [0.16.21] - 2026-09-06
+3 -1
View File
@@ -228,7 +228,9 @@ impl DavProperty {
match self {
DavProperty::WebDav(WebDavProperty::GetCTag) => Namespace::CalendarServer,
DavProperty::CardDav(_)
| DavProperty::Principal(PrincipalProperty::AddressbookHomeSet) => Namespace::CardDav,
| DavProperty::Principal(
PrincipalProperty::AddressbookHomeSet | PrincipalProperty::PrincipalAddress,
) => Namespace::CardDav,
DavProperty::CalDav(_)
| DavProperty::Principal(
PrincipalProperty::CalendarHomeSet
+10 -6
View File
@@ -377,6 +377,10 @@ impl PropFindRequestHandler for Server {
_ => unreachable!(),
};
for property in container_props.iter().chain(children_props) {
response.set_namespace(property.namespace());
}
for item in paths {
let props = if item.is_container {
container_props
@@ -412,6 +416,10 @@ impl PropFindRequestHandler for Server {
PropFind::Prop(items) => items.clone(),
};
for property in &properties {
response.set_namespace(property.namespace());
}
let is_scheduling = collection_container == Collection::CalendarEventNotification;
let account_info = self
.account(access_token.account_id())
@@ -559,7 +567,6 @@ impl PropFindRequestHandler for Server {
} else {
fields_not_found.push(DavPropertyValue::empty(property.clone()));
}
response.set_namespace(Namespace::CalendarServer);
}
WebDavProperty::GetLastModified => {
fields.push(DavPropertyValue::new(
@@ -1062,7 +1069,6 @@ impl PropFindRequestHandler for Server {
_ => {
if !skip_not_found {
response.set_namespace(property.namespace());
fields_not_found.push(DavPropertyValue::empty(property.clone()));
}
}
@@ -1070,7 +1076,6 @@ impl PropFindRequestHandler for Server {
property => {
if !skip_not_found {
response.set_namespace(property.namespace());
fields_not_found.push(DavPropertyValue::empty(property.clone()));
}
}
@@ -1737,6 +1742,7 @@ async fn add_base_collection_response(
.caused_by(trc::location!())?;
for prop in properties {
response.set_namespace(prop.namespace());
match &prop {
DavProperty::WebDav(WebDavProperty::ResourceType) => {
fields.push(DavPropertyValue::new(
@@ -1762,7 +1768,6 @@ async fn add_base_collection_response(
.caused_by(trc::location!())?;
fields.push(DavPropertyValue::new(prop.clone(), hrefs));
response.set_namespace(Namespace::CalDav);
}
DavProperty::Principal(PrincipalProperty::AddressbookHomeSet) => {
let hrefs = build_home_set(
@@ -1776,7 +1781,6 @@ async fn add_base_collection_response(
.caused_by(trc::location!())?;
fields.push(DavPropertyValue::new(prop.clone(), hrefs));
response.set_namespace(Namespace::CardDav);
}
DavProperty::WebDav(WebDavProperty::SupportedReportSet) => {
let reports = match collection {
@@ -1787,9 +1791,9 @@ async fn add_base_collection_response(
};
fields.push(DavPropertyValue::new(prop.clone(), reports));
response.set_namespace(collection.namespace());
}
_ => {
response.set_namespace(prop.namespace());
fields_not_found.push(DavPropertyValue::empty(prop.clone()));
}
}
+6 -9
View File
@@ -69,6 +69,9 @@ impl PrincipalPropFind for Server {
let properties = match request {
PropFind::PropName => {
let props = all_props(collection, None);
for property in &props {
response.set_namespace(property.namespace());
}
for account_id in account_ids {
response.add_response(Response::new_propstat(
self.owner_href(&access_account_info, account_id)
@@ -84,6 +87,9 @@ impl PrincipalPropFind for Server {
PropFind::AllProp(items) => Cow::Owned(all_props(collection, items.as_slice().into())),
PropFind::Prop(items) => Cow::Borrowed(items),
};
for property in properties.as_slice() {
response.set_namespace(property.namespace());
}
let is_principal = match collection {
Collection::AddressBook | Collection::ContactCard => {
response.set_namespace(Namespace::CardDav);
@@ -235,7 +241,6 @@ impl PrincipalPropFind for Server {
));
}
_ => {
response.set_namespace(property.namespace());
fields_not_found.push(DavPropertyValue::empty(property.clone()));
}
},
@@ -267,7 +272,6 @@ impl PrincipalPropFind for Server {
.caused_by(trc::location!())?;
fields.push(DavPropertyValue::new(property.clone(), hrefs));
response.set_namespace(Namespace::CalDav);
}
PrincipalProperty::AddressbookHomeSet => {
let hrefs = build_home_set(
@@ -281,19 +285,16 @@ impl PrincipalPropFind for Server {
.caused_by(trc::location!())?;
fields.push(DavPropertyValue::new(property.clone(), hrefs));
response.set_namespace(Namespace::CardDav);
}
PrincipalProperty::PrincipalAddress => {
fields_not_found.push(DavPropertyValue::empty(property.clone()));
response.set_namespace(Namespace::CardDav);
}
PrincipalProperty::CalendarUserAddressSet => {
fields.push(DavPropertyValue::new(
property.clone(),
vec![Href(format!("mailto:{}", account.name()))],
));
response.set_namespace(Namespace::CalDav);
}
PrincipalProperty::CalendarUserType => {
fields.push(DavPropertyValue::new(
@@ -304,7 +305,6 @@ impl PrincipalPropFind for Server {
DavValue::String("GROUP".to_string())
},
));
response.set_namespace(Namespace::CalDav);
}
PrincipalProperty::ScheduleInboxURL => {
fields.push(DavPropertyValue::new(
@@ -315,7 +315,6 @@ impl PrincipalPropFind for Server {
percent_encoding::utf8_percent_encode(account.name(), RFC_3986),
))],
));
response.set_namespace(Namespace::CalDav);
}
PrincipalProperty::ScheduleOutboxURL => {
fields.push(DavPropertyValue::new(
@@ -326,11 +325,9 @@ impl PrincipalPropFind for Server {
percent_encoding::utf8_percent_encode(account.name(), RFC_3986),
))],
));
response.set_namespace(Namespace::CalDav);
}
},
_ => {
response.set_namespace(property.namespace());
fields_not_found.push(DavPropertyValue::empty(property.clone()));
}
}
+33 -2
View File
@@ -13,7 +13,7 @@ use dav_proto::{
};
use groupware::DavResourceName;
use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION};
use quick_xml::{Reader, XmlVersion, events::Event};
use quick_xml::{NsReader, Reader, XmlVersion, events::Event, name::ResolveResult};
use std::{borrow::Cow, time::Duration};
use store::rand::{RngExt, distr::Alphanumeric, rng};
@@ -135,7 +135,15 @@ impl DummyWebDavClient {
.map(|bytes| String::from_utf8(bytes.to_vec()).unwrap())
.map_err(|err| err.to_string());
let xml = match &body {
Ok(body) if body.starts_with("<?xml") => flatten_xml(body),
Ok(body) if body.starts_with("<?xml") => {
if let Some(prefix) = undeclared_xml_prefix(body) {
panic!(
"Response uses undeclared namespace prefix {prefix:?}: {}",
xml_pretty_print(body)
);
}
flatten_xml(body)
}
_ => vec![],
};
@@ -1119,6 +1127,29 @@ impl Default for DavItem {
}
}
fn undeclared_xml_prefix(xml: &str) -> Option<String> {
let mut reader = NsReader::from_str(xml);
let mut buf = Vec::new();
loop {
let (resolved, event) = reader.read_resolved_event_into(&mut buf).unwrap();
let name = match &event {
Event::Start(e) | Event::Empty(e) => e.name(),
Event::Eof => return None,
_ => {
buf.clear();
continue;
}
};
if matches!(resolved, ResolveResult::Unknown(_)) {
return Some(String::from_utf8_lossy(name.as_ref()).into_owned());
}
buf.clear();
}
}
fn flatten_xml(xml: &str) -> Vec<(String, String)> {
let mut reader = Reader::from_str(xml);
+11
View File
@@ -65,6 +65,17 @@ pub async fn test(test: &TestServer, assisted_discovery: bool) {
.with_status(StatusCode::MULTI_STATUS)
.with_hrefs([format!("{user_base_path}/").as_str()]);
client
.propfind_with_headers(
&user_base_path,
[DavProperty::WebDav(WebDavProperty::GetCTag)],
[("depth", "0")],
)
.await
.properties(&format!("{user_base_path}/"))
.get(DavProperty::WebDav(WebDavProperty::GetCTag))
.is_not_empty();
// Test 3: PROPFIND Depth 1 on root
client
.request_with_headers("PROPFIND", resource_type.base_path(), [("depth", "1")], "")