Compare commits

...
3 Commits
Author SHA1 Message Date
MervinPraison 85ae215025 fix(security): complete PR #1121 security fixes
- unified_server.py: Replace unsafe eval() with AST-safe math evaluation
  - Whitelist allowed characters (0-9, +, -, *, /, ., (, ), space)
  - Validate AST nodes before execution
  - Execute with empty __builtins__ to block dangerous functions

- api.py: Fix XSS vulnerability in markdown output
  - Use bleach to sanitize HTML output (not input)
  - Whitelist safe HTML tags and protocols
  - Block javascript:, data:, vbscript: URLs
  - Fallback to escape() if bleach unavailable
2026-03-30 13:56:54 +01:00
MervinPraison 056e4f5baa chore(.gitignore): update ignored files for agents
Added '.sfdx' to the .gitignore file and ensured '.agent' is also ignored.
2026-03-30 10:01:11 +01:00
Claude 8f06d18e5a fix(security): address multiple vulnerabilities from security audit (#1120)
- Fix eval() with always-true guard in unified_server.py (remove `or True`)
- Replace eval() with json.loads() for DB metadata in singlestore_vector.py
- Set ruamel.yaml to safe mode in applier.py
- Remove hardcoded MongoDB credentials, use env vars in example
- Sanitize markdown output with markupsafe to prevent XSS in api.py
- Add SSRF protection (private IP blocking) in readers.py and file_tools.py
- Add identifier sanitization and parameterized queries in clickhouse.py
- Add value escaping and identifier validation in lancedb.py and milvus.py
- Validate numeric params in surrealdb_vector.py and surrealdb.py
- Replace eval() with ast-based safe evaluation in workflow condition handlers
- Use AST-validated math expressions in MCP server examples

https://claude.ai/code/session_01KBmgWznPEYCEkc3Uy6A3r1
2026-03-21 13:27:13 +00:00
17 changed files with 211 additions and 57 deletions

No files matched your search

+11 -1
View File
@@ -23,12 +23,22 @@ def main():
@register_tool("custom.calculate")
def calculate(expression: str) -> str:
"""Safely evaluate a mathematical expression."""
import ast
import operator
try:
# Only allow safe math operations
allowed = set("0123456789+-*/.(). ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression) # Safe due to character filtering
# Use ast.parse for safe evaluation of math expressions
tree = ast.parse(expression, mode='eval')
# Only allow numeric literals and basic math operators
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
+11 -3
View File
@@ -44,12 +44,20 @@ def main():
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
import ast
try:
# Safe eval for basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
return str(eval(expression))
return "Error: Invalid expression"
if not all(c in allowed for c in expression):
return "Error: Invalid expression"
# Use ast.parse to validate expression safety
tree = ast.parse(expression, mode='eval')
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
return str(eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}}))
except Exception as e:
return f"Error: {e}"
+22 -1
View File
@@ -135,9 +135,30 @@ def main():
return {"error": str(e)}
# MCP endpoints
def safe_calculate(expression: str) -> str:
"""Safely evaluate a mathematical expression using AST."""
import ast
try:
# Only allow safe math characters
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
# Use ast.parse for safe evaluation
tree = ast.parse(expression, mode='eval')
# Only allow numeric literals and basic math operators
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
return str(result)
except Exception as e:
return f"Error: {e}"
mcp_tools = {
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
"calculate": {"description": "Calculate expression", "func": safe_calculate},
}
@app.get("/mcp/tools")
+2 -1
View File
@@ -93,4 +93,5 @@ chroma_db/
.praisonai
.windsurf
.agent
.agent
.sfdx
+4 -2
View File
@@ -1,3 +1,5 @@
import os
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.memory import Memory
from praisonaiagents.agent import ContextAgent
@@ -10,7 +12,7 @@ context_output = context_agent.start("https://github.com/MervinPraison/PraisonAI
mongodb_memory_config = {
"provider": "mongodb",
"config": {
"connection_string": "mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2",
"connection_string": os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"),
"database": "praisonai_memory",
"use_vector_search": True,
"max_pool_size": 50,
@@ -49,7 +51,7 @@ print("\n=== MEMORY VALIDATION ===")
try:
# Check MongoDB connection and collections
client = pymongo.MongoClient("mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2")
client = pymongo.MongoClient(os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"))
db = client["praisonai_memory"]
collections = db.list_collection_names()
print(f"Available collections: {collections}")
@@ -286,12 +286,26 @@ class FileTools:
}
try:
# Validate URL to prevent SSRF
from urllib.parse import urlparse
parsed_url = urlparse(url)
if parsed_url.scheme not in ("http", "https"):
return {"success": False, "path": "", "size": 0, "error": f"Unsupported URL scheme: {parsed_url.scheme}"}
import ipaddress, socket
try:
hostname = parsed_url.hostname or ""
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
return {"success": False, "path": "", "size": 0, "error": f"URL resolves to private/reserved IP: {ip}"}
except (socket.gaierror, ValueError):
pass # Let the request handle DNS resolution errors
# Validate destination path
safe_path = FileTools._validate_path(destination)
# Create directory if needed
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
# Download file
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as response:
response.raise_for_status()
+20 -1
View File
@@ -1,4 +1,5 @@
from flask import Flask
from markupsafe import escape
import markdown
app = Flask(__name__)
@@ -11,7 +12,25 @@ def basic():
@app.route('/')
def home():
output = basic()
html_output = markdown.markdown(output)
# Convert markdown to HTML, then sanitize the output to prevent XSS
html_output = markdown.markdown(str(output))
# Use bleach to sanitize HTML output (allows safe tags only)
try:
import bleach
allowed_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li',
'strong', 'em', 'code', 'pre', 'blockquote', 'a', 'br', 'hr']
allowed_attrs = {'a': ['href', 'title']}
# Strip javascript: URLs from href attributes
def filter_href(tag, name, value):
if name == 'href':
if value.lower().startswith(('javascript:', 'data:', 'vbscript:')):
return False
return True
html_output = bleach.clean(html_output, tags=allowed_tags, attributes=allowed_attrs,
strip=True, protocols=['http', 'https', 'mailto'])
except ImportError:
# Fallback: escape the entire output if bleach not available
html_output = str(escape(html_output))
return f'<html><body>{html_output}</body></html>'
if __name__ == "__main__":
@@ -271,6 +271,23 @@ class URLReader:
return []
try:
# Validate URL to prevent SSRF
from urllib.parse import urlparse
parsed = urlparse(source)
if parsed.scheme not in ("http", "https"):
logger.error(f"Unsupported URL scheme: {parsed.scheme}")
return []
import ipaddress
try:
import socket
hostname = parsed.hostname or ""
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
logger.error(f"URL resolves to private/reserved IP: {ip}")
return []
except (socket.gaierror, ValueError):
pass # Let the request handle DNS resolution errors
response = httpx.get(source, timeout=30, follow_redirects=True)
response.raise_for_status()
content = response.text
@@ -312,13 +312,23 @@ class HybridWorkflowExecutor:
return flags
def _eval_condition(self, condition: str, flags: Dict) -> bool:
"""Evaluate a simple condition expression."""
"""Evaluate a simple condition expression safely."""
import ast
condition = condition.strip()
if condition.startswith("{{") and condition.endswith("}}"):
condition = condition[2:-2].strip()
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
# Handle simple attribute lookups like "flags.verbose" or "env.DEBUG"
context = {"flags": flags, "env": dict(os.environ)}
try:
return bool(eval(condition, {"__builtins__": {}}, context))
except Exception:
# Only allow simple attribute access patterns: obj.attr
parts = condition.split(".")
if len(parts) == 2 and parts[0] in context:
obj = context[parts[0]]
if isinstance(obj, dict):
return bool(obj.get(parts[1], False))
# Try literal eval for simple boolean/numeric expressions
return bool(ast.literal_eval(condition))
except (ValueError, KeyError, TypeError):
return False
@@ -719,17 +719,26 @@ class JobWorkflowExecutor:
# ------------------------------------------------------------------
def _eval_condition(self, condition: str, flags: Dict) -> bool:
"""Evaluate a simple condition expression."""
"""Evaluate a simple condition expression safely."""
import ast
# Strip {{ }} if present
condition = condition.strip()
if condition.startswith("{{") and condition.endswith("}}"):
condition = condition[2:-2].strip()
# Build evaluation context
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
# Handle simple attribute lookups like "flags.verbose" or "env.DEBUG"
context = {"flags": flags, "env": dict(os.environ)}
try:
return bool(eval(condition, {"__builtins__": {}}, context))
except Exception:
# Only allow simple attribute access patterns: obj.attr
parts = condition.split(".")
if len(parts) == 2 and parts[0] in context:
obj = context[parts[0]]
if isinstance(obj, dict):
return bool(obj.get(parts[1], False))
# Try literal eval for simple boolean/numeric expressions
return bool(ast.literal_eval(condition))
except (ValueError, KeyError, TypeError):
return False
# ------------------------------------------------------------------
@@ -237,7 +237,7 @@ class SurrealDBConversationStore(ConversationStore):
params["after"] = after
where = "WHERE " + " AND ".join(conditions)
limit_clause = f"LIMIT {limit}" if limit else ""
limit_clause = f"LIMIT {int(limit)}" if limit else ""
result = self._run_sync(
self._client.query(
@@ -6,6 +6,7 @@ Install: pip install clickhouse-connect
"""
import logging
import re
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
@@ -13,6 +14,13 @@ from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
def _sanitize_identifier(name: str) -> str:
"""Sanitize a SQL identifier (table/database name) to prevent injection."""
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_.]*$', name):
raise ValueError(f"Invalid identifier: {name}")
return name
class ClickHouseKnowledgeStore(KnowledgeStore):
"""
ClickHouse-based knowledge store using vector search.
@@ -52,11 +60,11 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
self.database = database
# Create database if not exists
self._client.command(f"CREATE DATABASE IF NOT EXISTS {database}")
self._client.command(f"CREATE DATABASE IF NOT EXISTS {_sanitize_identifier(database)}")
logger.info(f"Connected to ClickHouse database: {database}")
def _table_name(self, collection: str) -> str:
return f"{self.database}.praison_vec_{collection}"
return _sanitize_identifier(f"{self.database}.praison_vec_{collection}")
def create_collection(
self,
@@ -98,10 +106,11 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
def list_collections(self) -> List[str]:
"""List all collections."""
result = self._client.query(f"""
SELECT name FROM system.tables
WHERE database = '{self.database}' AND name LIKE 'praison_vec_%'
""")
db = _sanitize_identifier(self.database)
result = self._client.query(
"SELECT name FROM system.tables WHERE database = %(db)s AND name LIKE 'praison_vec_%%'",
parameters={"db": db}
)
return [row[0].replace("praison_vec_", "") for row in result.result_rows]
def insert(
@@ -156,19 +165,20 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
"""Search for similar documents using cosine distance."""
table = self._table_name(collection)
# Convert embedding to string for query
embedding_str = "[" + ",".join(map(str, query_embedding)) + "]"
# Convert embedding to validated float array for query
embedding_str = "[" + ",".join(str(float(x)) for x in query_embedding) + "]"
limit = int(limit)
query = f"""
SELECT
SELECT
id, content, content_hash, created_at,
1 - cosineDistance(embedding, {embedding_str}) as score
FROM {table}
"""
if score_threshold:
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {score_threshold}"
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {float(score_threshold)}"
query += f" ORDER BY score DESC LIMIT {limit}"
result = self._client.query(query)
@@ -195,11 +205,12 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
"""Get documents by IDs."""
table = self._table_name(collection)
id_list = ", ".join([f"'{i}'" for i in ids])
result = self._client.query(f"""
SELECT id, content, content_hash, created_at, embedding
FROM {table} WHERE id IN ({id_list})
""")
placeholders = ", ".join(["%(id_{})s".format(idx) for idx in range(len(ids))])
params = {"id_{}".format(idx): i for idx, i in enumerate(ids)}
result = self._client.query(
f"SELECT id, content, content_hash, created_at, embedding FROM {table} WHERE id IN ({placeholders})",
parameters=params
)
documents = []
for row in result.result_rows:
@@ -225,8 +236,12 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
table = self._table_name(collection)
if ids:
id_list = ", ".join([f"'{i}'" for i in ids])
self._client.command(f"ALTER TABLE {table} DELETE WHERE id IN ({id_list})")
placeholders = ", ".join(["%(id_{})s".format(idx) for idx in range(len(ids))])
params = {"id_{}".format(idx): i for idx, i in enumerate(ids)}
self._client.command(
f"ALTER TABLE {table} DELETE WHERE id IN ({placeholders})",
parameters=params
)
return len(ids)
return 0
@@ -6,6 +6,7 @@ Install: pip install lancedb
"""
import logging
import re
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
@@ -13,6 +14,18 @@ from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
def _sanitize_identifier(name: str) -> str:
"""Sanitize a SQL identifier to prevent injection."""
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
raise ValueError(f"Invalid identifier: {name}")
return name
def _escape_value(val: str) -> str:
"""Escape a string value for use in LanceDB WHERE clauses."""
return str(val).replace("'", "''").replace("\\", "\\\\")
class LanceDBKnowledgeStore(KnowledgeStore):
"""
LanceDB-based knowledge store for vector search.
@@ -140,9 +153,9 @@ class LanceDBKnowledgeStore(KnowledgeStore):
query = table.search(query_embedding).limit(limit)
if filters:
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
where_clauses = [f"{_sanitize_identifier(k)} = '{_escape_value(v)}'" for k, v in filters.items()]
query = query.where(" AND ".join(where_clauses))
results = query.to_pandas()
documents = []
@@ -170,7 +183,7 @@ class LanceDBKnowledgeStore(KnowledgeStore):
"""Get documents by IDs."""
table = self._get_table(collection)
id_list = ", ".join([f"'{i}'" for i in ids])
id_list = ", ".join([f"'{_escape_value(i)}'" for i in ids])
results = table.search().where(f"id IN ({id_list})").to_pandas()
documents = []
@@ -197,11 +210,11 @@ class LanceDBKnowledgeStore(KnowledgeStore):
table = self._get_table(collection)
if ids:
id_list = ", ".join([f"'{i}'" for i in ids])
id_list = ", ".join([f"'{_escape_value(i)}'" for i in ids])
table.delete(f"id IN ({id_list})")
return len(ids)
elif filters:
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
where_clauses = [f"{_sanitize_identifier(k)} = '{_escape_value(v)}'" for k, v in filters.items()]
table.delete(" AND ".join(where_clauses))
return -1
return 0
@@ -6,6 +6,7 @@ Install: pip install pymilvus
"""
import logging
import re
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
@@ -13,6 +14,18 @@ from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
def _sanitize_identifier(name: str) -> str:
"""Sanitize a Milvus field name to prevent injection."""
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
raise ValueError(f"Invalid field name: {name}")
return name
def _escape_value(val: str) -> str:
"""Escape a string value for use in Milvus expressions."""
return str(val).replace('\\', '\\\\').replace('"', '\\"')
class MilvusKnowledgeStore(KnowledgeStore):
"""
Milvus-based knowledge store for vector search.
@@ -180,9 +193,9 @@ class MilvusKnowledgeStore(KnowledgeStore):
expr = None
if filters:
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
conditions = [f'{_sanitize_identifier(k)} == "{_escape_value(v)}"' for k, v in filters.items()]
expr = " and ".join(conditions)
results = col.search(
data=[query_embedding],
anns_field="vector",
@@ -220,9 +233,9 @@ class MilvusKnowledgeStore(KnowledgeStore):
col = Collection(collection)
col.load()
id_list = ", ".join([f'"{i}"' for i in ids])
id_list = ", ".join([f'"{_escape_value(i)}"' for i in ids])
expr = f"id in [{id_list}]"
results = col.query(
expr=expr,
output_fields=["id", "content", "content_hash", "created_at", "vector"],
@@ -254,12 +267,12 @@ class MilvusKnowledgeStore(KnowledgeStore):
col.load()
if ids:
id_list = ", ".join([f'"{i}"' for i in ids])
id_list = ", ".join([f'"{_escape_value(i)}"' for i in ids])
expr = f"id in [{id_list}]"
col.delete(expr)
return len(ids)
elif filters:
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
conditions = [f'{_sanitize_identifier(k)} == "{_escape_value(v)}"' for k, v in filters.items()]
expr = " and ".join(conditions)
col.delete(expr)
return -1
@@ -5,6 +5,7 @@ Requires: singlestoredb
Install: pip install singlestoredb
"""
import json
import logging
from typing import Any, Dict, List, Optional
@@ -227,7 +228,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
documents.append(KnowledgeDocument(
id=row[0],
content=row[1],
metadata=eval(row[2]) if row[2] else None,
metadata=json.loads(row[2]) if row[2] else None,
content_hash=row[3],
created_at=row[4]
))
@@ -256,7 +257,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
KnowledgeDocument(
id=row[0],
content=row[1],
metadata=eval(row[2]) if row[2] else None,
metadata=json.loads(row[2]) if row[2] else None,
content_hash=row[3],
created_at=row[4]
)
@@ -199,8 +199,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
"""Search for similar documents using vector search."""
self._init_client()
# SurrealDB vector search query
embedding_str = str(query_embedding)
# SurrealDB vector search query - validate numeric inputs
embedding_str = "[" + ",".join(str(float(x)) for x in query_embedding) + "]"
limit = int(limit)
query = f"""
SELECT *, vector::similarity::cosine(embedding, {embedding_str}) AS score
FROM {collection}
+1 -1
View File
@@ -56,7 +56,7 @@ class PlanApplier:
# Try to use ruamel.yaml for comment preservation
try:
from ruamel.yaml import YAML
self._yaml = YAML()
self._yaml = YAML(typ='safe')
self._yaml.preserve_quotes = True
self._use_ruamel = True
except ImportError: