PST-056: connections:create reaches the built-in local store that the pick page hides #785

Closed
opened 2026-10-10 10:56:28 +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-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.


  • Severity: Low
  • Category: authorization / unintended-reachability / data-retention
  • Location: src/postulo/plugins/views.py (47-55,230,454-466)
  • Found by: ownership review of plugins-views

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 the local plugin is shipped and always on.

How it works.

  1. GET /settings/connections/add/store/local/. _plugin_or_404 checks only that kind is a connected kind, that find_plugin finds the plugin, and that decide(name, person).offered is true; it never asks needs_connection, which is the attribute policy.connected_plugins() (line 382) uses to keep local off the pick page. LocalStore is registered under store in documents/apps.py, and as a shipped plugin decide answers on/offered. The form renders (verified: 200).
  2. POST the form; ConnectionForm.save stores Connection(kind='store', plugin='local') (verified: row created).
  3. POST connections:backfill for that pk. ConnectionBackfillView narrows only by owner and kind, then calls backfill(connection), which hands the connection straight to schedule_copies(document, connections=[connection]), bypassing store_connections() whose .exclude(plugin='local') is the only place the local store is excluded. A DocumentCopy(store='local') is queued per document (verified).
  4. The scheduler's send_copy opens the document's own file and calls LocalStore.put, which runs document.file.save(metadata.filename, file, save=False): the storage writes a second file (mine_BCXlo9s.pdf) beside the original, the in-memory document.file.name changes but save=False leaves the row pointing at the original, and the copy is marked SENT with external_id naming the duplicate (verified: two files on disk, document row unchanged).
  5. …

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.

views.py:47-55 `def _plugin_or_404(kind, name, person=None): if kind not in CONNECTED_KINDS: raise Http404 ... plugin = find_plugin(kind, name) ... if person is not None and not decide(name, person).offered: raise Http404` (no needs_connection check). policy.py:382 `found.extend(p for p in of_kind if getattr(p, "needs_connection", True))` (the pick page's filter). archiving.py:62-64 `def store_connections(user): ... connections = connections.exclude(plugin="local")`; archiving.py:104-109 `def backfill(connection): ... count += len(schedule_copies(document, connections=[connection]))` (store_connections not consulted). localstore/__init__.py:56-58 `def put(...): document.file.save(metadata.fi
…

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 (or ConnectionFormView) 404 any plugin with needs_connection = False, and have backfill/schedule_copies(connections=...) skip plugin == 'local'. Also consider making LocalStore.put a no-op that returns a reference to the existing file.

Suggested remediation. In _plugin_or_404, raise 404 for any plugin whose needs_connection is false, so the create address matches what the pick page offers. Independently, make backfill (or schedule_copies when handed explicit connections) apply the same exclude(plugin='local') as store_connections, and have LocalStore.put return the existing reference for a document already in this store instead of re-saving the file. A migration or management command could remove DocumentCopy rows with store='local'.

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-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. --- - **Severity:** Low - **Category:** authorization / unintended-reachability / data-retention - **Location:** `src/postulo/plugins/views.py` (47-55,230,454-466) - **Found by:** ownership review of plugins-views **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 the `local` plugin is shipped and always on. **How it works.** 1. `GET /settings/connections/add/store/local/`. `_plugin_or_404` checks only that `kind` is a connected kind, that `find_plugin` finds the plugin, and that `decide(name, person).offered` is true; it never asks `needs_connection`, which is the attribute `policy.connected_plugins()` (line 382) uses to keep `local` off the pick page. `LocalStore` is registered under `store` in `documents/apps.py`, and as a shipped plugin `decide` answers on/offered. The form renders (verified: 200). 2. POST the form; `ConnectionForm.save` stores `Connection(kind='store', plugin='local')` (verified: row created). 3. POST `connections:backfill` for that pk. `ConnectionBackfillView` narrows only by owner and kind, then calls `backfill(connection)`, which hands the connection straight to `schedule_copies(document, connections=[connection])`, bypassing `store_connections()` whose `.exclude(plugin='local')` is the only place the local store is excluded. A `DocumentCopy(store='local')` is queued per document (verified). 4. The scheduler's `send_copy` opens the document's own file and calls `LocalStore.put`, which runs `document.file.save(metadata.filename, file, save=False)`: the storage writes a second file (`mine_BCXlo9s.pdf`) beside the original, the in-memory `document.file.name` changes but `save=False` leaves the row pointing at the original, and the copy is marked SENT with `external_id` naming the duplicate (verified: two files on disk, document row unchanged). 5. … **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.** ```text views.py:47-55 `def _plugin_or_404(kind, name, person=None): if kind not in CONNECTED_KINDS: raise Http404 ... plugin = find_plugin(kind, name) ... if person is not None and not decide(name, person).offered: raise Http404` (no needs_connection check). policy.py:382 `found.extend(p for p in of_kind if getattr(p, "needs_connection", True))` (the pick page's filter). archiving.py:62-64 `def store_connections(user): ... connections = connections.exclude(plugin="local")`; archiving.py:104-109 `def backfill(connection): ... count += len(schedule_copies(document, connections=[connection]))` (store_connections not consulted). localstore/__init__.py:56-58 `def put(...): document.file.save(metadata.fi … ``` **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` (or `ConnectionFormView`) 404 any plugin with `needs_connection = False`, and have `backfill`/`schedule_copies(connections=...)` skip `plugin == 'local'`. Also consider making `LocalStore.put` a no-op that returns a reference to the existing file. **Suggested remediation.** In `_plugin_or_404`, raise 404 for any plugin whose `needs_connection` is false, so the create address matches what the pick page offers. Independently, make `backfill` (or `schedule_copies` when handed explicit connections) apply the same `exclude(plugin='local')` as `store_connections`, and have `LocalStore.put` return the existing reference for a document already in this store instead of re-saving the file. A migration or management command could remove `DocumentCopy` rows with `store='local'`.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:55 +00:00
Author
Owner

Fixed in 2e34ec445 on main.

Fixed in 2e34ec445 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#785
No description provided.