Skip to content

Commit d7fcaf8

Browse files
Merge pull request #740 from MervinPraison/claude/issue-737-20250707_104724
fix: critical security vulnerabilities in tools
2 parents 48fae95 + abe4d6c commit d7fcaf8

File tree

5 files changed

+246
-41
lines changed

5 files changed

+246
-41
lines changed

src/praisonai-agents/praisonaiagents/tools/duckdb_tools.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
1414
from importlib import util
1515
import json
16+
import re
1617

1718
if TYPE_CHECKING:
1819
import duckdb
@@ -29,6 +30,25 @@ def __init__(self, database: str = ':memory:'):
2930
"""
3031
self.database = database
3132
self._conn = None
33+
34+
def _validate_identifier(self, identifier: str) -> str:
35+
"""Validate and quote a SQL identifier to prevent injection.
36+
37+
Args:
38+
identifier: Table or column name to validate
39+
40+
Returns:
41+
Quoted identifier safe for SQL
42+
43+
Raises:
44+
ValueError: If identifier contains invalid characters
45+
"""
46+
# Only allow alphanumeric characters, underscores, and dots
47+
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$', identifier):
48+
raise ValueError(f"Invalid identifier: {identifier}")
49+
50+
# Quote the identifier to handle reserved words
51+
return f'"{identifier}"'
3252

3353
def _get_duckdb(self) -> Optional['duckdb']:
3454
"""Get duckdb module, installing if needed"""
@@ -125,39 +145,50 @@ def load_csv(
125145
if conn is None:
126146
return False
127147

128-
# Check if table exists
129-
exists = conn.execute(f"""
148+
# Check if table exists using parameterized query
149+
exists = conn.execute("""
130150
SELECT name FROM sqlite_master
131-
WHERE type='table' AND name='{table_name}'
132-
""").fetchone() is not None
151+
WHERE type='table' AND name=?
152+
""", [table_name]).fetchone() is not None
133153

134154
if exists:
135155
if if_exists == 'fail':
136156
raise ValueError(f"Table {table_name} already exists")
137157
elif if_exists == 'replace':
138-
conn.execute(f"DROP TABLE IF EXISTS {table_name}")
158+
# Validate and quote table name to prevent injection
159+
safe_table = self._validate_identifier(table_name)
160+
conn.execute(f"DROP TABLE IF EXISTS {safe_table}")
139161
elif if_exists != 'append':
140162
raise ValueError("if_exists must be 'fail', 'replace', or 'append'")
141163

142164
# Create table if needed
143165
if not exists or if_exists == 'replace':
166+
safe_table = self._validate_identifier(table_name)
144167
if schema:
145-
# Create table with schema
146-
columns = ', '.join(f"{k} {v}" for k, v in schema.items())
147-
conn.execute(f"CREATE TABLE {table_name} ({columns})")
168+
# Create table with schema - validate column names
169+
column_defs = []
170+
for col_name, col_type in schema.items():
171+
safe_col = self._validate_identifier(col_name)
172+
# Validate column type to prevent injection
173+
if not re.match(r'^[A-Z][A-Z0-9_]*(\([0-9,]+\))?$', col_type.upper()):
174+
raise ValueError(f"Invalid column type: {col_type}")
175+
column_defs.append(f"{safe_col} {col_type}")
176+
columns = ', '.join(column_defs)
177+
conn.execute(f"CREATE TABLE {safe_table} ({columns})")
148178
else:
149-
# Infer schema from CSV
179+
# Infer schema from CSV - use parameterized query for filepath
150180
conn.execute(f"""
151-
CREATE TABLE {table_name} AS
152-
SELECT * FROM read_csv_auto('{filepath}')
181+
CREATE TABLE {safe_table} AS
182+
SELECT * FROM read_csv_auto(?)
153183
WHERE 1=0
154-
""")
184+
""", [filepath])
155185

