-
Notifications
You must be signed in to change notification settings - Fork 302
Description
Title: Implement proper input validation and escaping for user-controlled data in APIs (part 1: /admin APIs)
Description:
Several API endpoints accept user content that is displayed in the admin UI without proper validation and escaping. This can lead to data integrity issues and unexpected behavior when special characters or malformed data are submitted. This ticket focuses on securing all endpoints where user input is stored and later rendered in the UI.
A separate ticket will be created for non-admin APIs.
High-Risk Endpoints (user content displayed in UI):
1. Prompt Management APIs
# app/routers/admin.py
- [ ] POST /admin/prompts
- [ ] PUT /admin/prompts/{name}/edit
- [ ] POST /admin/prompts/{name}/deactivate
- [ ] POST /admin/prompts/{name}/activate
Fields requiring validation:
name
: Displayed in tables and modalsdescription
: Displayed in viewstemplate
: Displayed in code viewers (special handling for Jinja2)arguments
: JSON displayed in UI
2. Tool Management APIs
# app/routers/admin.py
- [ ] POST /admin/tools
- [ ] PUT /admin/tools/{id}/edit
- [ ] POST /admin/tools/{id}/deactivate
- [ ] POST /admin/tools/{id}/activate
Fields requiring validation:
name
: Displayed in lists and headersdescription
: Displayed in cardsurl
: Rendered as linksheaders
: JSON displayed in viewersinputSchema
: JSON displayed in UIauth
: Authentication data displayed in sectionsannotations
: Custom metadata displayed as badges
3. Resource Management APIs
# app/routers/admin.py
- [ ] POST /admin/resources
- [ ] PUT /admin/resources/{uri}/edit
- [ ] POST /admin/resources/{uri}/deactivate
- [ ] POST /admin/resources/{uri}/activate
Fields requiring validation:
uri
: Displayed as identifiersname
: Displayed in tablesdescription
: Displayed in viewscontent
: Large text/JSON content displayed in viewersmimeType
: Used for content type decisions
4. Gateway & Server APIs
# app/routers/admin.py
- [ ] POST /admin/gateways
- [ ] PUT /admin/gateways/{id}/edit
- [ ] POST /admin/gateways/{id}/deactivate
- [ ] POST /admin/gateways/{id}/activate
- [ ] POST /admin/servers
- [ ] PUT /admin/servers/{id}/edit
Fields requiring validation:
name
: Displayed in listsdescription
: Displayed in cardsurl
: Displayed and used for connectionsicon
: Image URLs rendered in UI (servers)transport
: Gateway transport type (SSE, STREAMABLE, STDIO)
5. RPC Endpoint
# app/routers/rpc.py
- [ ] POST /rpc
Fields requiring validation:
method
: Executed as tool nameparams
: Passed to tool executionid
: Returned in response
Implementation Tasks:
A. Create Base Validation Module (app/core/validators.py
):
from pydantic import field_validator, ConfigDict
from typing import Any, Optional, Dict, List, Union
import re
import html
from urllib.parse import urlparse
from app.core.config import settings
class SecurityValidator:
"""Configurable validation with MCP-compliant limits"""
# Configurable patterns (from settings)
DANGEROUS_HTML_PATTERN = settings.VALIDATION_DANGEROUS_HTML_PATTERN
DANGEROUS_JS_PATTERN = settings.VALIDATION_DANGEROUS_JS_PATTERN
ALLOWED_URL_SCHEMES = settings.VALIDATION_ALLOWED_URL_SCHEMES
# Character type patterns
NAME_PATTERN = settings.VALIDATION_NAME_PATTERN # Default: ^[a-zA-Z0-9_\-]+$
IDENTIFIER_PATTERN = settings.VALIDATION_IDENTIFIER_PATTERN # Default: ^[a-zA-Z0-9_\-\.]+$
TOOL_NAME_PATTERN = settings.VALIDATION_TOOL_NAME_PATTERN # Default: ^[a-zA-Z][a-zA-Z0-9_]*$
# MCP-compliant limits (configurable)
MAX_NAME_LENGTH = settings.VALIDATION_MAX_NAME_LENGTH # Default: 255
MAX_DESCRIPTION_LENGTH = settings.VALIDATION_MAX_DESCRIPTION_LENGTH # Default: 4096
MAX_TEMPLATE_LENGTH = settings.VALIDATION_MAX_TEMPLATE_LENGTH # Default: 65536
MAX_CONTENT_LENGTH = settings.VALIDATION_MAX_CONTENT_LENGTH # Default: 1048576 (1MB)
MAX_JSON_DEPTH = settings.VALIDATION_MAX_JSON_DEPTH # Default: 10
MAX_URL_LENGTH = settings.VALIDATION_MAX_URL_LENGTH # Default: 2048
@classmethod
def sanitize_display_text(cls, value: str, field_name: str) -> str:
"""Ensure text is safe for display in UI by escaping special characters"""
if not value:
return value
# Check for patterns that could cause display issues
if re.search(cls.DANGEROUS_HTML_PATTERN, value, re.IGNORECASE):
raise ValueError(f"{field_name} contains HTML tags that may cause display issues")
if re.search(cls.DANGEROUS_JS_PATTERN, value, re.IGNORECASE):
raise ValueError(f"{field_name} contains script patterns that may cause display issues")
# Escape HTML entities to ensure proper display
return html.escape(value, quote=True)
@classmethod
def validate_name(cls, value: str, field_name: str = "Name") -> str:
"""Validate names with strict character requirements"""
if not value:
raise ValueError(f"{field_name} cannot be empty")
# Check against allowed pattern
if not re.match(cls.NAME_PATTERN, value):
raise ValueError(
f"{field_name} can only contain letters, numbers, underscore, and hyphen. "
f"Special characters like <, >, quotes are not allowed."
)
# Additional check for HTML-like patterns
if re.search(r'[<>"\'/]', value):
raise ValueError(f"{field_name} cannot contain HTML special characters")
if len(value) > cls.MAX_NAME_LENGTH:
raise ValueError(f"{field_name} exceeds maximum length of {cls.MAX_NAME_LENGTH}")
return value
@classmethod
def validate_identifier(cls, value: str, field_name: str) -> str:
"""Validate identifiers (IDs, URIs) - MCP compliant"""
if not value:
raise ValueError(f"{field_name} cannot be empty")
# MCP spec: identifiers should be alphanumeric + limited special chars
if not re.match(cls.IDENTIFIER_PATTERN, value):
raise ValueError(
f"{field_name} can only contain letters, numbers, underscore, hyphen, and dots"
)
# Block HTML-like patterns
if re.search(r'[<>"\'/]', value):
raise ValueError(f"{field_name} cannot contain HTML special characters")
if len(value) > cls.MAX_NAME_LENGTH:
raise ValueError(f"{field_name} exceeds maximum length of {cls.MAX_NAME_LENGTH}")
return value
@classmethod
def validate_tool_name(cls, value: str) -> str:
"""Special validation for MCP tool names"""
if not value:
raise ValueError("Tool name cannot be empty")
# MCP tools have specific naming requirements
if not re.match(cls.TOOL_NAME_PATTERN, value):
raise ValueError(
"Tool name must start with a letter and contain only letters, numbers, and underscore"
)
# Ensure no HTML-like content
if re.search(r'[<>"\'/]', value):
raise ValueError("Tool name cannot contain HTML special characters")
if len(value) > cls.MAX_NAME_LENGTH:
raise ValueError(f"Tool name exceeds maximum length of {cls.MAX_NAME_LENGTH}")
return value
@classmethod
def validate_template(cls, value: str) -> str:
"""Special validation for templates - allow Jinja2 but ensure safe display"""
if not value:
return value
if len(value) > cls.MAX_TEMPLATE_LENGTH:
raise ValueError(f"Template exceeds maximum length of {cls.MAX_TEMPLATE_LENGTH}")
# Block dangerous tags but allow Jinja2 syntax {{ }} and {% %}
dangerous_tags = r'<(script|iframe|object|embed|link|meta|base|form)\b'
if re.search(dangerous_tags, value, re.IGNORECASE):
raise ValueError("Template contains HTML tags that may interfere with proper display")
# Check for event handlers that could cause issues
if re.search(r'on\w+\s*=', value, re.IGNORECASE):
raise ValueError("Template contains event handlers that may cause display issues")
return value
@classmethod
def validate_url(cls, value: str, field_name: str = "URL") -> str:
"""Validate URL format and ensure safe display"""
if not value:
raise ValueError(f"{field_name} cannot be empty")
# Length check
if len(value) > cls.MAX_URL_LENGTH:
raise ValueError(f"{field_name} exceeds maximum length of {cls.MAX_URL_LENGTH}")
# Check allowed schemes
allowed_schemes = cls.ALLOWED_URL_SCHEMES
if not any(value.lower().startswith(scheme.lower()) for scheme in allowed_schemes):
raise ValueError(f"{field_name} must start with one of: {', '.join(allowed_schemes)}")
# Block dangerous URL patterns
dangerous_patterns = [
r'javascript:', r'data:', r'vbscript:', r'about:', r'chrome:',
r'file:', r'ftp:', r'mailto:'
]
for pattern in dangerous_patterns:
if re.search(pattern, value, re.IGNORECASE):
raise ValueError(f"{field_name} contains unsupported or potentially dangerous protocol")
# Basic URL structure validation
try:
result = urlparse(value)
if not all([result.scheme, result.netloc]):
raise ValueError(f"{field_name} is not a valid URL")
except Exception:
raise ValueError(f"{field_name} is not a valid URL")
return value
@classmethod
def validate_json_depth(cls, obj: Any, max_depth: int = None, current_depth: int = 0) -> None:
"""Check JSON structure doesn't exceed maximum depth"""
max_depth = max_depth or cls.MAX_JSON_DEPTH
if current_depth > max_depth:
raise ValueError(f"JSON structure exceeds maximum depth of {max_depth}")
if isinstance(obj, dict):
for value in obj.values():
cls.validate_json_depth(value, max_depth, current_depth + 1)
elif isinstance(obj, list):
for item in obj:
cls.validate_json_depth(item, max_depth, current_depth + 1)
@classmethod
def validate_mime_type(cls, value: str) -> str:
"""Validate MIME type format"""
if not value:
return value
# Basic MIME type pattern
mime_pattern = r'^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*$'
if not re.match(mime_pattern, value):
raise ValueError("Invalid MIME type format")
# Common safe MIME types
safe_mime_types = settings.VALIDATION_ALLOWED_MIME_TYPES
if value not in safe_mime_types:
# Allow x- vendor types and + suffixes
base_type = value.split(';')[0].strip()
if not (base_type.startswith('application/x-') or
base_type.startswith('text/x-') or
'+' in base_type):
raise ValueError(f"MIME type '{value}' is not in the allowed list")
return value
B. Enhanced Pydantic Models with MCP Compliance:
# app/schemas/admin.py
from app.core.validators import SecurityValidator
from pydantic import BaseModel, field_validator, ConfigDict
from typing import Optional, Dict, Any, List
from typing_extensions import Literal
class SecurePromptCreate(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
name: str
description: Optional[str] = None
template: str
arguments: Optional[Dict[str, Any]] = {}
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""Ensure prompt names display correctly in UI"""
return SecurityValidator.validate_name(v, "Prompt name")
@field_validator('description')
@classmethod
def validate_description(cls, v: Optional[str]) -> Optional[str]:
"""Ensure descriptions display safely without breaking UI layout"""
if v is None:
return v
if len(v) > SecurityValidator.MAX_DESCRIPTION_LENGTH:
raise ValueError(f"Description exceeds maximum length of {SecurityValidator.MAX_DESCRIPTION_LENGTH}")
return SecurityValidator.sanitize_display_text(v, "Description")
@field_validator('template')
@classmethod
def validate_template(cls, v: str) -> str:
"""Validate template content for safe display"""
return SecurityValidator.validate_template(v)
@field_validator('arguments')
@classmethod
def validate_arguments(cls, v: Dict[str, Any]) -> Dict[str, Any]:
"""Ensure JSON structure is valid and within complexity limits"""
SecurityValidator.validate_json_depth(v)
return v
class SecureToolCreate(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
name: str
url: str
description: Optional[str] = None
integrationType: Literal["MCP", "REST"]
requestType: str
headers: Optional[Dict[str, str]] = {}
inputSchema: Optional[Dict[str, Any]] = {}
auth: Optional[Dict[str, Any]] = None
annotations: Optional[Dict[str, Any]] = {}
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""Ensure tool names follow MCP naming conventions"""
return SecurityValidator.validate_tool_name(v)
@field_validator('url')
@classmethod
def validate_url(cls, v: str) -> str:
"""Validate URL format and ensure safe display"""
return SecurityValidator.validate_url(v, "Tool URL")
@field_validator('description')
@classmethod
def validate_description(cls, v: Optional[str]) -> Optional[str]:
"""Ensure descriptions display safely"""
if v is None:
return v
if len(v) > SecurityValidator.MAX_DESCRIPTION_LENGTH:
raise ValueError(f"Description exceeds maximum length of {SecurityValidator.MAX_DESCRIPTION_LENGTH}")
return SecurityValidator.sanitize_display_text(v, "Description")
@field_validator('headers', 'inputSchema', 'annotations')
@classmethod
def validate_json_fields(cls, v: Dict[str, Any]) -> Dict[str, Any]:
"""Validate JSON structure depth"""
SecurityValidator.validate_json_depth(v)
return v
@field_validator('requestType')
@classmethod
def validate_request_type(cls, v: str, values: Dict[str, Any]) -> str:
"""Validate request type based on integration type"""
integration_type = values.get('integrationType', 'MCP')
if integration_type == 'MCP':
allowed = ['SSE', 'STREAMABLE', 'STDIO']
else: # REST
allowed = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
if v not in allowed:
raise ValueError(f"Request type '{v}' not allowed for {integration_type} integration")
return v
class SecureResourceCreate(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
uri: str
name: str
description: Optional[str] = None
mimeType: Optional[str] = None
content: Optional[str] = None
@field_validator('uri')
@classmethod
def validate_uri(cls, v: str) -> str:
"""Validate URI format"""
return SecurityValidator.validate_identifier(v, "Resource URI")
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate resource name"""
return SecurityValidator.validate_name(v, "Resource name")
@field_validator('description')
@classmethod
def validate_description(cls, v: Optional[str]) -> Optional[str]:
"""Ensure descriptions display safely"""
if v is None:
return v
if len(v) > SecurityValidator.MAX_DESCRIPTION_LENGTH:
raise ValueError(f"Description exceeds maximum length of {SecurityValidator.MAX_DESCRIPTION_LENGTH}")
return SecurityValidator.sanitize_display_text(v, "Description")
@field_validator('mimeType')
@classmethod
def validate_mime_type(cls, v: Optional[str]) -> Optional[str]:
"""Validate MIME type format"""
if v is None:
return v
return SecurityValidator.validate_mime_type(v)
@field_validator('content')
@classmethod
def validate_content(cls, v: Optional[str]) -> Optional[str]:
"""Validate content size and safety"""
if v is None:
return v
if len(v) > SecurityValidator.MAX_CONTENT_LENGTH:
raise ValueError(f"Content exceeds maximum length of {SecurityValidator.MAX_CONTENT_LENGTH}")
# Don't escape content as it may be legitimate code/data
# Just check for dangerous patterns
if re.search(SecurityValidator.DANGEROUS_HTML_PATTERN, v, re.IGNORECASE):
# Only raise if it's not a code/data MIME type
return v
return v
class SecureGatewayCreate(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
name: str
url: str
description: Optional[str] = None
transport: Literal["SSE", "STREAMABLEHTTP", "STDIO"]
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate gateway name"""
return SecurityValidator.validate_name(v, "Gateway name")
@field_validator('url')
@classmethod
def validate_url(cls, v: str) -> str:
"""Validate gateway URL"""
return SecurityValidator.validate_url(v, "Gateway URL")
@field_validator('description')
@classmethod
def validate_description(cls, v: Optional[str]) -> Optional[str]:
"""Ensure descriptions display safely"""
if v is None:
return v
if len(v) > SecurityValidator.MAX_DESCRIPTION_LENGTH:
raise ValueError(f"Description exceeds maximum length of {SecurityValidator.MAX_DESCRIPTION_LENGTH}")
return SecurityValidator.sanitize_display_text(v, "Description")
class SecureServerCreate(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
name: str
description: Optional[str] = None
icon: Optional[str] = None
associatedTools: List[str] = []
associatedResources: List[str] = []
associatedPrompts: List[str] = []
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate server name"""
return SecurityValidator.validate_name(v, "Server name")
@field_validator('description')
@classmethod
def validate_description(cls, v: Optional[str]) -> Optional[str]:
"""Ensure descriptions display safely"""
if v is None:
return v
if len(v) > SecurityValidator.MAX_DESCRIPTION_LENGTH:
raise ValueError(f"Description exceeds maximum length of {SecurityValidator.MAX_DESCRIPTION_LENGTH}")
return SecurityValidator.sanitize_display_text(v, "Description")
@field_validator('icon')
@classmethod
def validate_icon(cls, v: Optional[str]) -> Optional[str]:
"""Validate icon URL"""
if v is None:
return v
return SecurityValidator.validate_url(v, "Icon URL")
class SecureRPCRequest(BaseModel):
"""MCP-compliant RPC request validation"""
jsonrpc: Literal["2.0"]
method: str
params: Optional[Union[Dict[str, Any], List[Any]]] = None
id: Optional[Union[str, int]] = None
@field_validator('method')
@classmethod
def validate_method(cls, v: str) -> str:
"""Ensure method names follow MCP format"""
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_\.]*$', v):
raise ValueError("Invalid method name format")
if len(v) > 128: # MCP method name limit
raise ValueError("Method name too long")
return v
@field_validator('params')
@classmethod
def validate_params(cls, v: Optional[Union[Dict, List]]) -> Optional[Union[Dict, List]]:
"""Validate RPC parameters"""
if v is None:
return v
# Check size limits (MCP recommends max 256KB for params)
import json
param_size = len(json.dumps(v))
if param_size > settings.VALIDATION_MAX_RPC_PARAM_SIZE:
raise ValueError(f"Parameters exceed maximum size of {settings.VALIDATION_MAX_RPC_PARAM_SIZE} bytes")
# Check depth
SecurityValidator.validate_json_depth(v)
return v
C. Configuration Settings (app/core/config.py
):
class Settings(BaseSettings):
# Validation patterns for safe display (configurable)
VALIDATION_DANGEROUS_HTML_PATTERN: str = r'<(script|iframe|object|embed|link|meta|base|form)\b|</*(script|iframe|object|embed|link|meta|base|form)>'
VALIDATION_DANGEROUS_JS_PATTERN: str = r'javascript:|vbscript:|on\w+\s*=|data:.*script'
VALIDATION_ALLOWED_URL_SCHEMES: List[str] = ["http://", "https://", "ws://", "wss://"]
# Character validation patterns
VALIDATION_NAME_PATTERN: str = r'^[a-zA-Z0-9_\-\s]+$' # Allow spaces for names
VALIDATION_IDENTIFIER_PATTERN: str = r'^[a-zA-Z0-9_\-\.]+$' # No spaces for IDs
VALIDATION_TOOL_NAME_PATTERN: str = r'^[a-zA-Z][a-zA-Z0-9_]*$' # MCP tool naming
# MCP-compliant size limits (configurable via env)
VALIDATION_MAX_NAME_LENGTH: int = 255
VALIDATION_MAX_DESCRIPTION_LENGTH: int = 4096
VALIDATION_MAX_TEMPLATE_LENGTH: int = 65536 # 64KB
VALIDATION_MAX_CONTENT_LENGTH: int = 1048576 # 1MB
VALIDATION_MAX_JSON_DEPTH: int = 10
VALIDATION_MAX_URL_LENGTH: int = 2048
VALIDATION_MAX_RPC_PARAM_SIZE: int = 262144 # 256KB
# Allowed MIME types
VALIDATION_ALLOWED_MIME_TYPES: List[str] = [
"text/plain", "text/html", "text/css", "text/javascript",
"application/json", "application/xml", "application/pdf",
"image/png", "image/jpeg", "image/gif", "image/svg+xml",
"application/octet-stream"
]
# Rate limiting
VALIDATION_MAX_REQUESTS_PER_MINUTE: int = 60
D. Apply to Routes:
# app/routers/admin.py
from app.schemas.admin import (
SecurePromptCreate, SecureToolCreate, SecureResourceCreate,
SecureGatewayCreate, SecureServerCreate
)
@router.post("/prompts")
async def create_prompt(prompt: SecurePromptCreate):
"""Create a new prompt with validated input"""
# Input is now validated and safe to use
pass
@router.post("/tools")
async def create_tool(tool: SecureToolCreate):
"""Create a new tool with validated input"""
# Input is now validated and safe to use
pass
# Apply similar pattern to all endpoints...
Testing Requirements:
- Test each endpoint with special characters:
<script>
,<img src=x onerror=alert(1)>
- Test with HTML entities that need escaping:
<>&"'/
- Test with oversized inputs exceeding each limit
- Test with deeply nested JSON structures
- Test with invalid URLs containing javascript: or data: protocols
- Test tool names with invalid characters
- Verify error messages are user-friendly and don't leak internals
Acceptance Criteria:
- All high-risk endpoints validate and escape user input properly
- Special characters display as text content without breaking UI
- HTML tags like
<script>
are rejected or escaped - Names follow proper character restrictions (no
<>
etc.) - Validation limits are configurable via environment variables
- Error messages help users correct their input
- Admin interface displays all user data safely
- Performance impact < 10ms per request
Note: This validation layer ensures that user-controlled data displays correctly in the admin UI without causing layout issues or unexpected behavior.
The admin UI can be disabled through feature flags and listens on localhost by default, which provides additional security in typical deployments.