PST-044: The owner-folder guard is missing on rendered-document and export downloads, and is a raw prefix test elsewhere #773
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#773
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-044. 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/documents/views.py(794-802);src/postulo/api/routers/documents.py(346-350);src/postulo/core/views_export.py(86-94);src/postulo/core/files.py(98-113; 92-95)Who can do it, and what they need. An authenticated account holder, given one RenderedDocument row of their own whose
filecolumn names a path under another account'sdocuments/<other_id>/folder. No request-driven path writes such a row today (LocalStore.put and the importer always store throughupload_to_documents, which prefixes the owner's id), so the precondition is a bug in a future writer, a plugin that saves renders, a careless migration, or a hand-edited database. …How it works.
file.name=documents/<B>/2026/10/theirs.pdf(precondition above).RenderedDownloadView.getnarrows by owner (row is A's) and callsserve_private_file(request, document.file, download_name=...)with nowithin.serve_private_fileonly checks the path stays inside MEDIA_ROOT and streams B's bytes. Verified with a scratch test: the same manipulation on an UploadedDocument row answers 404 (thewithin=document.storage_prefixcheck), while the RenderedDocument row answers 200 with the other owner's bytes.Impact. A second line of defence that exists for uploads (and for API upload downloads) is absent for rendered snapshots, so one wrong row can hand one person another person's file. No live exploit path from a request was found; the finding is that the project's stated #663 guarantee ('a row whose name says otherwise is not handed over whatever it points at') does not hold for half of the file-bearing models.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Line numbers are right: views.py:794-802 and api/routers/documents.py:346-350. The guarantee quoted from #663 is written in core/files.py about uploads ('an upload is kept under its owner's own'). It is not a promise made for every file-bearing model. The same gap exists for the other serve_private_file callers (jobs/page_views.py CapturedRenderingView, core/views_export.py export archives), so RenderedDocument is not unique. Remediation: pass within=f'documents/{document.owner_id}/' in both rendered branches, or add a storage_prefix property to RenderedDocument, and add a test beside the upload one. No attacker can create the precondition today.
Suggested remediation. Give RenderedDocument the same
storage_prefixproperty (documents/{owner_id}/) and passwithin=document.storage_prefixfrom RenderedDownloadView and from the API's rendered branch, so a snapshot row is held to its owner's folder exactly as an upload is. Add the rendered case to tests/security/test_files.py beside the upload one.Also covered by this issue.
src/postulo/api/routers/documents.py(346-350); Informational). None on its own; a second mistake elsewhere is required. Recorded because the project treats this check as a rule for uploads and the sent CV/letter is the file it considers most sensitive.withinowner-folder guard that uploads use (src/postulo/core/views_export.py(86-94); Informational). One wrong file name on one row becomes another person's file. No path reaches it from a request today, so this is a hardening gap rather than an exploitable hole.within=is a raw string-prefix test that..segments in a stored name defeat (src/postulo/core/files.py(98-113); Informational). If any row ever carries such a name, the #663 promise 'an upload is served only from its owner's folder' does not hold, and a file in another account's folder is handed over. The guard is the second line of defence behind the owner-scoped queryset. …src/postulo/core/files.py(92-95); Informational). None today; the guard would not hold in the case it was written for if a traversal name ever reached the column.Fixed in
5dafe9083on main.