ADR-013 Phase 2: verify every plugins.* import via a meta_path guard
A re-review showed the previous "single import choke point" claim was wrong: `plugins` is a normal importable package, so core request handlers that do `from plugins.<name>.models import ...` never passed through the loader and ran unverified - an attacker who dropped a file into plugins/<name>/ got arbitrary in-process code execution on an ordinary HTTP request (and a planted .pyc ran from cache). Gating load_plugin_class covered only plugin.py, one path of many. Fix: importguard.py installs a sys.meta_path finder (under enforcement) that intercepts EVERY plugins.<name>.* import, verifies the plugin's signed provenance once, then verifies each module file against it and execs the exact bytes it hashed - read once, compiled, exec'd, never a .pyc, never a re-opened file. This closes the submodule bypass and the planted-bytecode read, and the read-once exec closes the verify-vs-exec TOCTOU on the import path. The import system, not one method, is the real choke point. - init_app installs the guard when PLUGIN_REQUIRE_SIGNED, clears it otherwise. - load_plugin_class now verifies plugin.py from a single read and execs that buffer (finding #3 on that file); its submodule imports flow through the guard. - docs: stamp-bundled must cover every plugin dir present (a disabled plugin's module can be imported by core); recommend a read-only plugins/ owned by the deploy user as defense in depth (closes the residual migrate-time race an attacker with concurrent write could otherwise attempt). Earlier review's fixes #3 (migrate code paths) and #4 (shelf content binding) were confirmed sound and are unchanged. 7 import-guard tests (submodule verify, tamper, unsigned refused, planted .pyc ignored, real import through the guard, install/uninstall). 1061 pass, naming green.
This commit is contained in:
@@ -88,12 +88,27 @@ signatures on a site:
|
||||
PLUGIN_REQUIRE_SIGNED=true
|
||||
```
|
||||
|
||||
Now a plugin only loads (verify-at-load) or migrates (verify-at-migrate) when
|
||||
its tree matches a trusted signature. An unsigned, tampered, or wrong-key
|
||||
plugin is refused - fail-closed. `PLUGIN_DEV_TRUST_DIRS` exempts named
|
||||
Now a plugin only loads or migrates when its tree matches a trusted signature.
|
||||
Under enforcement a `sys.meta_path` guard verifies EVERY `plugins.<name>.*`
|
||||
import (not just `plugin.py`) - including the `from plugins.<name>.models import
|
||||
...` that core request handlers do - against the plugin's signed provenance, and
|
||||
executes the exact bytes it hashed (never a `.pyc`). An unsigned, tampered, or
|
||||
wrong-key plugin is refused, fail-closed. `PLUGIN_DEV_TRUST_DIRS` exempts named
|
||||
directories, but ONLY under DEBUG/TESTING (the external-repo dev workflow);
|
||||
production ignores it.
|
||||
|
||||
Because every `plugins.*` import is verified, `stamp-bundled` must cover EVERY
|
||||
plugin directory present (its no-argument form does), not only the enabled ones
|
||||
- core code can import a disabled plugin's module, and an unstamped one would be
|
||||
refused.
|
||||
|
||||
Defense in depth - set filesystem permissions so the app's runtime user CANNOT
|
||||
write the `plugins/` directory (owned by the deploy user). Import-time
|
||||
verification closes the "attacker drops a file, a request imports it" path; a
|
||||
strict read-only `plugins/` also closes the narrow verify-vs-migrate race where
|
||||
an attacker with concurrent write to `plugins/` swaps a migration script between
|
||||
the check and alembic re-reading it.
|
||||
|
||||
## The shelf and adopt (Phase 2)
|
||||
|
||||
A shelf is a read-only folder of artifacts plus a signed index. The app reads
|
||||
|
||||
Reference in New Issue
Block a user