SEC2-025: A plugin's settings page is not held to the access list's "may change its settings": only the button is #967

Closed
opened 2026-10-17 10:38:37 +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: Authentication/Authorization · Reference: CWE-285 (Improper Authorization); CWE-602 (Client-Side Enforcement of Server-Side Security); OWASP A01:2021 (Broken Access Control)

Location:

  • src/postulo/plugins/policy.py:173-186 (may_change_settings, called nowhere but the plugins list)
  • src/postulo/core/server_views.py:1317, :1361 ("settings_allowed": policy.may_change_settings(user, plugin.name) feeds a disabled button only)
  • src/postulo/core/server_views.py:480-504 (PolicyView(ServerSectionMixin, UpdateView): StaffRequiredMixin and nothing else)
  • src/postulo/core/server_views.py:618, :688, :785, :1132 (MapsView, DataProtectionView, EmailView, IdentifierSchemesView: the pages the manifests name in settings=)
  • src/postulo/plugins/models.py:195-214 (may_change_settings: "the right to use the plugin's settings page, for an administrator who is not the operator")
  • src/postulo/core/views_access.py:41 (GroupsView: decides group membership, StaffRequiredMixin only)

Issue

#886 added PluginAccess.may_change_settings, documented on the model as "the right to use the plugin's settings page, for an administrator who is not the operator", and policy.may_change_settings() implements the decision (operator always; another administrator unless a rule for them or their groups says no). The only caller is _policy_rows, which uses it to draw the Settings button disabled:

"settings_url": _settings_url(plugin) if person is None else "",
"settings_allowed": policy.may_change_settings(user, plugin.name),

The pages the button leads to (server:maps, server:data_protection, server:email, server:identifier_schemes, named in the manifests of maps, gdpr, email/smtp and identifiers) are plain PolicyView subclasses whose only guard is StaffRequiredMixin.test_func (is_staff). An administrator whose rule says may_change_settings=False opens the address directly, or posts the form, and the setting is saved; PolicyView.form_valid records them as updated_by, so the change is at least attributed. tests/test_plugin_access.py:135 tests only that the button is disabled.

The same holds one level up: the access-list page (PluginAccessView) and GroupsView are themselves guarded by is_staff only, so an excluded administrator could also lift the rule, or change the group membership it keys on, before using the page.

Impact

An administrator the operator deliberately excluded from a plugin's settings changes them anyway: the map tile server every member's browser fetches from (and so where the neighbourhood of their search is sent), the retention policy and privacy notice, the identifier schemes, and the instance's mail settings page, which holds the mail password and the OAuth client secret and is where the recovery mail of every account is configured. The restriction exists only in the interface.

Fix

Enforce the rule in the view. Give PolicyView (or a mixin the settings views use) the plugin's name and refuse in dispatch:

class PluginSettingsMixin:
    plugin_name = ""

    def dispatch(self, request, *args, **kwargs):
        from postulo.plugins import policy
        if self.plugin_name and not policy.may_change_settings(request.user, self.plugin_name):
            raise PermissionDenied
        return super().dispatch(request, *args, **kwargs)

