mirror of
https://github.com/anthropics/claude-code.git
synced 2026-02-19 04:27:33 -08:00
Compare commits
1 Commits
v2.1.27
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0fdc0676 |
@@ -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', [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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