PST-045: Owner scoping rests on each write path: several read helpers and generic relations take ids without an owner filter #774
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#774
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-045. 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/models.py(463-500; 72-87);src/postulo/documents/proofs.py(84-103);src/postulo/resume/translating.py(96);src/postulo/applications/views.py(530-532,779,926,1012,1166,1193,1217,1549,1558,1569);src/postulo/jobs/models.py(Contact.company);src/postulo/applications/suggestions.py(24-67);src/postulo/plugins/models.py(332-354)Who can do it, and what they need. Not reachable by an ordinary account through any current request path:
CVAddItemsViewvalidates posted ids againstMultipleChoiceFieldchoices built fromcv.owner's rows (forms.py 439-466), verified by a scratch test for experience, education and project ids as well as the sweep's reference case; the importer maps refs through rows it created for the same user. …How it works.
CVItem.save()orclean()refuses it, unlikeUploadedDocument.check_proves, which raises for a foreignprovestarget on every save.with_entries()prefetches the target withmodel.objects.all()(no owner filter) andbuild_sectionsrenderscv_item.itemas an entry.Impact. A single unguarded write anywhere turns into another person's career entry being rendered and exported. The threat model's own answer for the parallel
proveslink was a save-time check ('no way of writing the link -- the form, the importer, the shell -- can point at somebody else's entry'); the older and more widely used CVItem link has no such check.Evidence.
Verification (verdict: confirmed; severity after verification: Low). The quoted sentence 'no way of writing the link -- the form, the importer, the shell -- can point at somebody else's entry' comes from the check_proves docstring in models.py. It is not in docs/THREAT-MODEL.md, which does not mention it. File and lines are right (models.py:449-475). Request-path exploitability is nil today. The finding is that the invariant lives only in one form. Remediation: validate in CVItem.save() or clean() that the target model has owner_id == self.owner_id, as check_proves does, and restrict with_entries querysets to the CV owner.
Suggested remediation. Mirror
check_proveson CVItem: insave()verify the target model is a ResumeItem subclass andmodel.objects.filter(pk=object_id, owner_id=owner_id).exists(), and thatcv.owner_id == owner_id; optionally scope thewith_entriesprefetch querysets withowner_id=cv.owner_idas a second line. Add a test like tests/security/test_proofs.py::test_a_save_refuses_a_link_to_another_persons_entry for CVItem.Also covered by this issue.
renders_of()(CV.renders / CoverLetter.renders) lists snapshots by source link only, not by owner (src/postulo/documents/models.py(72-87); Informational). Limited:sent_totext and a date of another account's snapshot would be drawn on A's page; the file itself stays unreachable. By contrastrendering.exported_on_its_own()(rendering.py:969-971) andRenderedDocument.previous()(models.py:1136) do addowner, so the same question is answered …proofs.counts_for,archiving.copies_for) resolve by content id without an owner filter (src/postulo/documents/proofs.py(84-103); Informational). Negligible information (a count, a store's label/status) and no file access; recorded so the synthesis can say these helpers are not independently owner-scoped.src/postulo/resume/translating.py(96); Informational). None today. Would become stored cross-account content injection into a CV (Translation) or a misleading count (proofs) if a scoped-write invariant were ever broken.applicationFK without re-scoping, so an owner mismatch would cross accounts in both directions (src/postulo/applications/views.py(530-532,779,926,1012,1166,1193,1217,1549,1558,1569); Informational). Given the precondition: cross-account read of one application's outstanding reminders (text and dates) and of its role/company through the fragment and edit pages; injection of another account's text into the owner's page; cross-account deletion through the cascade. …src/postulo/jobs/models.py(Contact.company); Informational). None presently; one unscoped writer anywhere in the process would turn several jobs pages into cross-account reads and deletes without any further bug.src/postulo/applications/suggestions.py(24-67); Informational). No path for an ordinary account; a plugin defect would show one person's role and employer in another's suggestion queue, and accepting it would raise a server error instead of a quiet 404. Recorded so the synthesis can say the boundary was looked at.src/postulo/plugins/models.py(332-354); Informational). No reachable path for an account holder; a defensive assert at the one chokepoint plugins must use would turn a plugin's scoping bug into a logged failure instead of a cross-account push.Fixed in
58c880df5on main.