PST-047: The kept-page account quota is a check-then-write with no lock, so concurrent uploads exceed it #776

Closed
opened 2026-10-10 10:56:19 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 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.


  • Severity: Low
  • Category: resource-limit-bypass / race-condition
  • Location: src/postulo/jobs/pages.py (517-556; 190-192,390,527-548,640)
  • Found by: REST API, business logic and imports

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.

  1. Create N pending captures (POST /captures with html, not rate-limited by the capture allowance).
  2. Send N PUT /captures/{pk}/rendering requests concurrently, each of up to POSTULO_CAPTURE_RENDERING_MAX_BYTES (10 MB).
  3. Each request reads room = room_left(owner) before streaming the body and compares size > room afterwards; 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 with non_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.

pages.py 536: `room = room_left(capture.owner)`; 551-552: `if size > room:\n            raise _no_room()`; 555: `page, claimed = _claim_rendering(...)` -- `_claim_rendering` guards only against two renderings for the *same* capture (`get_or_create(capture_id=...)`, `filter(pk=page.pk, rendering="").update(...)`), not the account total.

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.

  • Per-account kept-page quota is a read-then-write check with no lock, so concurrent renderings can exceed the operator's cap (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. …
Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 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. --- - **Severity:** Low - **Category:** resource-limit-bypass / race-condition - **Location:** `src/postulo/jobs/pages.py` (517-556; 190-192,390,527-548,640) - **Found by:** REST API, business logic and imports **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.** 1. Create N pending captures (`POST /captures` with `html`, not rate-limited by the capture allowance). 2. Send N `PUT /captures/{pk}/rendering` requests concurrently, each of up to POSTULO_CAPTURE_RENDERING_MAX_BYTES (10 MB). 3. Each request reads `room = room_left(owner)` before streaming the body and compares `size > room` afterwards; 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 with `non_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.** ```text pages.py 536: `room = room_left(capture.owner)`; 551-552: `if size > room:\n raise _no_room()`; 555: `page, claimed = _claim_rendering(...)` -- `_claim_rendering` guards only against two renderings for the *same* capture (`get_or_create(capture_id=...)`, `filter(pk=page.pk, rendering="").update(...)`), not the account total. ``` **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.** - **Per-account kept-page quota is a read-then-write check with no lock, so concurrent renderings can exceed the operator's cap** (`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. …
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:49 +00:00
Author
Owner

Fixed in 7e865a90d on main.

Fixed in 7e865a90d on main.
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#776
No description provided.