PST-055: Switching a plugin off does not stop its code: .pth files in the plugins directory run at every process start #784

Closed
opened 2026-10-10 10:56:27 +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-055. 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: plugin-trust-boundary
  • Location: src/postulo/plugins/record.py (98-108)
  • Found by: administrator surface and plugins
  • Related: PST-043 (#772), PST-091 (#820)

Who can do it, and what they need. A plugin author whose wheel an administrator installed and later switched off (or an administrator who installed something they now distrust). Precondition: the wheel ships a .pth file (uv/pip lay .pth files from a wheel's data dir into --target).

How it works.

  1. The wheel includes evil.pth containing import evil_hook (or an inline import os; ... line).
  2. Administrator installs it; later presses 'Switch off' (set_disabled) believing, as the page and the command say ('Stop it loading'), that its code no longer runs.
  3. On every worker/scheduler/manage.py start, PluginsConfig.ready() → activate() → site.addsitedir(plugins_dir), which executes every import line in every .pth in the directory regardless of plugins.json. Confirmed by scratch test: a .pth beside a record marking the plugin disabled set an environment variable at activate().
  4. The registry's disabled_names() filtering only affects entry-point loading, not .pth execution; only 'Remove' deletes the files.

Impact. A plugin an administrator believes is off keeps arbitrary code running in every process (with the database, SECRET_KEY and the field key in reach). The documented promise that off = 'the code not being there' (policy.py docstring) is false for .pth-carried code. Trust is already extended at install, so this is a hardening gap rather than a new boundary crossing.

Evidence.

record.py:101-108 '``site.addsitedir`` rather than a plain ``sys.path`` append, so that ``.pth`` files a package ships are honoured' ... `site.addsitedir(str(directory))`.
plugins/apps.py:28 `activate()` in ready(); registry.catch_up() calls it again.
Scratch test test_pth_in_plugins_dir_runs_at_activate passes.

Verification (verdict: confirmed; severity after verification: Low). Keep record.py:98-108 as the location. Corrections: (a) the .pth lands in the plugins directory from the wheel's root (purelib), not from a 'data dir'. (b) Neither the page nor the policy.py docstring promises that no code runs; the page says 'its files are still here', so the gap is in the registry docstring and the administrator's expectation, not an explicit false claim. (c) The realistic attackers are a plugin author who deliberately ships a .pth, or a compromised PyPI dependency of a plugin (the .pth lives in the dependency, and switching off the plugin does not stop it). (d) The administrator's only full remedy is Remove, which data.refuse_removing blocks while the plugin holds rows. Suggested fix: do not use site.addsitedir for the plugins directory. Append it to sys.path and process .pth files only for distributions not marked disabled, or drop .pth support. Document that switch-off does not stop import-time code and that Remove or deleting files does.

Suggested remediation. Append the plugins directory to sys.path without processing .pth files (plain sys.path.append, or addsitedir on a per-plugin subdirectory only for enabled plugins), and have read_wheel/check refuse a wheel that contains a .pth (or any file outside its own packages). Say on the Plugins page that 'off' leaves files importable until 'Remove'.

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-055**. 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:** plugin-trust-boundary - **Location:** `src/postulo/plugins/record.py` (98-108) - **Found by:** administrator surface and plugins - **Related:** PST-043 (#772), PST-091 (#820) **Who can do it, and what they need.** A plugin author whose wheel an administrator installed and later switched off (or an administrator who installed something they now distrust). Precondition: the wheel ships a .pth file (uv/pip lay .pth files from a wheel's data dir into --target). **How it works.** 1. The wheel includes `evil.pth` containing `import evil_hook` (or an inline `import os; ...` line). 2. Administrator installs it; later presses 'Switch off' (set_disabled) believing, as the page and the command say ('Stop it loading'), that its code no longer runs. 3. On every worker/scheduler/manage.py start, PluginsConfig.ready() → activate() → site.addsitedir(plugins_dir), which executes every `import` line in every .pth in the directory regardless of plugins.json. Confirmed by scratch test: a .pth beside a record marking the plugin disabled set an environment variable at activate(). 4. The registry's disabled_names() filtering only affects entry-point loading, not .pth execution; only 'Remove' deletes the files. **Impact.** A plugin an administrator believes is off keeps arbitrary code running in every process (with the database, SECRET_KEY and the field key in reach). The documented promise that off = 'the code not being there' (policy.py docstring) is false for .pth-carried code. Trust is already extended at install, so this is a hardening gap rather than a new boundary crossing. **Evidence.** ```text record.py:101-108 '``site.addsitedir`` rather than a plain ``sys.path`` append, so that ``.pth`` files a package ships are honoured' ... `site.addsitedir(str(directory))`. plugins/apps.py:28 `activate()` in ready(); registry.catch_up() calls it again. Scratch test test_pth_in_plugins_dir_runs_at_activate passes. ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep record.py:98-108 as the location. Corrections: (a) the .pth lands in the plugins directory from the wheel's root (purelib), not from a 'data dir'. (b) Neither the page nor the policy.py docstring promises that no code runs; the page says 'its files are still here', so the gap is in the registry docstring and the administrator's expectation, not an explicit false claim. (c) The realistic attackers are a plugin author who deliberately ships a .pth, or a compromised PyPI dependency of a plugin (the .pth lives in the dependency, and switching off the plugin does not stop it). (d) The administrator's only full remedy is Remove, which data.refuse_removing blocks while the plugin holds rows. Suggested fix: do not use site.addsitedir for the plugins directory. Append it to sys.path and process .pth files only for distributions not marked disabled, or drop .pth support. Document that switch-off does not stop import-time code and that Remove or deleting files does. **Suggested remediation.** Append the plugins directory to sys.path without processing .pth files (plain sys.path.append, or addsitedir on a per-plugin subdirectory only for enabled plugins), and have read_wheel/check refuse a wheel that contains a .pth (or any file outside its own packages). Say on the Plugins page that 'off' leaves files importable until 'Remove'.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:55 +00:00
Author
Owner

Fixed in 0b8834ffe on main.

Fixed in 0b8834ffe 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#784
No description provided.