1 Commits
Author SHA1 Message Date
Lerking 984458d8f3 Hardened XtendR. /JL 2026-07-03 08:39:45 +02:00
2 changed files with 165 additions and 66 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ if __name__ == "__main__":
setup( setup(
name="XtendR", name="XtendR",
version="0.3.3.1", version="0.4.0",
packages=find_packages(), packages=find_packages(),
install_requires=[], install_requires=[],
author="Jan Lerking", author="Jan Lerking",
+164 -65
View File
@@ -1,20 +1,37 @@
import importlib import importlib.util
import sys import sys
import os import os
import re
import json import json
import stat
import logging
import threading import threading
from xtendr.xtendrbase import XtendRBase from xtendr.xtendrbase import XtendRBase
__version__ = "0.3.3.1" __version__ = "0.4.0"
logger = logging.getLogger("xtendr")
# Plugin (folder) names and module names are restricted to a safe identifier
# pattern. This blocks path traversal (e.g. "../../etc") and stray characters
# that have no business in a plugin name.
_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
_MODULE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class XtendRSystem: class XtendRSystem:
"""Plugin system to manage plugins. """Plugin system to manage plugins.
SECURITY NOTE: plugins are arbitrary Python code that runs with the full
privileges of the host process. Only attach plugins from sources you
trust. This class validates names/paths and isolates module loading, but
it cannot make untrusted plugin code safe to run.
Example: Example:
>>> system = XtendRSystem() >>> system = XtendRSystem()
>>> system.version() >>> system.version()
XtendR v0.1.3 XtendR v0.4.0
>>> system.attach("example_plugin") # Assuming 'example_plugin/example_plugin.json' exists >>> system.attach("example_plugin", lambda: None)
>>> system.run("example_plugin") >>> system.run("example_plugin")
ExamplePlugin is running! ExamplePlugin is running!
>>> system.stop("example_plugin") >>> system.stop("example_plugin")
@@ -22,78 +39,160 @@ class XtendRSystem:
>>> system.detach("example_plugin") >>> system.detach("example_plugin")
Detached plugin 'example_plugin'. Detached plugin 'example_plugin'.
""" """
def __init__(self, pluginpath = "plugins"):
def __init__(self, pluginpath="plugins"):
self.pluginspath = pluginpath self.pluginspath = pluginpath
self.plugins = {} self.plugins = {}
self._lock = threading.RLock()
def version(self) -> str: def version(self) -> str:
return "XtendR v" + __version__ return "XtendR v" + __version__
def attach(self, name: str, callback) -> None: def _validate_name(self, name: str) -> bool:
"""Dynamically load a plugin from its folder.""" if not isinstance(name, str) or not _NAME_RE.match(name):
if name in self.plugins: logger.error("Rejected plugin name %r: must match %s", name, _NAME_RE.pattern)
print(f"Plugin '{name}' is already attached.") return False
return return True
plugin_path = os.path.join(os.getcwd(), self.pluginspath, name) def _check_permissions(self, path: str) -> None:
info_path = os.path.join(plugin_path, name + ".json") """Warn (don't block) if a plugin file is group/world-writable."""
print(plugin_path + "\n" + info_path)
if not os.path.isdir(plugin_path) or not os.path.isfile(info_path):
print(f"Failed to attach plugin '{name}', folder or info file not found.")
return
try: try:
with open(info_path, "r", encoding="utf-8") as f: st = os.stat(path)
plugin_info = json.load(f) if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
module_name = plugin_info.get("module") logger.warning(
class_name = plugin_info.get("class") "Plugin file '%s' is group/world-writable; this is a "
if not module_name or not class_name: "security risk on shared systems.",
print(f"Plugin '{name}' info file is missing 'module' or 'class' key.") path,
return )
except OSError:
sys.path.insert(0, plugin_path) pass
module = importlib.import_module(module_name)
def attach(self, name: str, callback=None) -> None:
"""Dynamically load a plugin from its folder."""
with self._lock:
if name in self.plugins:
logger.info("Plugin '%s' is already attached.", name)
return
if not self._validate_name(name):
return
plugin_path = os.path.join(os.getcwd(), self.pluginspath, name)
info_path = os.path.join(plugin_path, name + ".json")
# Defense in depth: even with a validated name, make sure the
# resolved path is actually inside the plugins directory.
plugins_root = os.path.realpath(os.path.join(os.getcwd(), self.pluginspath))
if os.path.commonpath([plugins_root, os.path.realpath(plugin_path)]) != plugins_root:
logger.error("Refusing to attach '%s': resolves outside plugins directory.", name)
return
if not os.path.isdir(plugin_path) or not os.path.isfile(info_path):
logger.error("Failed to attach plugin '%s': folder or info file not found.", name)
return
self._check_permissions(info_path)
try:
with open(info_path, "r", encoding="utf-8") as f:
plugin_info = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.error("Failed to read info file for plugin '%s': %s", name, e)
return
module_name = plugin_info.get("module")
class_name = plugin_info.get("class")
if not module_name or not class_name:
logger.error("Plugin '%s' info file is missing 'module' or 'class' key.", name)
return
if not _MODULE_RE.match(module_name) or not _MODULE_RE.match(class_name):
logger.error("Plugin '%s' has an invalid module/class identifier.", name)
return
module_file = os.path.join(plugin_path, module_name + ".py")
if not os.path.isfile(module_file):
logger.error("Plugin '%s' module file '%s' not found.", name, module_file)
return
self._check_permissions(module_file)
# Load the module directly from its file path instead of
# mutating sys.path. This prevents a plugin from shadowing
# stdlib or third-party modules for the rest of the process.
qualified_name = f"xtendr_plugin_{name}_{module_name}"
try:
spec = importlib.util.spec_from_file_location(qualified_name, module_file)
if spec is None or spec.loader is None:
raise ImportError(f"Could not create import spec for '{module_file}'")
module = importlib.util.module_from_spec(spec)
sys.modules[qualified_name] = module
spec.loader.exec_module(module)
plugin_class = getattr(module, class_name) plugin_class = getattr(module, class_name)
instance = plugin_class() instance = plugin_class()
if not isinstance(instance, XtendRBase): if not isinstance(instance, XtendRBase):
print(f"Plugin '{name}' does not inherit from PluginBase.") logger.error("Plugin '%s' does not inherit from XtendRBase.", name)
sys.modules.pop(qualified_name, None)
return return
self.plugins[name] = { except Exception as e: # noqa: BLE001 - plugin code is untrusted, isolate all failures
'instance': instance, logger.error("Failed to attach plugin '%s': %s", name, e, exc_info=True)
'running': False, sys.modules.pop(qualified_name, None)
'info': plugin_info, return
'autorun': False
} self.plugins[name] = {
print(f"Attached plugin '{name}'.") "instance": instance,
print(f"Running pre-load on '{name}'.") "running": False,
thread = threading.Thread(target=self.plugins[name]['instance'].pre_load, args=(callback,)) "info": plugin_info,
thread.start() "autorun": False,
except (ModuleNotFoundError, json.JSONDecodeError, AttributeError) as e: "module_key": qualified_name,
print(f"Failed to attach plugin '{name}': {e}") }
logger.info("Attached plugin '%s'.", name)
logger.info("Running pre-load on '%s'.", name)
def _pre_load_worker():
try:
instance.pre_load(callback)
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during pre_load.", name, exc_info=True)
thread = threading.Thread(target=_pre_load_worker, daemon=True)
thread.start()
def run(self, name: str, *args, **kwargs): def run(self, name: str, *args, **kwargs):
"""Run the plugin's 'run' method if available.""" """Run the plugin's 'run' method if available."""
if name in self.plugins: with self._lock:
self.plugins[name]['running'] = True entry = self.plugins.get(name)
return self.plugins[name]['instance'].run(*args, **kwargs) if entry is None:
print(f"Plugin '{name}' not found or has no 'run' method.") logger.error("Plugin '%s' not found or has no 'run' method.", name)
return
entry["running"] = True
try:
return entry["instance"].run(*args, **kwargs)
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during run.", name, exc_info=True)
entry["running"] = False
def stop(self, name: str) -> None: def stop(self, name: str) -> None:
"""Stop the plugin if it's running.""" """Stop the plugin if it's running."""
if name in self.plugins and self.plugins[name]['running']: with self._lock:
self.plugins[name]['running'] = False entry = self.plugins.get(name)
self.plugins[name]['instance'].stop() if entry is None or not entry["running"]:
else: logger.info("Plugin '%s' is not running.", name)
print(f"Plugin '{name}' is not running.") return
entry["running"] = False
try:
entry["instance"].stop()
except Exception: # noqa: BLE001
logger.error("Plugin '%s' raised during stop.", name, exc_info=True)
def detach(self, name: str) -> None: def detach(self, name: str) -> None:
"""Unload a plugin.""" """Unload a plugin."""
if name in self.plugins: with self._lock:
del self.plugins[name] entry = self.plugins.pop(name, None)
sys.modules.pop(name, None) if entry is None:
print(f"Detached plugin '{name}'.") logger.info("Plugin '%s' is not attached.", name)
else: return
print(f"Plugin '{name}' is not attached.") sys.modules.pop(entry["module_key"], None)
logger.info("Detached plugin '%s'.", name)