SEC2-026: An installed plugin may register under a built-in's name and is then treated as shipped inside Postulo #968

Closed
opened 2026-10-17 10:38:39 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Low · Category: Business Logic · Reference: CWE-706 (Use of Incorrectly-Resolved Name or Reference); CWE-284 (Improper Access Control); OWASP A04:2021 (Insecure Design)

Location:

  • src/postulo/plugins/registry.py:325-339 (plugins(): third-party first, then the built-ins)
  • src/postulo/plugins/registry.py:341-363 (_unique_names: the first holder of a name wins, the later one is logged and dropped)
  • src/postulo/plugins/installing.py:460-494 (check(): no refusal for an entry point whose name a built-in holds)
  • src/postulo/plugins/installing.py:958-971 (is_internal: answers by name alone, from registry.builtins())
  • src/postulo/plugins/policy.py:188-197, :370-374 (shipped_inside -> is_internal; a shipped plugin is "on unless an administrator said otherwise, and never the person's to switch")

Issue

#593 made a plugin's name unique across the instance by keeping the first plugin that claims it. Within one kind the candidates are built as [*_load_third_party(kind), *(plugin_class() for plugin_class in _builtin.get(kind, []))], so a wheel whose entry point is named like a built-in of the same kind (webhook, email, local, maps, gdpr, europass, ...) wins and the built-in is the one logged and dropped. Nothing at install time refuses it: check() looks at the platform tag, the entry-point groups, .pth files and version pins only. Afterwards every question asked by name answers for the replacement as if it were Postulo's own: is_internal(name) is true because a built-in class with that name exists, so policy.shipped_inside makes it "on for everyone and never the person's to switch", overview() hides its row from a person's plugins page unless an administrator decided about it, installing.remove/set_disabled refuse the name as "ships inside Postulo" (so the replacement cannot be removed through the installer either, which is keyed by plugin name rather than by distribution), provenance.status() lists a built-in row for it, and the policy rows, access rules, logo cache and Connection.plugin values written for the real built-in now drive the replacement (existing webhook or email connections, with their secrets, are handed to the new code).

The plugin is still code an administrator chose to install, which the threat model says is a trust boundary. What this removes is the visibility the model relies on: an administrator reading the page sees a "Module" row and a person sees nothing they can switch.

Impact

An administrator who installs a wheel without reading it ends up with a third-party notifier, store or source wearing a built-in's name and label, on for every account, invisible on the person's page, not removable or switchable by name, and fed every existing connection's configuration and secrets for that name.

Fix

Refuse the collision at the door and prefer the built-in at load time:

  • in installing.check(): for each group:name in info.entry_points, if is_internal(name) raise InstallError(_("%(name)s is the name of a plugin that ships inside Postulo; a plugin needs a name of its own."));
  • in registry.plugins(): run _unique_names over [*builtins, *third_party] and then put third-party sources back in front for parse_page ordering, or have _unique_names skip a third-party candidate whose name a built-in class holds (this part alone already closes the displacement);
  • make is_internal answer from the class actually registered (type(find_any(name)).__module__.startswith("postulo.")), not from the name.

Done when: tests/test_plugin_install.py installs a wheel declaring postulo.notifiers: webhook = ... and asserts the install is refused; tests/test_plugin_policy.py registers a third-party notifier named webhook and asserts find_plugin("notifier", "webhook") is the built-in class.

#593 (closed) - enforced uniqueness, but chose "first wins" with third-party plugins first, which is what lets a built-in be displaced; this is a different weakness in the same mechanism. #911 (open) - a label clash between two built-ins, not this.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Business Logic · **Reference:** CWE-706 (Use of Incorrectly-Resolved Name or Reference); CWE-284 (Improper Access Control); OWASP A04:2021 (Insecure Design) **Location:** - `src/postulo/plugins/registry.py:325-339` (`plugins()`: third-party first, then the built-ins) - `src/postulo/plugins/registry.py:341-363` (`_unique_names`: the first holder of a name wins, the later one is logged and dropped) - `src/postulo/plugins/installing.py:460-494` (`check()`: no refusal for an entry point whose name a built-in holds) - `src/postulo/plugins/installing.py:958-971` (`is_internal`: answers by name alone, from `registry.builtins()`) - `src/postulo/plugins/policy.py:188-197`, `:370-374` (`shipped_inside` -> `is_internal`; a shipped plugin is "on unless an administrator said otherwise, and never the person's to switch") ## Issue #593 made a plugin's name unique across the instance by keeping the first plugin that claims it. Within one kind the candidates are built as `[*_load_third_party(kind), *(plugin_class() for plugin_class in _builtin.get(kind, []))]`, so a wheel whose entry point is named like a built-in of the same kind (`webhook`, `email`, `local`, `maps`, `gdpr`, `europass`, ...) wins and the built-in is the one logged and dropped. Nothing at install time refuses it: `check()` looks at the platform tag, the entry-point groups, `.pth` files and version pins only. Afterwards every question asked by name answers for the replacement as if it were Postulo's own: `is_internal(name)` is true because a built-in *class* with that name exists, so `policy.shipped_inside` makes it "on for everyone and never the person's to switch", `overview()` hides its row from a person's plugins page unless an administrator decided about it, `installing.remove`/`set_disabled` refuse the *name* as "ships inside Postulo" (so the replacement cannot be removed through the installer either, which is keyed by plugin name rather than by distribution), `provenance.status()` lists a built-in row for it, and the policy rows, access rules, logo cache and `Connection.plugin` values written for the real built-in now drive the replacement (existing webhook or email connections, with their secrets, are handed to the new code). The plugin is still code an administrator chose to install, which the threat model says is a trust boundary. What this removes is the visibility the model relies on: an administrator reading the page sees a "Module" row and a person sees nothing they can switch. ## Impact An administrator who installs a wheel without reading it ends up with a third-party notifier, store or source wearing a built-in's name and label, on for every account, invisible on the person's page, not removable or switchable by name, and fed every existing connection's configuration and secrets for that name. ## Fix Refuse the collision at the door and prefer the built-in at load time: - in `installing.check()`: for each `group:name` in `info.entry_points`, if `is_internal(name)` raise `InstallError(_("%(name)s is the name of a plugin that ships inside Postulo; a plugin needs a name of its own."))`; - in `registry.plugins()`: run `_unique_names` over `[*builtins, *third_party]` and then put third-party sources back in front for `parse_page` ordering, or have `_unique_names` skip a third-party candidate whose name a built-in class holds (this part alone already closes the displacement); - make `is_internal` answer from the class actually registered (`type(find_any(name)).__module__.startswith("postulo.")`), not from the name. Done when: `tests/test_plugin_install.py` installs a wheel declaring `postulo.notifiers: webhook = ...` and asserts the install is refused; `tests/test_plugin_policy.py` registers a third-party notifier named `webhook` and asserts `find_plugin("notifier", "webhook")` is the built-in class. ## Related #593 (closed) - enforced uniqueness, but chose "first wins" with third-party plugins first, which is what lets a built-in be displaced; this is a different weakness in the same mechanism. #911 (open) - a label clash between two built-ins, not this.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:38:41 +00:00
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#968
No description provided.