156-
# Load data
186+
# Load data - use validated table name and parameterized filepath
187+
safe_table = self._validate_identifier(table_name)
157188
conn.execute(f"""
158-
INSERT INTO {table_name}
159-
SELECT * FROM read_csv_auto('{filepath}')
160-
""")
189+
INSERT INTO {safe_table}
190+
SELECT * FROM read_csv_auto(?)
191+
""", [filepath])
161192

162193
return True
163194

src/praisonai-agents/praisonaiagents/tools/file_tools.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@
2121
class FileTools:
2222
"""Tools for file operations including read, write, list, and information."""
2323

24+
@staticmethod
25+
def _validate_path(filepath: str) -> str:
26+
"""
27+
Validate and normalize a file path to prevent path traversal attacks.
28+
29+
Args:
30+
filepath: Path to validate
31+
32+
Returns:
33+
str: Normalized absolute path
34+
35+
Raises:
36+
ValueError: If path contains suspicious patterns
37+
"""
38+
# Normalize the path
39+
normalized = os.path.normpath(filepath)
40+
absolute = os.path.abspath(normalized)
41+
42+
# Check for suspicious patterns
43+
if '..' in filepath or filepath.startswith('~'):
44+
raise ValueError(f"Suspicious path pattern detected: {filepath}")
45+
46+
# Additional check: ensure the resolved path doesn't escape expected boundaries
47+
# This is a basic check - in production, you'd want to define allowed directories
48+
return absolute
49+
2450
@staticmethod
2551
def read_file(filepath: str, encoding: str = 'utf-8') -> str:
2652
"""
@@ -34,7 +60,9 @@ def read_file(filepath: str, encoding: str = 'utf-8') -> str:
3460
str: Content of the file
3561
"""
3662
try:
37-
with open(filepath, 'r', encoding=encoding) as f:
63+
# Validate path to prevent traversal attacks
64+
safe_path = FileTools._validate_path(filepath)
65+
with open(safe_path, 'r', encoding=encoding) as f:
3866
return f.read()
3967
except Exception as e:
4068
error_msg = f"Error reading file {filepath}: {str(e)}"
@@ -56,9 +84,11 @@ def write_file(filepath: str, content: str, encoding: str = 'utf-8') -> bool:
5684
bool: True if successful, False otherwise
5785
"""
5886
try:
87+
# Validate path to prevent traversal attacks
88+
safe_path = FileTools._validate_path(filepath)
5989
# Create directory if it doesn't exist
60-
os.makedirs(os.path.dirname(filepath), exist_ok=True)
61-
with open(filepath, 'w', encoding=encoding) as f:
90+
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
91+
with open(safe_path, 'w', encoding=encoding) as f:
6292
f.write(content)
6393
return True
6494
except Exception as e:
@@ -79,7 +109,9 @@ def list_files(directory: str, pattern: Optional[str] = None) -> List[Dict[str,
79109
List[Dict]: List of file information dictionaries
80110
"""
81111
try:
82-
path = Path(directory)
112+
# Validate directory path
113+
safe_dir = FileTools._validate_path(directory)
114+
path = Path(safe_dir)
83115
if pattern:
84116
files = path.glob(pattern)
85117
else:
@@ -114,7 +146,9 @@ def get_file_info(filepath: str) -> Dict[str, Union[str, int]]:
114146
Dict: File information including size, dates, etc.
115147
"""
116148
try:
117-
path = Path(filepath)
149+
# Validate file path
150+
safe_path = FileTools._validate_path(filepath)
151+
path = Path(safe_path)
118152
if not path.exists():
119153
return {'error': f'File not found: {filepath}'}
120154

@@ -149,9 +183,12 @@ def copy_file(src: str, dst: str) -> bool:
149183
bool: True if successful, False otherwise
150184
"""
151185
try:
186+
# Validate paths to prevent traversal attacks
187+
safe_src = FileTools._validate_path(src)
188+
safe_dst = FileTools._validate_path(dst)
152189
# Create destination directory if it doesn't exist
153-
os.makedirs(os.path.dirname(dst), exist_ok=True)
154-
shutil.copy2(src, dst)
190+
os.makedirs(os.path.dirname(safe_dst), exist_ok=True)
191+
shutil.copy2(safe_src, safe_dst)
155192
return True
156193
except Exception as e:
157194
error_msg = f"Error copying file from {src} to {dst}: {str(e)}"
@@ -172,9 +209,12 @@ def move_file(src: str, dst: str) -> bool:
172209
bool: True if successful, False otherwise
173210
"""
174211
try:
212+
# Validate paths to prevent traversal attacks
213+
safe_src = FileTools._validate_path(src)
214+
safe_dst = FileTools._validate_path(dst)
175215
# Create destination directory if it doesn't exist
176-
os.makedirs(os.path.dirname(dst), exist_ok=True)
177-
shutil.move(src, dst)
216+
os.makedirs(os.path.dirname(safe_dst), exist_ok=True)
217+
shutil.move(safe_src, safe_dst)
178218
return True
179219
except Exception as e:
180220
error_msg = f"Error moving file from {src} to {dst}: {str(e)}"
@@ -194,7 +234,9 @@ def delete_file(filepath: str) -> bool:
194234
bool: True if successful, False otherwise
195235
"""
196236
try:
197-
os.remove(filepath)
237+
# Validate path to prevent traversal attacks
238+
safe_path = FileTools._validate_path(filepath)
239+
os.remove(safe_path)
198240
return True
199241
except Exception as e:
200242
error_msg = f"Error deleting file {filepath}: {str(e)}"

src/praisonai-agents/praisonaiagents/tools/python_tools.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,100 @@ def execute_code(
4646
timeout: int = 30,
4747
max_output_size: int = 10000
4848
) -> Dict[str, Any]:
49-
"""Execute Python code safely."""
49+
"""Execute Python code safely with restricted builtins."""
5050
try:
51-
# Set up execution environment
51+
# Create safe builtins - restricted set of functions
52+
safe_builtins = {
53+
# Basic functions
54+
'print': print,
55+
'len': len,
56+
'range': range,
57+
'enumerate': enumerate,
58+
'zip': zip,
59+
'map': map,
60+
'filter': filter,
61+
'sum': sum,
62+
'min': min,
63+
'max': max,
64+
'abs': abs,
65+
'round': round,
66+
'sorted': sorted,
67+
'reversed': reversed,
68+
'any': any,
69+
'all': all,
70+
# Type constructors
71+
'int': int,
72+
'float': float,
73+
'str': str,
74+
'bool': bool,
75+
'list': list,
76+
'tuple': tuple,
77+
'dict': dict,
78+
'set': set,
79+
# Math functions
80+
'pow': pow,
81+
'divmod': divmod,
82+
# Exceptions
83+
'Exception': Exception,
84+
'ValueError': ValueError,
85+
'TypeError': TypeError,
86+
'KeyError': KeyError,
87+
'IndexError': IndexError,
88+
'RuntimeError': RuntimeError,
89+
# Other safe functions
90+
'isinstance': isinstance,
91+
'type': type,
92+
'hasattr': hasattr,
93+
'getattr': getattr,
94+
'setattr': setattr,
95+
'dir': dir,
96+
'help': help,
97+
# Disable dangerous functions
98+
'__import__': None,
99+
'eval': None,
100+
'exec': None,
101+
'compile': None,
102+
'open': None,
103+
'input': None,
104+
'globals': None,
105+
'locals': None,
106+
'vars': None,
107+
}
108+
109+
# Set up execution environment with safe builtins
52110
if globals_dict is None:
53-
globals_dict = {'__builtins__': __builtins__}
111+
globals_dict = {'__builtins__': safe_builtins}
112+
else:
113+
# Override builtins in provided globals
114+
globals_dict['__builtins__'] = safe_builtins
115+
54116
if locals_dict is None:
55117
locals_dict = {}
56118

119+
# Security check: validate code doesn't contain dangerous patterns
120+
dangerous_patterns = [
121+
'__import__', 'import ', 'from ', 'exec', 'eval',
122+
'compile', 'open(', 'file(', 'input(', 'raw_input',
123+
'__subclasses__', '__bases__', '__globals__', '__code__',
124+
'__class__', 'globals(', 'locals(', 'vars('
125+
]
126+
127+
code_lower = code.lower()
128+
for pattern in dangerous_patterns:
129+
if pattern.lower() in code_lower:
130+
return {
131+
'result': None,
132+
'stdout': '',
133+
'stderr': f'Security Error: Code contains restricted pattern: {pattern}',
134+
'success': False
135+
}
136+
57137
# Capture output
58138
stdout_buffer = io.StringIO()
59139
stderr_buffer = io.StringIO()
60140

61141
try:
62-
# Compile code
142+
# Compile code with restricted mode
63143
compiled_code = compile(code, '<string>', 'exec')
64144

65145
# Execute with output capture

src/praisonai-agents/praisonaiagents/tools/shell_tools.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ def execute_command(
4040
command: str,
4141
cwd: Optional[str] = None,
4242
timeout: int = 30,
43-
shell: bool = False,
4443
env: Optional[Dict[str, str]] = None,
4544
max_output_size: int = 10000
4645
) -> Dict[str, Union[str, int, bool]]:
@@ -50,22 +49,20 @@ def execute_command(
5049
command: Command to execute
5150
cwd: Working directory
5251
timeout: Maximum execution time in seconds
53-
shell: Whether to run command in shell
5452
env: Environment variables
5553
max_output_size: Maximum output size in bytes
5654
5755
Returns:
5856
Dictionary with execution results
5957
"""
6058
try:
61-
# Split command if not using shell
62-
if not shell:
63-
# Use shlex.split with appropriate posix flag
64-
if platform.system() == 'Windows':
65-
# Use shlex with posix=False for Windows to handle quotes properly
66-
command = shlex.split(command, posix=False)
67-
else:
68-
command = shlex.split(command)
59+
# Always split command for safety (no shell execution)
60+
# Use shlex.split with appropriate posix flag
61+
if platform.system() == 'Windows':
62+
# Use shlex with posix=False for Windows to handle quotes properly
63+
command = shlex.split(command, posix=False)
64+
else:
65+
command = shlex.split(command)
6966

7067
# Set up process environment
7168
process_env = os.environ.copy()
@@ -79,7 +76,7 @@ def execute_command(
7976
stdout=subprocess.PIPE,
8077
stderr=subprocess.PIPE,
8178
cwd=cwd,
82-
shell=shell,
79+
shell=False, # Always use shell=False for security
8380
env=process_env,
8481
text=True
8582
)

0 commit comments

Comments
 (0)