mirror of
https://github.com/anthropics/claude-code.git
synced 2026-01-30 04:02:03 +00:00
fix(security): Resolve symlinks before checking deny rules (CVE-2025-59829)
This commit fixes a security vulnerability where deny rules could be bypassed by creating symbolic links to restricted files. Changes: - Add symlink resolution in rule_engine.py _extract_field method - Add symlink resolution in security_reminder_hook.py check_patterns - Create new symlink_deny_hook.py for blocking symlinks to system paths - Include Read tool in file event handlers for deny rule checking - Update hooks.json to apply security hooks to Read tool The vulnerability allowed attackers to bypass deny rules like Read(/etc/passwd) by creating a symlink (e.g., ln -s /etc/passwd test.txt) and then reading the symlink instead of the restricted file directly. The fix uses os.path.realpath() to resolve all symlinks to their canonical paths before checking against deny patterns, ensuring that deny rules are enforced regardless of whether the path is accessed directly or via symlink.
This commit is contained in:
@@ -141,6 +141,39 @@ class RuleEngine:
|
|||||||
patterns = matcher.split('|')
|
patterns = matcher.split('|')
|
||||||
return tool_name in patterns
|
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,
|
def _check_condition(self, condition: Condition, tool_name: str,
|
||||||
tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> bool:
|
tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> bool:
|
||||||
"""Check if a single condition matches.
|
"""Check if a single condition matches.
|
||||||
@@ -196,6 +229,10 @@ class RuleEngine:
|
|||||||
if field in tool_input:
|
if field in tool_input:
|
||||||
value = tool_input[field]
|
value = tool_input[field]
|
||||||
if isinstance(value, str):
|
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 value
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
@@ -241,11 +278,18 @@ class RuleEngine:
|
|||||||
elif field == 'old_text' or field == 'old_string':
|
elif field == 'old_text' or field == 'old_string':
|
||||||
return tool_input.get('old_string', '')
|
return tool_input.get('old_string', '')
|
||||||
elif field == 'file_path':
|
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':
|
elif tool_name == 'MultiEdit':
|
||||||
if field == 'file_path':
|
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']:
|
elif field in ['new_text', 'content']:
|
||||||
# Concatenate all edits
|
# Concatenate all edits
|
||||||
edits = tool_input.get('edits', [])
|
edits = tool_input.get('edits', [])
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ def main():
|
|||||||
event = None
|
event = None
|
||||||
if tool_name == 'Bash':
|
if tool_name == 'Bash':
|
||||||
event = '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'
|
event = 'file'
|
||||||
|
|
||||||
# Load rules
|
# Load rules
|
||||||
|
|||||||
@@ -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": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/symlink_deny_hook.py"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": "Edit|Write|MultiEdit|Read"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
@@ -9,7 +18,7 @@
|
|||||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"matcher": "Edit|Write|MultiEdit"
|
"matcher": "Edit|Write|MultiEdit|Read"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,10 +180,46 @@ def save_state(session_id, shown_warnings):
|
|||||||
pass # Fail silently if we can't save state
|
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):
|
def check_patterns(file_path, content):
|
||||||
"""Check if file path or content matches any security patterns."""
|
"""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
|
# Normalize path by removing leading slashes
|
||||||
normalized_path = file_path.lstrip("/")
|
normalized_path = resolved_path.lstrip("/")
|
||||||
|
|
||||||
for pattern in SECURITY_PATTERNS:
|
for pattern in SECURITY_PATTERNS:
|
||||||
# Check path-based patterns
|
# Check path-based patterns
|
||||||
@@ -241,7 +277,7 @@ def main():
|
|||||||
tool_input = input_data.get("tool_input", {})
|
tool_input = input_data.get("tool_input", {})
|
||||||
|
|
||||||
# Check if this is a relevant tool
|
# 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
|
sys.exit(0) # Allow non-file tools to proceed
|
||||||
|
|
||||||
# Extract file path from tool_input
|
# Extract file path from tool_input
|
||||||
|
|||||||
137
plugins/security-guidance/hooks/symlink_deny_hook.py
Normal file
137
plugins/security-guidance/hooks/symlink_deny_hook.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user