PST-056: connections:create reaches the built-in local store that the pick page hides #785
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#785
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-056. 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/views.py(47-55,230,454-466)Who can do it, and what they need. Any signed-in account on the instance, acting against its own data and the instance's disk. No other precondition: the address is guessable (
/settings/connections/add/store/local/) and thelocalplugin is shipped and always on.How it works.
GET /settings/connections/add/store/local/._plugin_or_404checks only thatkindis a connected kind, thatfind_pluginfinds the plugin, and thatdecide(name, person).offeredis true; it never asksneeds_connection, which is the attributepolicy.connected_plugins()(line 382) uses to keeplocaloff the pick page.LocalStoreis registered understoreindocuments/apps.py, and as a shipped plugindecideanswers on/offered. The form renders (verified: 200).ConnectionForm.savestoresConnection(kind='store', plugin='local')(verified: row created).connections:backfillfor that pk.ConnectionBackfillViewnarrows only by owner and kind, then callsbackfill(connection), which hands the connection straight toschedule_copies(document, connections=[connection]), bypassingstore_connections()whose.exclude(plugin='local')is the only place the local store is excluded. ADocumentCopy(store='local')is queued per document (verified).send_copyopens the document's own file and callsLocalStore.put, which runsdocument.file.save(metadata.filename, file, save=False): the storage writes a second file (mine_BCXlo9s.pdf) beside the original, the in-memorydocument.file.namechanges butsave=Falseleaves the row pointing at the original, and the copy is marked SENT withexternal_idnaming the duplicate (verified: two files on disk, document row unchanged).Impact. Self-inflicted only, no cross-account reach. Two effects: unbounded growth of private media from one account (N connections x every document), and a retention gap where personal documents the person later deletes survive on disk as orphans nothing references, exports, or erases with the account. Denial of service is out of scope by the threat model, which is why this is low rather than medium.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Same files and lines as cited. Corrections: (1) the duplicates are not 'never cleaned':
manage.py prune_media(an operator action) lists and removes them, but no automatic path does, either on document deletion or on account erasure. (2) 'Unbounded growth' is a denial of service against one's own instance, which the threat model excludes, so the substance is the retention gap: copies of documents the person deleted, or of an erased account, stay on disk and in backups. (3) The path is reachable only by typing the hidden address/settings/connections/add/store/local/, because the pick page never links to it. Remediation: make_plugin_or_404(orConnectionFormView) 404 any plugin withneeds_connection = False, and havebackfill/schedule_copies(connections=...)skipplugin == 'local'. Also consider makingLocalStore.puta no-op that returns a reference to the existing file.Suggested remediation. In
_plugin_or_404, raise 404 for any plugin whoseneeds_connectionis false, so the create address matches what the pick page offers. Independently, makebackfill(orschedule_copieswhen handed explicit connections) apply the sameexclude(plugin='local')asstore_connections, and haveLocalStore.putreturn the existing reference for a document already in this store instead of re-saving the file. A migration or management command could removeDocumentCopyrows withstore='local'.Fixed in
2e34ec445on main.