PST-047: The kept-page account quota is a check-then-write with no lock, so concurrent uploads exceed it #776
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#776
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 in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 8 to 10 October 2026, as PST-047. Severity: Low.The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.
src/postulo/jobs/pages.py(517-556; 190-192,390,527-548,640)Who can do it, and what they need. Someone holding a captures token on an instance where renderings are kept. Precondition: keeping switched on by the instance and the person.
How it works.
POST /captureswithhtml, not rate-limited by the capture allowance).PUT /captures/{pk}/renderingrequests concurrently, each of up to POSTULO_CAPTURE_RENDERING_MAX_BYTES (10 MB).room = room_left(owner)before streaming the body and comparessize > roomafterwards; nothing serialises these reads against the writes, so all N pass while the account is still under the cap and all N are stored. Verified deterministically: with the cap set to one rendering, a second upload interleaved inside the first's stream read left the account at 144 bytes against a 72-byte limit. The view runs withnon_atomic_requests, so no transaction holds SQLite's write lock either.Impact. The 'what one account's kept pages may weigh together' bound the threat model names does not hold under concurrency; an account can exceed it by roughly one rendering cap per concurrent request, bounded only by POSTULO_API_RATE (600/h, ~6 GB/h worst case). Disk-fill on a small self-hosted box. Denial of service is out of scope in the threat model, hence low.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Correct the lines to pages.py:527-529 (room read and length check), 547-548 (stale size check) and 551 (claim); the 536/551/555 citation does not match the file. Correct the impact: a one-off overshoot of about (concurrent in-flight requests - 1) x the per-file cap, about 20 MB over the 1 GB default on the shipped 3-worker image, then all further uploads are refused because room_left clamps at 0. The '~6 GB/h worst case' is wrong. The overshoot approaches N x the account cap only when an operator configures the account cap near or below the 10 MB per-file cap and runs many workers or threads. keep_source (line 390) is serialised on file-backed SQLite by the IMMEDIATE transaction inside create_capture. Remediation: take a per-owner lock (select_for_update on the owner's row inside a short transaction) and re-sum weight_of(owner) immediately before and after claiming the file, deleting the file if the total is over the cap.
Suggested remediation. Re-check the account weight inside a short transaction that locks the account's CapturedPage rows (select_for_update on the owner's rows, or a per-owner advisory row) immediately before the row is written, and roll back/delete the file when the total would exceed the cap; or keep a per-account counter column updated atomically with an F() expression and a CHECK against the cap.
Also covered by this issue.
src/postulo/jobs/pages.py(190-192,390,527-548,640); Low). The threat model promises that a capture token is bounded by 'what one account's kept pages may weigh together'. That bound is only enforced for sequential requests; disk use on a small self-hosted box can be driven well past the operator's configured limit by one leaked extension token. …Fixed in
7e865a90don main.