SEC2-061: Store reads every Paperless answer whole with no deadline, so a server the account holder names can hold the shared scheduler #16

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

Found on 2026-10-16 in a read-only security review of postulo-paperless at 37a044b, 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 A05:2021 (Security Misconfiguration)

Location:

  • src/postulo_paperless/store.py:116-126 (Archive._get, Archive._post)
  • src/postulo_paperless/store.py:149-159 (post_document)
  • src/postulo_paperless/store.py:167-196 (wait_for, up to WAIT_SECONDS of polls per document)
  • src/postulo_paperless/store.py:200-213 (set_custom_field)
  • src/postulo_paperless/store.py:302 and :314 (client() opened with no total=)

Issue

Every call uses httpx's eager methods and then parses the whole body:

# store.py:116-120
def _get(self, path: str, **params) -> dict | list:
    response = self.client.get(f"{self.base}/api/{path}", params=params, headers=self.headers)
    if response.status_code != 200:
        raise PaperlessError(_explain(response))
    return response.json()

client.get() has read the complete body before it returns, however large; nothing passes a
limit. The core's client says so ("A body is read with read_body, or not at all",
plugins/http.py:324-337). The per-wait timeout of 10 s bounds each read, not their sum; the
core's deadline machinery cuts waits only when a deadline is open, which happens through
total=, and the plugin opens none (store.py:302, :314).

put() runs in the scheduler's single pass: documents/archiving.py:263-273 sends copies one
after another, and send_due_reminders.py:176-206 runs copies after reminders and before
webhooks for every account in turn. The address is the account holder's to choose; the
destination policy decides where it may be, not what the server answers.

Impact

On a multi-user instance, one account's connection to a hostile or broken server stalls every
account's reminders, document copies, webhook deliveries and syncs for as long as that server
keeps answering a byte every nine seconds, on every pass in which that account has a copy due
(six attempts per document, MAX_ATTEMPTS). A very large body is held in memory by the
scheduler; where the container has a memory ceiling, the pass is killed. The Test button holds a
web worker until gunicorn's limit ends it. No data is exposed or crossed, the precondition is a
server the account holder controls or a compromised Paperless, and the threat model puts denial
of service against a personal instance at its edge: hardening, Low.

Fix

The time half needs nothing from the core: total is a plain keyword of the surface's client,
and since total wraps send, an eager .get() reads its body inside the deadline too.

with client(total=UPLOAD_TIMEOUT) as session:    # put()
with client(total=DEFAULT_TIMEOUT) as session:   # test()

total is per request, so one put() is bounded to roughly (4 ensures + 1 post + up to 20 polls

    1. × total; acceptable, or give the polls a smaller total.

The size half can be done in the plugin too: session.stream("GET", ...), sum iter_bytes() up
to a cap (1 MiB is generous for these JSON answers) and raise PaperlessError beyond it; the same
for the POST and PATCH answers. Publishing get_bytes/read_body on postulo.plugins.api
would shorten this and is a sensible core follow-up, but this fix does not wait on it.

Done when: a test whose MockTransport answers documents/ with a body above the cap raises a
PaperlessError without holding it, and one whose handler sleeps past total fails within the
deadline rather than after the sum of its reads.

#13 and #14 (open) are about retry counts for a refused token and an unsupported file type, not
about what is read. postulo/postulo-apprise#17 (open) is the same scheduler hold for Apprise
lines.

Found on 2026-10-16 in a read-only security review of `postulo-paperless` at `37a044b`, 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 A05:2021 (Security Misconfiguration) **Location:** - `src/postulo_paperless/store.py:116-126` (`Archive._get`, `Archive._post`) - `src/postulo_paperless/store.py:149-159` (`post_document`) - `src/postulo_paperless/store.py:167-196` (`wait_for`, up to `WAIT_SECONDS` of polls per document) - `src/postulo_paperless/store.py:200-213` (`set_custom_field`) - `src/postulo_paperless/store.py:302` and `:314` (`client()` opened with no `total=`) ## Issue Every call uses httpx's eager methods and then parses the whole body: ```python # store.py:116-120 def _get(self, path: str, **params) -> dict | list: response = self.client.get(f"{self.base}/api/{path}", params=params, headers=self.headers) if response.status_code != 200: raise PaperlessError(_explain(response)) return response.json() ``` `client.get()` has read the complete body before it returns, however large; nothing passes a limit. The core's client says so ("A body is read with `read_body`, or not at all", `plugins/http.py:324-337`). The per-wait timeout of 10 s bounds each read, not their sum; the core's deadline machinery cuts waits only when a deadline is open, which happens through `total=`, and the plugin opens none (`store.py:302`, `:314`). `put()` runs in the scheduler's single pass: `documents/archiving.py:263-273` sends copies one after another, and `send_due_reminders.py:176-206` runs copies after reminders and before webhooks for every account in turn. The address is the account holder's to choose; the destination policy decides *where* it may be, not what the server answers. ## Impact On a multi-user instance, one account's connection to a hostile or broken server stalls every account's reminders, document copies, webhook deliveries and syncs for as long as that server keeps answering a byte every nine seconds, on every pass in which that account has a copy due (six attempts per document, `MAX_ATTEMPTS`). A very large body is held in memory by the scheduler; where the container has a memory ceiling, the pass is killed. The Test button holds a web worker until gunicorn's limit ends it. No data is exposed or crossed, the precondition is a server the account holder controls or a compromised Paperless, and the threat model puts denial of service against a personal instance at its edge: hardening, Low. ## Fix The time half needs nothing from the core: `total` is a plain keyword of the surface's `client`, and since `total` wraps `send`, an eager `.get()` reads its body inside the deadline too. ```python with client(total=UPLOAD_TIMEOUT) as session: # put() with client(total=DEFAULT_TIMEOUT) as session: # test() ``` `total` is per request, so one `put()` is bounded to roughly (4 ensures + 1 post + up to 20 polls + 2) × `total`; acceptable, or give the polls a smaller `total`. The size half can be done in the plugin too: `session.stream("GET", ...)`, sum `iter_bytes()` up to a cap (1 MiB is generous for these JSON answers) and raise `PaperlessError` beyond it; the same for the `POST` and `PATCH` answers. Publishing `get_bytes`/`read_body` on `postulo.plugins.api` would shorten this and is a sensible core follow-up, but this fix does not wait on it. Done when: a test whose `MockTransport` answers `documents/` with a body above the cap raises a `PaperlessError` without holding it, and one whose handler sleeps past `total` fails within the deadline rather than after the sum of its reads. ## Related #13 and #14 (open) are about retry counts for a refused token and an unsupported file type, not about what is read. postulo/postulo-apprise#17 (open) is the same scheduler hold for Apprise lines.
Author
Owner

Fixed by pull request #18.

Fixed by pull request #18.
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-paperless#16
No description provided.