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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user