diff --git a/plugins/hookify/core/rule_engine.py b/plugins/hookify/core/rule_engine.py index 8244c005..17cbecec 100644 --- a/plugins/hookify/core/rule_engine.py +++ b/plugins/hookify/core/rule_engine.py @@ -141,6 +141,39 @@ class RuleEngine: patterns = matcher.split('|') return tool_name in patterns + def _resolve_symlink_path(self, file_path: str) -> str: + """Resolve symlinks in file path to get canonical path. + + Security fix for CVE-2025-59829: Deny rules could be bypassed by creating + a symlink to a restricted file. This method resolves the symlink to its + target path so that deny rules are checked against the actual file. + + Args: + file_path: The file path that may contain symlinks + + Returns: + The canonical path with symlinks resolved, or original path if + resolution fails (e.g., file doesn't exist yet) + """ + import os + + if not file_path: + return file_path + + try: + # Expand user home directory first + expanded_path = os.path.expanduser(file_path) + + # Use realpath to resolve all symlinks and get canonical path + # This handles nested symlinks and relative path components + resolved = os.path.realpath(expanded_path) + + return resolved + except (OSError, ValueError): + # If resolution fails (e.g., permission denied, invalid path), + # return the original path to avoid blocking legitimate operations + return file_path + def _check_condition(self, condition: Condition, tool_name: str, tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> bool: """Check if a single condition matches. @@ -196,6 +229,10 @@ class RuleEngine: if field in tool_input: value = tool_input[field] if isinstance(value, str): + # Security fix: resolve symlinks for file_path fields to prevent bypass + # CVE-2025-59829: Deny rules could be bypassed via symlinks + if field == 'file_path': + value = self._resolve_symlink_path(value) return value return str(value) @@ -241,11 +278,18 @@ class RuleEngine: elif field == 'old_text' or field == 'old_string': return tool_input.get('old_string', '') elif field == 'file_path': - return tool_input.get('file_path', '') + # Security fix: resolve symlinks to prevent deny rule bypass + return self._resolve_symlink_path(tool_input.get('file_path', '')) + + elif tool_name == 'Read': + # Security fix for CVE-2025-59829: Read tool symlink bypass + if field == 'file_path': + return self._resolve_symlink_path(tool_input.get('file_path', '')) elif tool_name == 'MultiEdit': if field == 'file_path': - return tool_input.get('file_path', '') + # Security fix: resolve symlinks to prevent deny rule bypass + return self._resolve_symlink_path(tool_input.get('file_path', '')) elif field in ['new_text', 'content']: # Concatenate all edits edits = tool_input.get('edits', []) diff --git a/plugins/hookify/hooks/pretooluse.py b/plugins/hookify/hooks/pretooluse.py index f265c277..24dc139d 100755 --- a/plugins/hookify/hooks/pretooluse.py +++ b/plugins/hookify/hooks/pretooluse.py @@ -45,7 +45,9 @@ def main(): event = None if tool_name == 'Bash': event = 'bash' - elif tool_name in ['Edit', 'Write', 'MultiEdit']: + elif tool_name in ['Edit', 'Write', 'MultiEdit', 'Read']: + # Include Read tool in file events to check symlink bypass + # Security fix for CVE-2025-59829 event = 'file' # Load rules diff --git a/plugins/security-guidance/hooks/hooks.json b/plugins/security-guidance/hooks/hooks.json index 98df9bd2..ff81c26d 100644 --- a/plugins/security-guidance/hooks/hooks.json +++ b/plugins/security-guidance/hooks/hooks.json @@ -1,7 +1,16 @@ { - "description": "Security reminder hook that warns about potential security issues when editing files", + "description": "Security hooks for file access validation and security pattern warnings", "hooks": { "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/symlink_deny_hook.py" + } + ], + "matcher": "Edit|Write|MultiEdit|Read" + }, { "hooks": [ { @@ -9,7 +18,7 @@ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py" } ], - "matcher": "Edit|Write|MultiEdit" + "matcher": "Edit|Write|MultiEdit|Read" } ] } diff --git a/plugins/security-guidance/hooks/security_reminder_hook.py b/plugins/security-guidance/hooks/security_reminder_hook.py index 37a8b578..217d6ad3 100755 --- a/plugins/security-guidance/hooks/security_reminder_hook.py +++ b/plugins/security-guidance/hooks/security_reminder_hook.py @@ -180,10 +180,46 @@ def save_state(session_id, shown_warnings): pass # Fail silently if we can't save state +def resolve_symlink_path(file_path): + """Resolve symlinks in file path to get canonical path. + + Security fix for CVE-2025-59829: Deny rules could be bypassed by creating + a symlink to a restricted file. This method resolves the symlink to its + target path so that security patterns are checked against the actual file. + + Args: + file_path: The file path that may contain symlinks + + Returns: + The canonical path with symlinks resolved, or original path if + resolution fails (e.g., file doesn't exist yet) + """ + if not file_path: + return file_path + + try: + # Expand user home directory first + expanded_path = os.path.expanduser(file_path) + + # Use realpath to resolve all symlinks and get canonical path + # This handles nested symlinks and relative path components + resolved = os.path.realpath(expanded_path) + + return resolved + except (OSError, ValueError): + # If resolution fails (e.g., permission denied, invalid path), + # return the original path to avoid blocking legitimate operations + return file_path + + def check_patterns(file_path, content): """Check if file path or content matches any security patterns.""" + # Security fix: resolve symlinks before checking patterns + # CVE-2025-59829: Security patterns could be bypassed via symlinks + resolved_path = resolve_symlink_path(file_path) + # Normalize path by removing leading slashes - normalized_path = file_path.lstrip("/") + normalized_path = resolved_path.lstrip("/") for pattern in SECURITY_PATTERNS: # Check path-based patterns @@ -241,7 +277,7 @@ def main(): tool_input = input_data.get("tool_input", {}) # Check if this is a relevant tool - if tool_name not in ["Edit", "Write", "MultiEdit"]: + if tool_name not in ["Edit", "Write", "MultiEdit", "Read"]: sys.exit(0) # Allow non-file tools to proceed # Extract file path from tool_input diff --git a/plugins/security-guidance/hooks/symlink_deny_hook.py b/plugins/security-guidance/hooks/symlink_deny_hook.py new file mode 100644 index 00000000..a1843ce2 --- /dev/null +++ b/plugins/security-guidance/hooks/symlink_deny_hook.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Symlink Deny Hook for Claude Code +Security fix for CVE-2025-59829: Deny rules could be bypassed via symlinks. + +This hook resolves symlinks before checking file paths against deny patterns, +preventing attackers from using symlinks to access restricted files. +""" + +import json +import os +import re +import sys +from fnmatch import fnmatch + + +# System directories that should be blocked by default +# These match common deny rule patterns +BLOCKED_PATHS = [ + "/etc/**", + "/etc/passwd", + "/etc/shadow", + "/etc/sudoers", + "/etc/ssh/**", + "/etc/ssl/**", + "/root/**", + "/var/log/**", + "/proc/**", + "/sys/**", + "/boot/**", +] + + +def resolve_symlink_path(file_path: str) -> str: + """Resolve symlinks in file path to get canonical path. + + Args: + file_path: The file path that may contain symlinks + + Returns: + The canonical path with symlinks resolved, or original path if + resolution fails (e.g., file doesn't exist) + """ + if not file_path: + return file_path + + try: + # Expand user home directory first + expanded_path = os.path.expanduser(file_path) + + # Use realpath to resolve all symlinks and get canonical path + resolved = os.path.realpath(expanded_path) + + return resolved + except (OSError, ValueError): + return file_path + + +def is_path_blocked(resolved_path: str, original_path: str) -> tuple: + """Check if the resolved path matches any blocked patterns. + + Only blocks if: + 1. The path was a symlink (resolved != original) + 2. The resolved path matches a blocked pattern + + Args: + resolved_path: The canonical path after symlink resolution + original_path: The original path before resolution + + Returns: + Tuple of (is_blocked: bool, reason: str) + """ + # Only apply symlink protection if path was actually a symlink + original_real = os.path.realpath(os.path.expanduser(original_path)) + if original_real == resolved_path: + # Check if original was a symlink + expanded_original = os.path.expanduser(original_path) + if not os.path.islink(expanded_original): + # Not a symlink, allow normal deny rule checking to handle this + return False, "" + + # Check if resolved path matches any blocked patterns + for pattern in BLOCKED_PATHS: + if pattern.endswith("/**"): + # Directory wildcard pattern + base_dir = pattern[:-3] + if resolved_path.startswith(base_dir + "/") or resolved_path == base_dir: + return True, f"Symlink bypass blocked: '{original_path}' resolves to '{resolved_path}' which matches blocked pattern '{pattern}'" + elif fnmatch(resolved_path, pattern): + return True, f"Symlink bypass blocked: '{original_path}' resolves to '{resolved_path}' which matches blocked pattern '{pattern}'" + + return False, "" + + +def main(): + """Main hook function.""" + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) # Allow on parse error + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + # Only check file-related tools + if tool_name not in ["Read", "Edit", "Write", "MultiEdit"]: + sys.exit(0) + + # Extract file path + file_path = tool_input.get("file_path", "") + if not file_path: + sys.exit(0) + + # Resolve symlinks + resolved_path = resolve_symlink_path(file_path) + + # Check if blocked + is_blocked, reason = is_path_blocked(resolved_path, file_path) + + if is_blocked: + # Output denial response + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny" + }, + "systemMessage": f"Security: {reason}" + } + print(json.dumps(response)) + sys.exit(0) + + # Allow the operation + sys.exit(0) + + +if __name__ == "__main__": + main()