Prefer deriving the gate from manifest.settings (resolve each manifest's settings URL name to its view) over a hand-kept plugin_name per view, so a future plugin page is covered automatically. Whichever form, the gate has to cover EmailConsentView, EmailTestView and EmailConnectionTestView (reached from the mail page) and the htmx dialog path of IdentifierSchemesView, not only the four main views. For the check to mean anything, the access-list page and GroupsView also need a rule the excluded administrator cannot undo: at least "an administrator may not edit a rule about themselves or their own groups", or operator-only.

Done when: a test in tests/test_plugin_access.py makes a staff account with a may_change_settings=False rule for maps, GETs and POSTs server:maps, and asserts 403 and an unchanged SiteSettings; and a sibling test asserts the same account cannot remove its own rule through the access-list page.

#886 (closed) - introduced the right and says the Settings button's "not authorised" state reads it; it does not say the page itself is gated, and the code gates only the button, so this is an incomplete fix of #886. #885 (closed) - the button. #368 (closed) - the superuser grant; neither covers the server-side check.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Authentication/Authorization · **Reference:** CWE-285 (Improper Authorization); CWE-602 (Client-Side Enforcement of Server-Side Security); OWASP A01:2021 (Broken Access Control) **Location:** - `src/postulo/plugins/policy.py:173-186` (`may_change_settings`, called nowhere but the plugins list) - `src/postulo/core/server_views.py:1317`, `:1361` (`"settings_allowed": policy.may_change_settings(user, plugin.name)` feeds a disabled button only) - `src/postulo/core/server_views.py:480-504` (`PolicyView(ServerSectionMixin, UpdateView)`: `StaffRequiredMixin` and nothing else) - `src/postulo/core/server_views.py:618`, `:688`, `:785`, `:1132` (`MapsView`, `DataProtectionView`, `EmailView`, `IdentifierSchemesView`: the pages the manifests name in `settings=`) - `src/postulo/plugins/models.py:195-214` (`may_change_settings`: "the right to use the plugin's settings page, for an administrator who is not the operator") - `src/postulo/core/views_access.py:41` (`GroupsView`: decides group membership, `StaffRequiredMixin` only) ## Issue #886 added `PluginAccess.may_change_settings`, documented on the model as "the right to use the plugin's settings page, for an administrator who is not the operator", and `policy.may_change_settings()` implements the decision (operator always; another administrator unless a rule for them or their groups says no). The only caller is `_policy_rows`, which uses it to draw the *Settings* button disabled: ```python "settings_url": _settings_url(plugin) if person is None else "", "settings_allowed": policy.may_change_settings(user, plugin.name), ``` The pages the button leads to (`server:maps`, `server:data_protection`, `server:email`, `server:identifier_schemes`, named in the manifests of `maps`, `gdpr`, `email`/`smtp` and `identifiers`) are plain `PolicyView` subclasses whose only guard is `StaffRequiredMixin.test_func` (`is_staff`). An administrator whose rule says `may_change_settings=False` opens the address directly, or posts the form, and the setting is saved; `PolicyView.form_valid` records them as `updated_by`, so the change is at least attributed. `tests/test_plugin_access.py:135` tests only that the button is disabled. The same holds one level up: the access-list page (`PluginAccessView`) and `GroupsView` are themselves guarded by `is_staff` only, so an excluded administrator could also lift the rule, or change the group membership it keys on, before using the page. ## Impact An administrator the operator deliberately excluded from a plugin's settings changes them anyway: the map tile server every member's browser fetches from (and so where the neighbourhood of their search is sent), the retention policy and privacy notice, the identifier schemes, and the instance's mail settings page, which holds the mail password and the OAuth client secret and is where the recovery mail of every account is configured. The restriction exists only in the interface. ## Fix Enforce the rule in the view. Give `PolicyView` (or a mixin the settings views use) the plugin's name and refuse in `dispatch`: ```python class PluginSettingsMixin: plugin_name = "" def dispatch(self, request, *args, **kwargs): from postulo.plugins import policy if self.plugin_name and not policy.may_change_settings(request.user, self.plugin_name): raise PermissionDenied return super().dispatch(request, *args, **kwargs) ``` Prefer deriving the gate from `manifest.settings` (resolve each manifest's settings URL name to its view) over a hand-kept `plugin_name` per view, so a future plugin page is covered automatically. Whichever form, the gate has to cover `EmailConsentView`, `EmailTestView` and `EmailConnectionTestView` (reached from the mail page) and the htmx dialog path of `IdentifierSchemesView`, not only the four main views. For the check to mean anything, the access-list page and `GroupsView` also need a rule the excluded administrator cannot undo: at least "an administrator may not edit a rule about themselves or their own groups", or operator-only. Done when: a test in `tests/test_plugin_access.py` makes a staff account with a `may_change_settings=False` rule for `maps`, GETs and POSTs `server:maps`, and asserts 403 and an unchanged `SiteSettings`; and a sibling test asserts the same account cannot remove its own rule through the access-list page. ## Related #886 (closed) - introduced the right and says the Settings button's "not authorised" state reads it; it does not say the page itself is gated, and the code gates only the button, so this is an incomplete fix of #886. #885 (closed) - the button. #368 (closed) - the superuser grant; neither covers the server-side check.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:38:38 +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#967
No description provided.