fix(webdav): harden offset/key truthiness and escape SEARCH mime type

Optional review hardening on #849 (non-blocking nits from the approve):

- `_build_search_xml`: emit `<d:firstresult>` on `offset is not None` rather
  than truthiness, so a future explicit offset=0 isn't silently dropped.
- `_key`: key on `file_id is not None` so a (hypothetical) file_id of 0 isn't
  treated as absent and mis-keyed onto path.
- `_type_search_args`: XML-escape the MIME type before interpolating it into
  the SEARCH literal (defense-in-depth for any future user-supplied value),
  with a unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-04 14:19:30 +02:00
co-authored by Claude Opus 4.8
parent eaa898e6eb
commit 9452057570
2 changed files with 25 additions and 3 deletions
+14 -3
View File
@@ -6,6 +6,7 @@ import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import unquote
from xml.sax.saxutils import escape as xml_escape
from httpx import HTTPStatusError
@@ -759,7 +760,12 @@ class WebDAVClient(BaseNextcloudClient):
# producer omits fileid. ``id(item)`` is a last resort so an item
# missing both never collapses into another under a shared ``None``
# key (which would silently drop rows from the result set).
return item.get("file_id") or item.get("path") or id(item)
# ``is not None`` rather than truthiness so a (hypothetical)
# file_id of 0 isn't treated as absent.
file_id = item.get("file_id")
if file_id is not None:
return file_id
return item.get("path") or id(item)
results: List[Dict[str, Any]] = []
seen: set[Any] = set()
@@ -894,7 +900,9 @@ class WebDAVClient(BaseNextcloudClient):
limit_parts = []
if limit:
limit_parts.append(f"<d:nresults>{limit}</d:nresults>")
if offset:
# ``is not None`` (not truthiness) so a future explicit offset=0 is
# emitted rather than silently dropped.
if offset is not None:
limit_parts.append(f"<d:firstresult>{offset}</d:firstresult>")
limit_xml = f"<d:limit>{''.join(limit_parts)}</d:limit>" if limit_parts else ""
@@ -1160,12 +1168,15 @@ class WebDAVClient(BaseNextcloudClient):
@staticmethod
def _type_search_args(mime_type: str) -> Tuple[str, List[str]]:
"""Build the where-clause + property list for a MIME-type SEARCH."""
# Escape so a caller-supplied MIME type can't break the SEARCH XML or
# inject elements. All current callers pass literal strings, but this
# keeps the boundary safe for any future user-supplied value.
where_conditions = f"""
<d:like>
<d:prop>
<d:getcontenttype/>
</d:prop>
<d:literal>{mime_type}</d:literal>
<d:literal>{xml_escape(mime_type)}</d:literal>
</d:like>
"""
+11
View File
@@ -164,6 +164,17 @@ async def test_find_all_by_type_delegates_to_search_files_all(mocker):
assert "application/pdf" in kwargs["where_conditions"]
def test_type_search_args_escapes_mime_type(mocker):
"""A MIME value with XML metacharacters must not break / inject into the SEARCH."""
client = _make_client(mocker)
where, properties = client._type_search_args("application/pdf<&>")
# The injected metacharacters are escaped inside the <d:literal>, so they
# can't break the SEARCH XML or introduce new elements.
assert "pdf&lt;&amp;&gt;" in where
assert "pdf<&>" not in where
assert "fileid" in properties
def test_build_search_xml_emits_offset_only_when_set(mocker):
client = _make_client(mocker)