ADR-013 Phase 2: import guard fails closed on non-.py + owns package root
Third review found the meta_path guard leaked exactly where it delegated to the stdlib import system: 1. Non-.py submodules (CRITICAL). When a name had no dir and no .py, find_spec returned None and the stdlib loaded a planted .so (ExtensionFileLoader) or a sourceless .pyc unverified - an attacker deletes a signed .py and drops a same-named .so with arbitrary init code, run on a normal request via core's `from plugins.<name>.models import ...`. The guard now refuses any name for which a non-source importable candidate (EXTENSION_SUFFIXES + BYTECODE_ SUFFIXES) exists on disk; None is reserved for genuinely-absent modules. 2. Top-level plugins/__init__.py (CRITICAL). It is in no plugin's provenance, is attacker-writable, and Python runs it before any guarded submodule. The guard now owns `plugins`: it execs an EMPTY package body (search points at the plugins dir), so an overwritten plugins/__init__.py never runs. Also: specs are built with spec_from_file_location so loaded modules get __file__/__path__ (Flask blueprint root paths need it) while the loader still execs the verified in-memory bytes - never re-reading the file. Verified end to end: under PLUGIN_REQUIRE_SIGNED with all 13 bundled plugins stamped, the app boots and loads every plugin through the guard; a tampered plugin file is refused at load. 4 new guard tests (planted .so, sourceless .pyc, absent-module defer, neutralized package root). Prior fixes #3/#4 confirmed still sound by the review. 1065 pass, naming green.
This commit is contained in:
@@ -20,6 +20,7 @@ is absent and imports behave exactly as before.
|
||||
|
||||
import hashlib
|
||||
import importlib.abc
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
@@ -27,6 +28,14 @@ from pathlib import Path
|
||||
|
||||
from . import signing
|
||||
|
||||
# Non-source files the stdlib import system would execute for a module name.
|
||||
# The guard loads ONLY verified .py source, so any of these under a plugin is a
|
||||
# module the guard cannot vouch for and must refuse (a planted .so or a
|
||||
# sourceless .pyc standing in for a deleted, signed .py).
|
||||
_UNVERIFIABLE_SUFFIXES = tuple(
|
||||
importlib.machinery.EXTENSION_SUFFIXES
|
||||
+ importlib.machinery.BYTECODE_SUFFIXES)
|
||||
|
||||
|
||||
class PluginVerificationError(ImportError):
|
||||
"""Raised when a plugins.* module is not covered by a trusted signature."""
|
||||
@@ -51,6 +60,14 @@ class _VerifiedSourceLoader(importlib.abc.Loader):
|
||||
def is_package(self, fullname):
|
||||
return self._is_package
|
||||
|
||||
def get_filename(self, fullname):
|
||||
# gives the loaded module a __file__ so Flask can resolve blueprint
|
||||
# root paths (spec.origin is set from this).
|
||||
return self._filepath
|
||||
|
||||
def get_source(self, fullname):
|
||||
return self._source.decode('utf-8', 'replace')
|
||||
|
||||
|
||||
class PluginImportGuard(importlib.abc.MetaPathFinder):
|
||||
"""Verifies and loads every plugins.<name>.* module from signed bytes."""
|
||||
@@ -81,25 +98,49 @@ class PluginImportGuard(importlib.abc.MetaPathFinder):
|
||||
self._filemaps[name] = filemap
|
||||
return filemap
|
||||
|
||||
def _module_file(self, name, fullname):
|
||||
"""(filepath, is_package, search_locations) for a plugins.<name>.* module,
|
||||
or (None, ...) when it is not a source module we should load."""
|
||||
def _refuse_if_unverifiable_present(self, directory, leaf, name):
|
||||
"""Fail closed if a non-.py importable file (.so / sourceless .pyc) for
|
||||
`leaf` sits in `directory`. Reserving None for genuinely-absent names is
|
||||
what stops the stdlib loading a planted extension/bytecode unverified."""
|
||||
for suffix in _UNVERIFIABLE_SUFFIXES:
|
||||
if (directory / f'{leaf}{suffix}').exists():
|
||||
raise PluginVerificationError(
|
||||
f'plugin {name} module {leaf}{suffix} is not loadable from '
|
||||
f'trusted source; refusing')
|
||||
|
||||
def _source_spec(self, name, fullname, filepath, is_package, search):
|
||||
plugin_dir = self.plugins_dir / name
|
||||
tail = fullname.split('.')[2:] # components after plugins.<name>
|
||||
base = plugin_dir.joinpath(*tail) if tail else plugin_dir
|
||||
if base.is_dir():
|
||||
return base / '__init__.py', True, [str(base)]
|
||||
source = base.with_suffix('.py')
|
||||
if source.exists():
|
||||
return source, False, None
|
||||
return None, False, None
|
||||
relpath = filepath.relative_to(plugin_dir).as_posix()
|
||||
source = filepath.read_bytes()
|
||||
expected = self._filemap(name).get(relpath)
|
||||
if expected is None or hashlib.sha256(source).hexdigest() != expected:
|
||||
raise PluginVerificationError(
|
||||
f'plugin {name} module {relpath} is not covered by a trusted '
|
||||
f'signature')
|
||||
loader = _VerifiedSourceLoader(filepath, source, is_package, search)
|
||||
# spec_from_file_location sets origin -> the module gets __file__/__path__,
|
||||
# which Flask needs for blueprint root paths; the loader still execs the
|
||||
# verified in-memory bytes, never re-reading the file.
|
||||
return importlib.util.spec_from_file_location(
|
||||
fullname, str(filepath), loader=loader,
|
||||
submodule_search_locations=search if is_package else None)
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
# Only our package; the empty top-level `plugins` package loads normally.
|
||||
if fullname != 'plugins' and not fullname.startswith('plugins.'):
|
||||
return None
|
||||
|
||||
# The top-level `plugins` package is in no plugin's provenance, yet
|
||||
# Python imports it before any guarded submodule. Own it: execute an
|
||||
# EMPTY package body so an attacker-overwritten plugins/__init__.py never
|
||||
# runs, and point package search at the plugins dir.
|
||||
if fullname == 'plugins':
|
||||
return None
|
||||
init_path = self.plugins_dir / '__init__.py'
|
||||
loader = _VerifiedSourceLoader(
|
||||
init_path, b'', True, [str(self.plugins_dir)])
|
||||
return importlib.util.spec_from_file_location(
|
||||
'plugins', str(init_path), loader=loader,
|
||||
submodule_search_locations=[str(self.plugins_dir)])
|
||||
|
||||
name = fullname.split('.')[1]
|
||||
plugin_dir = self.plugins_dir / name
|
||||
|
||||
@@ -107,27 +148,28 @@ class PluginImportGuard(importlib.abc.MetaPathFinder):
|
||||
if self.verifier._dev_exempt(plugin_dir):
|
||||
return None
|
||||
|
||||
filemap = self._filemap(name) # verify signature (raises on failure)
|
||||
filepath, is_package, search = self._module_file(name, fullname)
|
||||
if filepath is None or not filepath.exists():
|
||||
# A missing __init__.py (namespace pkg) or non-python target: let the
|
||||
# normal machinery decide. Any .py it would run is covered above.
|
||||
self._filemap(name) # verify the plugin signature (raises on failure)
|
||||
|
||||
tail = fullname.split('.')[2:] # components after plugins.<name>
|
||||
base = plugin_dir.joinpath(*tail) if tail else plugin_dir
|
||||
|
||||
if base.is_dir():
|
||||
init = base / '__init__.py'
|
||||
if init.exists():
|
||||
return self._source_spec(name, fullname, init, True, [str(base)])
|
||||
# Regular package with no __init__.py: refuse a planted
|
||||
# __init__.<ext>; otherwise treat as a namespace package (no body).
|
||||
self._refuse_if_unverifiable_present(base, '__init__', name)
|
||||
return None
|
||||
|
||||
relpath = filepath.relative_to(plugin_dir).as_posix()
|
||||
source = filepath.read_bytes()
|
||||
expected = filemap.get(relpath)
|
||||
if expected is None or hashlib.sha256(source).hexdigest() != expected:
|
||||
raise PluginVerificationError(
|
||||
f'plugin {name} module {relpath} is not covered by a trusted '
|
||||
f'signature')
|
||||
source = base.with_suffix('.py')
|
||||
if source.exists():
|
||||
return self._source_spec(name, fullname, source, False, None)
|
||||
|
||||
loader = _VerifiedSourceLoader(filepath, source, is_package, search)
|
||||
spec = importlib.util.spec_from_loader(
|
||||
fullname, loader, is_package=is_package)
|
||||
if is_package:
|
||||
spec.submodule_search_locations = search
|
||||
return spec
|
||||
# No .py for this name: refuse a planted .so / sourceless .pyc, else defer
|
||||
# (a genuinely-absent module -> normal ModuleNotFoundError).
|
||||
self._refuse_if_unverifiable_present(base.parent, base.name, name)
|
||||
return None
|
||||
|
||||
def verified_source(self, name, relpath):
|
||||
"""Return hash-verified bytes of one plugin file (read once), for callers
|
||||
|
||||
Reference in New Issue
Block a user