Root cause
Injection & unsafe execution introduced
refactor: Implement proper plugin file access security model
How AI contributed: Direct introductionPublished Feb 10, 2026 in
jahlives/openssl_encryptAll 20 findings in this repository
An untrusted plugin class controls plugin.plugin_id. PluginManager instantiated the class and accepted any nonempty string of at most 50 characters, then propagated that value into PluginSecurityContext. For a plugin without READ_FILES, the sandbox intercepted file operations and called _is_safe_path, but _is_safe_path resolved ~/.openssl_encrypt/plugins/<plugin_id> without validating plugin_id and treated every path with that resolved prefix as allowed. Traversal such as '../../.ssh' therefore changes the purported per-plugin config root to a user directory outside ~/.openssl_encrypt/plugins, allowing the sandboxed plugin to read or write paths there despite lacking general file capability.
The sandbox treated a directory derived from the plugin identifier as trusted. Resolving traversal segments before the prefix check could relocate that trusted root into sensitive user directories and make forbidden paths appear allowed.
Root cause
refactor: Implement proper plugin file access security model
Fix
security: Sanitize plugin_id to prevent path traversal (GHSA-8jpj-w975-rwv5)
AI-assisted fix: Claude Code · Tobi
Case facts
@@ -132,6 +132,7 @@ class PluginSandbox: def __init__(self): self.temp_dir = None self.monitor = ResourceMonitor()+ self.current_context = None # Store current plugin context for file access checks def execute_plugin( self,@@ -212,6 +213,9 @@ def _create_sandbox_environment( max_memory_mb: Maximum memory in MB use_process_isolation: If False, skip strict memory limits (threading needs more memory) """+ # Store context for file access validation+ self.current_context = context+ original_cwd = os.getcwd() temp_dir = None memory_limit_set = False@@ -296,12 +300,18 @@ def _restrict_file_operations(self): # Store original function original_open = builtins.open + # Capture context for use in restricted_open+ context = self.current_context+ def restricted_open(file, mode="r", **kwargs): # Only allow access to temp directory and explicitly safe paths abs_path = os.path.abspath(file) - if not self._is_safe_path(abs_path):- raise SandboxViolationError(f"File access denied: {file}")+ # Check if this is a write operation+ is_write = any(c in mode for c in ['w', 'a', '+', 'x'])++ if not self._is_safe_path(abs_path, context, is_write):+ raise SandboxViolationError(f"File access denied: {file} (mode: {mode})") return original_open(file, mode, **kwargs) @@ -372,8 +382,17 @@ def _restore_original_environment(self, saved_state): subprocess.Popen = saved_state["subprocess"] - def _is_safe_path(self, path: str) -> bool:- """Check if file path is safe for plugin access."""+ def _is_safe_path(self, path: str, context: PluginSecurityContext = None, is_write: bool = False) -> bool:+ """Check if file path is safe for plugin access.++ Args:+ path: File path to check+ context: Plugin security context (contains plugin_id)+ is_write: True if this is a write operation++ Returns:+ True if access is allowed, False otherwise+ """ abs_path = os.path.abspath(path) # Allow access to temp directory@@ -384,6 +403,38 @@ def _is_safe_path(self, path: str) -> bool: if abs_path.startswith(os.path.dirname(os.__file__)): return True + # Plugin-specific directory access (if context available)+ if context and context.plugin_id:+ plugin_id = context.plugin_id++ # Plugin config directory: ~/.openssl_encrypt/plugins/<plugin_id>/+ # Allow read/write access+ config_dir = os.path.abspath(+ os.path.expanduser(f"~/.openssl_encrypt/plugins/{plugin_id}")+ )+ if abs_path.startswith(config_dir):+ return True++ # Plugin code directory: openssl_encrypt/plugins/<plugin_id>/+ # Allow read-only access (deny writes)+ # Find the openssl_encrypt package base directory+ try:+ import openssl_encrypt+ package_dir = os.path.dirname(os.path.dirname(openssl_encrypt.__file__))+ plugin_code_dir = os.path.abspath(+ os.path.join(package_dir, "openssl_encrypt", "plugins", plugin_id)+ )+ if abs_path.startswith(plugin_code_dir):+ if is_write:+ # Deny write access to plugin code directory+ logger.warning(+ f"Plugin '{plugin_id}' attempted to write to code directory: {abs_path}"+ )@@ -626,47 +626,26 @@ def _validate_plugin_file(self, file_path: str) -> bool: "compile(", ] - # Check if this is a built-in plugin (shipped with the application)- # Built-in plugins are trusted to access their own config/code directories- is_builtin_plugin = "/openssl_encrypt/plugins/" in file_path.replace("\\", "/")-- # Validate ALL plugins for dangerous patterns (for auditing)+ # Remove 'open(' from dangerous patterns - all plugins need file access+ # The sandbox will enforce that plugins can only access:+ # - Read/write: ~/.openssl_encrypt/plugins/<plugin_id>/+ # - Read-only: openssl_encrypt/plugins/<plugin_id>/+ safe_patterns = ["open("] # Patterns that are allowed by sandbox at runtime+ truly_dangerous_patterns = [p for p in dangerous_patterns if p not in safe_patterns]++ # Validate ALL plugins equally for truly dangerous patterns found_dangerous = False- for pattern in dangerous_patterns:+ for pattern in truly_dangerous_patterns: if pattern in content: found_dangerous = True - if is_builtin_plugin:- # Built-in plugins are allowed to use dangerous patterns- # They're trusted to only access their own directories:- # - ~/.openssl_encrypt/plugins/<plugin_id>/ (config)- # - openssl_encrypt/plugins/<plugin_id>/ (code)- logger.debug(- f"Built-in plugin allowed to use '{pattern}': {file_path}"- )-- # Audit log for tracking (not blocking)- if security_logger:- security_logger.log_event(- "builtin_plugin_dangerous_pattern",- "info",- {- "file_path": file_path,- "dangerous_pattern": pattern,- "action": "allowed_builtin",- },- )- # Don't block built-in plugins- continue-- # Third-party plugin with dangerous pattern if self.strict_security_mode:- # In strict mode, block third-party plugins with dangerous patterns+ # In strict mode, block plugins with dangerous patterns logger.error( f"SECURITY BLOCKED: Plugin contains dangerous pattern '{pattern}': {file_path}" ) logger.error(- "Third-party plugin rejected in strict security mode."+ "Plugin rejected in strict security mode. Dangerous patterns not allowed." ) # Security audit log for blocked plugin@@ -677,7 +656,7 @@ def _validate_plugin_file(self, file_path: str) -> bool: { "file_path": file_path, "dangerous_pattern": pattern,- "reason": "third_party_strict_mode",+ "reason": "strict_security_mode", }, ) @@ -705,9 +684,9 @@ def _validate_plugin_file(self, file_path: str) -> bool: ) # Audit log for dangerous patterns (even if allowed)- if found_dangerous and not is_builtin_plugin:+ if found_dangerous: self._audit_log(- f"Third-party plugin with dangerous patterns {'blocked' if self.strict_security_mode else 'allowed'}: {file_path}"+ f"Plugin with dangerous patterns {'blocked' if self.strict_security_mode else 'allowed'}: {file_path}" ) return True@@ -308,6 +308,42 @@ def __init__(self): self.monitor = ResourceMonitor() self.current_context = None # Store current plugin context for file access checks + @staticmethod+ def _sanitize_plugin_id(plugin_id: str) -> str:+ """Validate and sanitize plugin_id to prevent path traversal.++ Args:+ plugin_id: Plugin identifier to sanitize++ Returns:+ The plugin_id if valid++ Raises:+ SandboxViolationError: If plugin_id contains unsafe characters+ """+ import re++ if not plugin_id:+ raise SandboxViolationError("Plugin ID cannot be empty")++ # Reject path separators, traversal, and null bytes+ if any(c in plugin_id for c in ("/", "\\", "\x00")):+ raise SandboxViolationError(+ f"Plugin ID contains unsafe characters: {plugin_id!r}"+ )+ if ".." in plugin_id:+ raise SandboxViolationError(+ f"Plugin ID contains path traversal: {plugin_id!r}"+ )++ # Only allow alphanumeric, hyphens, underscores+ if not re.match(r"^[a-zA-Z0-9_-]+$", plugin_id):+ raise SandboxViolationError(+ f"Plugin ID contains disallowed characters: {plugin_id!r}"+ )++ return plugin_id+ def execute_plugin( self, plugin: BasePlugin,@@ -335,6 +371,10 @@ def execute_plugin( perform blocking operations. """ try:+ # Sanitize plugin_id to prevent path traversal+ if context and context.plugin_id:+ self._sanitize_plugin_id(context.plugin_id)+ # CRIT-4: Run AST security analysis before execution _validate_plugin_source(plugin) @@ -753,18 +793,15 @@ def _is_safe_path( # Plugin-specific directory access (if context available) if context and context.plugin_id:- plugin_id = context.plugin_id+ # Sanitize plugin_id before using in path construction+ plugin_id = self._sanitize_plugin_id(context.plugin_id) # Plugin config directory: ~/.openssl_encrypt/plugins/<plugin_id>/ # Allow read/write access config_dir = os.path.realpath( os.path.expanduser(f"~/.openssl_encrypt/plugins/{plugin_id}") ) if abs_path.startswith(config_dir):- # Double-check path didn't escape via symlink resolution- if not abs_path.startswith(config_dir):- logger.warning(f"Path traversal attempt detected: {path} -> {abs_path}")- return False return True # Plugin code directory: Use actual file location from contextAI-assisted change fe6a009482d321122545c2d0035c51d42135a4c2a1ec347ed39cb08a905f11ee · Fix a21ef37e25482c84e84808e118d5a5c3ae9bed8b8116a1f8911649822e3f8ed0