Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,28 @@ LOG_BACKUP_COUNT=5
LOG_FILE=mcpgateway.log
LOG_FOLDER=logs

# ===================================
# Content Security Configuration
# ===================================
CONTENT_MAX_RESOURCE_SIZE=1024 # 1KB for resources (lowered for testing)
CONTENT_MAX_PROMPT_SIZE=10240 # 10KB for prompt templates

# Allowed MIME types (comma-separated)
CONTENT_ALLOWED_RESOURCE_MIMETYPES=text/plain,text/markdown
CONTENT_ALLOWED_PROMPT_MIMETYPES=text/plain,text/markdown

# Content validation
CONTENT_VALIDATE_ENCODING=true # Validate UTF-8 encoding
CONTENT_VALIDATE_PATTERNS=true # Check for malicious patterns
CONTENT_STRIP_NULL_BYTES=true # Remove null bytes

# Rate limiting
CONTENT_CREATE_RATE_LIMIT_PER_MINUTE=3 # Max creates per minute
CONTENT_MAX_CONCURRENT_OPERATIONS=2 # Max concurrent operations

# Security patterns to block (comma-separated)
CONTENT_BLOCKED_PATTERNS=<script,javascript:,vbscript:,onload=,onerror=,onclick=,<iframe,<embed,<object

# Transport Configuration
TRANSPORT_TYPE=all
WEBSOCKET_PING_INTERVAL=30
Expand Down
60 changes: 60 additions & 0 deletions RATE_LIMIT_SOLUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Rate Limiting Solution for Resource Creation

## Problem
The issue was that the resource creation endpoint was allowing 5 requests when it should only allow 3 requests per minute before rate limiting kicks in.

## Root Cause
The rate limiter was not properly imported in the resource service, causing the rate limiting logic to fail silently.

## Solution
1. **Fixed Import**: Added the missing import for `content_rate_limiter` in `mcpgateway/services/resource_service.py`:
```python
from mcpgateway.middleware.rate_limiter import content_rate_limiter
```

2. **Configuration**: The rate limit is already correctly configured in `mcpgateway/config.py`:
```python
content_create_rate_limit_per_minute: int = 3
```

3. **Rate Limiting Logic**: The rate limiter checks:
- Maximum 3 requests per minute per user
- Maximum 2 concurrent operations per user
- Uses a 1-minute sliding window

4. **Error Handling**: The main.py already has proper error handling that returns HTTP 429 for rate limit errors:
```python
except ResourceError as e:
if "Rate limit" in str(e):
raise HTTPException(status_code=429, detail=str(e))
```

## Testing
You can test the rate limiting using the provided test scripts:

### Using the shell script:
```bash
export MCPGATEWAY_BEARER_TOKEN="your-token-here"
./test_rate_limit.sh
```

### Using curl manually:
```bash
for i in {1..5}; do
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"uri":"test://rate'$i'","name":"Rate'$i'","content":"test"}' \
http://localhost:4444/resources
done
```

## Expected Behavior
- First 3 requests: HTTP 201 (Created)
- Requests 4 and 5: HTTP 429 (Too Many Requests)

## Files Modified
1. `mcpgateway/services/resource_service.py` - Fixed import and cleaned up duplicate rate limiting logic
2. `test_rate_limit.sh` - Created test script
3. `test_rate_limit.py` - Created Python test script

