SEC2-026: An installed plugin may register under a built-in's name and is then treated as shipped inside Postulo #968
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#968
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 on 2026-10-16 in a read-only security review of
postuloat56fc9d531, 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, fromregistry.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,.pthfiles 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, sopolicy.shipped_insidemakes 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_disabledrefuse 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 andConnection.pluginvalues 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:
installing.check(): for eachgroup:nameininfo.entry_points, ifis_internal(name)raiseInstallError(_("%(name)s is the name of a plugin that ships inside Postulo; a plugin needs a name of its own."));registry.plugins(): run_unique_namesover[*builtins, *third_party]and then put third-party sources back in front forparse_pageordering, or have_unique_namesskip a third-party candidate whose name a built-in class holds (this part alone already closes the displacement);is_internalanswer from the class actually registered (type(find_any(name)).__module__.startswith("postulo.")), not from the name.Done when:
tests/test_plugin_install.pyinstalls a wheel declaringpostulo.notifiers: webhook = ...and asserts the install is refused;tests/test_plugin_policy.pyregisters a third-party notifier namedwebhookand assertsfind_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.