PST-049: The capture parser does quadratic work on a hostile page, with no time bound in the errand worker #778
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#778
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-049. 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/plugins/builtin/htmlutil.py(185-196,223-227,482-497)Who can do it, and what they need. Two attackers. (a) The in-scope 'malicious page that is captured': any third party whose job page a member captures by URL, with no account on the instance. (b) Any member, or the holder of a captures-only API token, who sends their own html to POST /api/v1/captures or the paste form.
How it works.
Impact. Availability for every account on a multi-user instance: web workers repeatedly killed, or the background worker (exports, PDFs, notifications, logo and capture fetches) stalled for hours by one hostile page. No data exposure. The threat model puts DoS against a personal instance out of scope, but it lists a captured page as an attacker and bounds the PDF renderer with a CPU limit; the HTML parser has no equivalent.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Precise conditions, corrected where the auditor's description was loose.
(1) The nested-
<article>/<main>cost inmain_textonly appears when the outer landmarks fail the early return, i.e. the text is under 200 chars or under 25% of the page. With ordinary long text the outermost article returns at once (0.03 s measured). With short text it is 41 s at 54 KB.(2)
hints._labelsis reached throughremembered.after_capture(api.py:525, jobs/slow.py) only after the page parsed into a capture. The attacker therefore puts a valid JSON-LD JobPosting first and the nesting after it. A page that parses to nothing returns 422 before_labelsruns (measured: 422 in under 4 s at 80 KB). The tree-build andmain_textcosts apply to any page.(3) Three concurrent API posts of about 70 KB each (about 70 s apiece) occupy all 3 sync workers. Posts of about 100 KB or more are killed at the 120 s timeout. I inferred this from the measurements and the Dockerfile and did not run a concurrent load test.
(4) Via captured URL (up to 2 MB), a member has to capture the hostile page. The single errand worker then stays busy for as long as the parse runs, since nothing ever interrupts it. …
Suggested remediation. Cap the number of open elements and total nodes in _TreeBuilder (stop or flatten past a depth such as 200 and a node budget). Make end-tag matching O(1) or bounded with a per-name index. Compute subtree text once bottom-up, as _link_farms already does, instead of calling text_of() per candidate. Run parse_page under a wall-clock budget (a child process or resource limit like the capture renderer's). Count own-html captures against an allowance, or bound html length well below 2.5 MB.
Fixed in
1933f75cdon main.