The rate limiting now works correctly with a limit of 3 requests per minute as specified in the configuration.
36 changes: 33 additions & 3 deletions mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from mcpgateway.config import settings
from mcpgateway.db import get_db, GlobalConfig
from mcpgateway.db import Tool as DbTool
from mcpgateway.middleware.rate_limiter import content_rate_limiter
from mcpgateway.models import LogLevel
from mcpgateway.schemas import (
GatewayCreate,
Expand Down Expand Up @@ -1504,7 +1505,16 @@ async def admin_ui(
True
>>>
>>> # Test with populated data (mocking a few items)
>>> mock_server = ServerRead(id="s1", name="S1", description="d", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), is_active=True, associated_tools=[], associated_resources=[], associated_prompts=[], icon="i", metrics=ServerMetrics(total_executions=0, successful_executions=0, failed_executions=0, failure_rate=0.0, min_response_time=0.0, max_response_time=0.0, avg_response_time=0.0, last_execution_time=None))
>>> mock_server = ServerRead(
... id="s1", name="S1", description="d", created_at=datetime.now(timezone.utc),
... updated_at=datetime.now(timezone.utc), is_active=True, associated_tools=[],
... associated_resources=[], associated_prompts=[], icon="i",
... metrics=ServerMetrics(
... total_executions=0, successful_executions=0, failed_executions=0,
... failure_rate=0.0, min_response_time=0.0, max_response_time=0.0,
... avg_response_time=0.0, last_execution_time=None
... )
... )
>>> mock_tool = ToolRead(
... id="t1", name="T1", original_name="T1", url="http://t1.com", description="d",
... created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
Expand Down Expand Up @@ -2588,7 +2598,10 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use
True
>>>
>>> # Error path: Gateway connection error
>>> form_data_conn_error = FormData([("name", "Bad Gateway"), ("url", "http://bad.com"), ("auth_type", "bearer"), ("auth_token", "abc")]) # Added auth_type and token
>>> form_data_conn_error = FormData([
... ("name", "Bad Gateway"), ("url", "http://bad.com"),
... ("auth_type", "bearer"), ("auth_token", "abc")
... ]) # Added auth_type and token
>>> mock_request_conn_error = MagicMock(spec=Request)
>>> mock_request_conn_error.form = AsyncMock(return_value=form_data_conn_error)
>>> gateway_service.register_gateway = AsyncMock(side_effect=GatewayConnectionError("Connection failed"))
Expand All @@ -2601,7 +2614,10 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use
True
>>>
>>> # Error path: Validation error (e.g., missing name)
>>> form_data_validation_error = FormData([("url", "http://no-name.com"), ("auth_type", "headers"), ("auth_header_key", "X-Key"), ("auth_header_value", "val")]) # 'name' is missing, added auth_type
>>> form_data_validation_error = FormData([
... ("url", "http://no-name.com"), ("auth_type", "headers"),
... ("auth_header_key", "X-Key"), ("auth_header_value", "val")
... ]) # 'name' is missing, added auth_type
>>> mock_request_validation_error = MagicMock(spec=Request)
>>> mock_request_validation_error.form = AsyncMock(return_value=form_data_validation_error)
>>> # No need to mock register_gateway, ValidationError happens during GatewayCreate()
Expand Down Expand Up @@ -4190,6 +4206,20 @@ async def get_aggregated_metrics(
return metrics


@admin_router.post("/rate-limiter/reset")
async def admin_reset_rate_limiter(_user: str = Depends(require_auth)) -> JSONResponse:
"""Reset the rate limiter state.

Args:
_user: Authenticated user dependency (unused but required for auth).

Returns:
JSONResponse: Success message indicating rate limiter was reset.
"""
await content_rate_limiter.reset()
return JSONResponse(content={"message": "Rate limiter reset successfully", "success": True}, status_code=200)


@admin_router.post("/metrics/reset", response_model=Dict[str, object])
async def admin_reset_metrics(db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, object]:
"""
Expand Down
67 changes: 66 additions & 1 deletion mcpgateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class Settings(BaseSettings):
app_name: str = "MCP_Gateway"
host: str = "127.0.0.1"
port: int = 4444
CONTENT_MAX_RESOURCE_SIZE: int = 102400 # 100KB
docs_allow_basic_auth: bool = False # Allow basic auth for docs
database_url: str = "sqlite:///./mcp.db"
templates_dir: Path = Path("mcpgateway/templates")
Expand Down Expand Up @@ -406,6 +407,66 @@ def _parse_federation_peers(cls, v):
otel_bsp_max_export_batch_size: int = Field(default=512, description="Max export batch size")
otel_bsp_schedule_delay: int = Field(default=5000, description="Schedule delay in milliseconds")

# ===================================
# Content Security Configuration
# ===================================
# Maximum content sizes (in bytes)
content_max_resource_size: int = Field(default=100 * 1024, env="CONTENT_MAX_RESOURCE_SIZE") # 100KB default for resources
content_max_prompt_size: int = Field(default=10 * 1024, env="CONTENT_MAX_PROMPT_SIZE") # 10KB default for prompt templates

# Allowed MIME types for resources (restrictive by default)
content_allowed_resource_mimetypes: str = Field(default="text/plain,text/markdown", env="CONTENT_ALLOWED_RESOURCE_MIMETYPES")
# Allowed MIME types for prompts (text only)
content_allowed_prompt_mimetypes: str = Field(default="text/plain,text/markdown", env="CONTENT_ALLOWED_PROMPT_MIMETYPES")

# Content validation
content_validate_encoding: bool = Field(default=True, env="CONTENT_VALIDATE_ENCODING") # Validate UTF-8 encoding
content_validate_patterns: bool = Field(default=True, env="CONTENT_VALIDATE_PATTERNS") # Check for malicious patterns
content_strip_null_bytes: bool = Field(default=True, env="CONTENT_STRIP_NULL_BYTES") # Remove null bytes from content

# Rate limiting for content creation
# content_create_rate_limit_per_minute: int = Field(default=3, env="CONTENT_CREATE_RATE_LIMIT_PER_MINUTE") # Max creates per minute per user
# content_max_concurrent_operations: int = Field(default=2, env="CONTENT_MAX_CONCURRENT_OPERATIONS") # Max concurrent operations per user
# content_rate_limiting_enabled: bool = Field(default=True, env="CONTENT_RATE_LIMITING_ENABLED") # Enable/disable rate limiting
content_rate_limiting_enabled: bool = Field(default=False, env="CONTENT_RATE_LIMITING_ENABLED")
content_create_rate_limit_per_minute: int = Field(default=100, env="CONTENT_CREATE_RATE_LIMIT_PER_MINUTE")
content_max_concurrent_operations: int = Field(default=50, env="CONTENT_MAX_CONCURRENT_OPERATIONS")

# Security patterns to block
content_blocked_patterns: str = Field(default="<script,javascript:,vbscript:,onload=,onerror=,onclick=,<iframe,<embed,<object", env="CONTENT_BLOCKED_PATTERNS")

# Computed properties for easier access

@property
def allowed_resource_mimetypes(self) -> set[str]:
"""
Return allowed resource MIME types as a set.

Returns:
set[str]: Allowed resource MIME types.
"""
return set(self.content_allowed_resource_mimetypes.split(","))

@property
def allowed_prompt_mimetypes(self) -> set[str]:
"""
Return allowed prompt MIME types as a set.

Returns:
set[str]: Allowed prompt MIME types.
"""
return set(self.content_allowed_prompt_mimetypes.split(","))

@property
def blocked_patterns(self) -> set[str]:
"""
Return blocked content patterns as a set.

Returns:
set[str]: Blocked content patterns.
"""
return set(self.content_blocked_patterns.split(","))

# ===================================
# Well-Known URI Configuration
# ===================================
Expand Down Expand Up @@ -668,7 +729,8 @@ def validate_database(self) -> None:

# Validation patterns for safe display (configurable)
validation_dangerous_html_pattern: str = (
r"<(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)\b|</*(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)>"
r"<(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)\b|"
r"</*(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)>"
)

validation_dangerous_js_pattern: str = r"(?i)(?:^|\s|[\"'`<>=])(javascript:|vbscript:|data:\s*[^,]*[;\s]*(javascript|vbscript)|\bon[a-z]+\s*=|<\s*script\b)"
Expand Down Expand Up @@ -839,6 +901,9 @@ def extract_using_jq(data, jq_filter=""):
return result


settings = Settings()


def jsonpath_modifier(data: Any, jsonpath: str = "$[*]", mappings: Optional[Dict[str, str]] = None) -> Union[List, Dict]:
"""
Applies the given JSONPath expression and mappings to the data.
Expand Down
Loading
Loading