SEC2-058: DAV responses are read whole with no size limit and no total deadline #33

Closed
opened 2026-10-17 10:39:51 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-10-16 in a read-only security review of postulo-dav at 1d49d6e, checked by a second, independent pass.

Severity: Low · Category: Input Validation · Reference: CWE-400 (Uncontrolled Resource Consumption); CWE-770 (Allocation of Resources Without Limits or Throttling); OWASP A04:2021 (Insecure Design)

Location:

  • src/postulo_dav/dav.py:107-108 (Server.request: self.client.request(...), which has read the whole body before it returns)
  • src/postulo_dav/dav.py:110-122 (Server.propfind: ElementTree.fromstring(response.content))
  • src/postulo_dav/dav.py:223-227 (Server.get: Resource(response.text, ...))
  • src/postulo_dav/dav.py:284-293 (connect: postulo_client() with no total)
  • core src/postulo/plugins/http.py:324-337 (client(timeout, total)), :479-527 (get_bytes, read_body)

Issue

Every request goes through the guarded client, so the destination policy holds, and defusedxml
refuses entity expansion and external entities. What is missing is the bound the core added for
every other body it reads from another server (postulo/postulo#321, #781, #782):
client.request() buffers the whole response before response.content or response.text is
touched, so a PROPFIND answer or a card or event of any size is held in memory in full, then
parsed in full (tree.iter(...), text.properties(...)); and connect() opens the client
without total, so each wait is held to 10 s but their sum is not. A server answering a byte
every few seconds holds the scheduler's pass for as long as it likes. The core's client states
the rule in its docstring ("A body is read with read_body, or not at all"); defusedxml bounds
entities, not document size.

Impact

The person's own DAV server, or whoever can write to the collection the connection reads, can
make a scheduled pass allocate without bound or run without end; syncs run inline in the
scheduler pass and under Sync now in a web worker, so every later connection and reminder
waits. Denial of service against a personal instance is out of scope in the threat model, hence
Low; but the core holds plugins to a bounded read as a rule, and this official plugin does not
follow it.

Fix

Prerequisite in the core: read_body, deadline_in, until and BodyRefused live in
postulo.plugins.http and are not published on postulo.plugins.api, so this plugin cannot
import them without failing tests/test_surface.py. The core must first put them on the plugin
surface (a small core issue; the paperless and France Travail plugins want the same). Then:

  • connect() opens postulo_client(total=60.0) (this part needs nothing new and can land now);

  • propfind and get stream and stop at a limit:

    with until(deadline):
        with self.client.stream(method, url, ...) as response:
            body = read_body(response, limit=limit, deadline=deadline)
    

    with 2 MB for a multistatus and 256 KB for one card or event, both generous;

  • BodyRefused becomes a DavError naming the resource, so the run reports it and moves on.

Done when a test serves a card over the limit and a multistatus over the limit and asserts that
the run reports each and continues with the next resource, and the fake server's MockTransport
in tests/test_dav.py still works through stream().

#5 and #6 (open) mention streaming for the future file store and backup destination, not the
sync's PROPFIND and GET bodies; #13 (open) confines origins and methods, not sizes.

Found on 2026-10-16 in a read-only security review of `postulo-dav` at `1d49d6e`, checked by a second, independent pass. **Severity:** Low · **Category:** Input Validation · **Reference:** CWE-400 (Uncontrolled Resource Consumption); CWE-770 (Allocation of Resources Without Limits or Throttling); OWASP A04:2021 (Insecure Design) **Location:** - `src/postulo_dav/dav.py:107-108` (`Server.request`: `self.client.request(...)`, which has read the whole body before it returns) - `src/postulo_dav/dav.py:110-122` (`Server.propfind`: `ElementTree.fromstring(response.content)`) - `src/postulo_dav/dav.py:223-227` (`Server.get`: `Resource(response.text, ...)`) - `src/postulo_dav/dav.py:284-293` (`connect`: `postulo_client()` with no `total`) - core `src/postulo/plugins/http.py:324-337` (`client(timeout, total)`), `:479-527` (`get_bytes`, `read_body`) ## Issue Every request goes through the guarded client, so the destination policy holds, and `defusedxml` refuses entity expansion and external entities. What is missing is the bound the core added for every other body it reads from another server (postulo/postulo#321, #781, #782): `client.request()` buffers the whole response before `response.content` or `response.text` is touched, so a PROPFIND answer or a card or event of any size is held in memory in full, then parsed in full (`tree.iter(...)`, `text.properties(...)`); and `connect()` opens the client without `total`, so each wait is held to 10 s but their sum is not. A server answering a byte every few seconds holds the scheduler's pass for as long as it likes. The core's client states the rule in its docstring ("A body is read with `read_body`, or not at all"); `defusedxml` bounds entities, not document size. ## Impact The person's own DAV server, or whoever can write to the collection the connection reads, can make a scheduled pass allocate without bound or run without end; syncs run inline in the scheduler pass and under *Sync now* in a web worker, so every later connection and reminder waits. Denial of service against a personal instance is out of scope in the threat model, hence Low; but the core holds plugins to a bounded read as a rule, and this official plugin does not follow it. ## Fix Prerequisite in the core: `read_body`, `deadline_in`, `until` and `BodyRefused` live in `postulo.plugins.http` and are not published on `postulo.plugins.api`, so this plugin cannot import them without failing `tests/test_surface.py`. The core must first put them on the plugin surface (a small core issue; the paperless and France Travail plugins want the same). Then: - `connect()` opens `postulo_client(total=60.0)` (this part needs nothing new and can land now); - `propfind` and `get` stream and stop at a limit: ```python with until(deadline): with self.client.stream(method, url, ...) as response: body = read_body(response, limit=limit, deadline=deadline) ``` with 2 MB for a multistatus and 256 KB for one card or event, both generous; - `BodyRefused` becomes a `DavError` naming the resource, so the run reports it and moves on. Done when a test serves a card over the limit and a multistatus over the limit and asserts that the run reports each and continues with the next resource, and the fake server's `MockTransport` in `tests/test_dav.py` still works through `stream()`. ## Related #5 and #6 (open) mention streaming for the future file store and backup destination, not the sync's PROPFIND and GET bodies; #13 (open) confines origins and methods, not sizes.
Author
Owner

Fixed by pull request #35, merged as fb8bbd7ae.

Fixed by pull request #35, merged as fb8bbd7ae.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-dav#33
No description provided.