SEC2-061: Store reads every Paperless answer whole with no deadline, so a server the account holder names can hold the shared scheduler #16
Labels
No labels
bug
documentation
enhancement
performance
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-paperless#16
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-10-16 in a read-only security review of
postulo-paperlessat37a044b, 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 toWAIT_SECONDSof polls per document)src/postulo_paperless/store.py:200-213(set_custom_field)src/postulo_paperless/store.py:302and:314(client()opened with nototal=)Issue
Every call uses httpx's eager methods and then parses the whole body:
client.get()has read the complete body before it returns, however large; nothing passes alimit. 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; thecore'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-273sends copies oneafter another, and
send_due_reminders.py:176-206runs copies after reminders and beforewebhooks 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 thescheduler; 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:
totalis a plain keyword of the surface'sclient,and since
totalwrapssend, an eager.get()reads its body inside the deadline too.totalis per request, so oneput()is bounded to roughly (4 ensures + 1 post + up to 20 pollstotal; acceptable, or give the polls a smallertotal.The size half can be done in the plugin too:
session.stream("GET", ...), sumiter_bytes()upto a cap (1 MiB is generous for these JSON answers) and raise
PaperlessErrorbeyond it; the samefor the
POSTandPATCHanswers. Publishingget_bytes/read_bodyonpostulo.plugins.apiwould shorten this and is a sensible core follow-up, but this fix does not wait on it.
Done when: a test whose
MockTransportanswersdocuments/with a body above the cap raises aPaperlessErrorwithout holding it, and one whose handler sleeps pasttotalfails within thedeadline 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.
Fixed by pull request #18.