Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Security]: Add input validation for /admin endpoints #339

@crivetimihai

Description

@crivetimihai

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 modals
  • description: Displayed in views
  • template: 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 headers
  • description: Displayed in cards
  • url: Rendered as links
  • headers: JSON displayed in viewers
  • inputSchema: JSON displayed in UI
  • auth: Authentication data displayed in sections
  • annotations: 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 identifiers
  • name: Displayed in tables
  • description: Displayed in views
  • content: Large text/JSON content displayed in viewers
  • mimeType: 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 lists
  • description: Displayed in cards
  • url: Displayed and used for connections
  • icon: 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 name
  • params: Passed to tool execution
  • id: 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingsecurityImproves securitytriageIssues / Features awaiting triage

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions