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:
cproudlock
2026-07-18 21:47:32 -04:00
parent 55a6f1b8d3
commit dd30ca0c3f
5 changed files with 386 additions and 22 deletions

View File

@@ -160,35 +160,50 @@ class PluginLoader:
plugin_name=name,
)
# THE import gate. Every import path reaches here, so an unsigned or
# tampered plugin never executes under enforcement (findings #1, #2).
# When enforcing: strip any bytecode (so only verified source can run),
# verify the tree, and import without writing new bytecode.
import contextlib
from .packaging import strip_bytecode, no_bytecode
# Under enforcement, verify plugin.py from a SINGLE read and exec exactly
# those bytes (no re-open, no .pyc), so there is no verify-vs-exec gap on
# this file. Its submodule imports (from .models import ...) and every
# `from plugins.X...` in core code are verified by the sys.meta_path
# guard installed at init_app - the import system, not this method, is
# the real choke point (findings #1, #2, #3).
enforcing = self.verifier is not None and self.verifier.require_signed
if enforcing:
from .packaging import strip_bytecode, no_bytecode
from .importguard import PluginImportGuard, PluginVerificationError
strip_bytecode(plugin_dir)
if self.verifier is not None:
ok, reason = self.verifier.check(name)
if not ok:
try:
source = PluginImportGuard(
self.plugins_dir, self.verifier).verified_source(
name, 'plugin.py')
except PluginVerificationError as e:
raise PluginContractError(
f'Plugin {name} failed signature verification: {reason}',
f'Plugin {name} failed signature verification: {e}',
plugin_name=name,
)
try:
with (no_bytecode() if enforcing else contextlib.nullcontext()):
) from e
try:
spec = importlib.util.spec_from_file_location(
f'plugins.{name}.plugin', plugin_module_path)
module = importlib.util.module_from_spec(spec)
with no_bytecode():
exec(compile(source, str(plugin_module_path), 'exec'),
module.__dict__)
except Exception as e:
raise PluginContractError(
f'Plugin {name} import failed: {e}',
plugin_name=name,
) from e
else:
try:
spec = importlib.util.spec_from_file_location(
f'plugins.{name}.plugin', plugin_module_path,
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
except Exception as e:
raise PluginContractError(
f'Plugin {name} import failed: {e}',
plugin_name=name,
) from e
except Exception as e:
raise PluginContractError(
f'Plugin {name} import failed: {e}',
plugin_name=name,
) from e
for attr_name in dir(module):
attr = getattr(module, attr_name)