Add request size limits - Prevents memory exhaustion from large uploads

This comprehensive request size limiting system prevents memory exhaustion and DoS attacks from oversized requests.

Key features:
- Global request size limit: 50MB (configurable)
- Type-specific limits: 25MB for audio, 1MB for JSON, 10MB for images
- Multi-layer validation before loading data into memory
- File type detection based on extensions
- Endpoint-specific limit enforcement
- Dynamic configuration via admin API
- Clear error messages with size information

Implementation details:
- RequestSizeLimiter middleware with Flask integration
- Pre-request validation using Content-Length header
- File size checking for multipart uploads
- JSON payload size validation
- Custom decorator for route-specific limits
- StreamSizeLimiter for chunked transfers
- Integration with Flask's MAX_CONTENT_LENGTH

Admin features:
- GET /admin/size-limits - View current limits
- POST /admin/size-limits - Update limits dynamically
- Human-readable size formatting in responses
- Size limit info in health check endpoints

Security benefits:
- Prevents memory exhaustion attacks
- Blocks oversized uploads before processing
- Protects against buffer overflow attempts
- Works with rate limiting for comprehensive protection

This addresses the critical security issue of unbounded request sizes that could lead to memory exhaustion or system crashes.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-06-03 00:58:14 -06:00
parent 30edf8d272
commit aec2d3b0aa
6 changed files with 905 additions and 1 deletions

108
app.py
View File

@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
from config import init_app as init_config
from secrets_manager import init_app as init_secrets
from session_manager import init_app as init_session_manager, track_resource
from request_size_limiter import RequestSizeLimiter, limit_request_size
# Error boundary decorator for Flask routes
def with_error_boundary(func):
@@ -110,6 +111,14 @@ app.config['UPLOAD_FOLDER'] = upload_folder
# Initialize session management after upload folder is configured
init_session_manager(app)
# Initialize request size limiter
request_size_limiter = RequestSizeLimiter(app, {
'max_content_length': app.config.get('MAX_CONTENT_LENGTH', 50 * 1024 * 1024), # 50MB default
'max_audio_size': app.config.get('MAX_AUDIO_SIZE', 25 * 1024 * 1024), # 25MB for audio
'max_json_size': app.config.get('MAX_JSON_SIZE', 1 * 1024 * 1024), # 1MB for JSON
'max_image_size': app.config.get('MAX_IMAGE_SIZE', 10 * 1024 * 1024), # 10MB for images
})
# TTS configuration is already loaded from config.py
# Warn if TTS API key is not set
if not app.config.get('TTS_API_KEY'):
@@ -592,6 +601,7 @@ def index():
@app.route('/transcribe', methods=['POST'])
@rate_limit(requests_per_minute=10, requests_per_hour=100, check_size=True)
@limit_request_size(max_audio_size=25 * 1024 * 1024) # 25MB limit for audio
@with_error_boundary
@track_resource('audio_file')
def transcribe():
@@ -707,6 +717,7 @@ def transcribe():
@app.route('/translate', methods=['POST'])
@rate_limit(requests_per_minute=20, requests_per_hour=300, check_size=True)
@limit_request_size(max_size=1 * 1024 * 1024) # 1MB limit for JSON
@with_error_boundary
def translate():
try:
@@ -775,6 +786,7 @@ def translate():
@app.route('/translate/stream', methods=['POST'])
@rate_limit(requests_per_minute=10, requests_per_hour=150, check_size=True)
@limit_request_size(max_size=1 * 1024 * 1024) # 1MB limit for JSON
@with_error_boundary
def translate_stream():
"""Streaming translation endpoint for reduced latency"""
@@ -872,6 +884,7 @@ def translate_stream():
@app.route('/speak', methods=['POST'])
@rate_limit(requests_per_minute=15, requests_per_hour=200, check_size=True)
@limit_request_size(max_size=1 * 1024 * 1024) # 1MB limit for JSON
@with_error_boundary
@track_resource('audio_file')
def speak():
@@ -1127,6 +1140,13 @@ def detailed_health_check():
health_status['metrics']['uptime'] = time.time() - app.start_time if hasattr(app, 'start_time') else 0
health_status['metrics']['request_count'] = getattr(app, 'request_count', 0)
# Add size limits info
if hasattr(app, 'request_size_limiter'):
health_status['metrics']['size_limits'] = {
k: f"{v / 1024 / 1024:.1f}MB" if v > 1024 * 1024 else f"{v / 1024:.1f}KB"
for k, v in app.request_size_limiter.limits.items()
}
# Set appropriate HTTP status code
http_status = 200 if health_status['status'] == 'healthy' else 503 if health_status['status'] == 'unhealthy' else 200
@@ -1481,5 +1501,93 @@ def get_session_metrics():
logger.error(f"Failed to get session metrics: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/admin/size-limits', methods=['GET'])
@rate_limit(requests_per_minute=10)
def get_size_limits():
"""Get current request size limits"""
try:
# Simple authentication check
auth_token = request.headers.get('X-Admin-Token')
expected_token = app.config.get('ADMIN_TOKEN', 'default-admin-token')
if auth_token != expected_token:
return jsonify({'error': 'Unauthorized'}), 401
if hasattr(app, 'request_size_limiter'):
return jsonify({
'limits': app.request_size_limiter.limits,
'limits_human': {
k: f"{v / 1024 / 1024:.1f}MB" if v > 1024 * 1024 else f"{v / 1024:.1f}KB"
for k, v in app.request_size_limiter.limits.items()
}
})
else:
return jsonify({'error': 'Size limiter not initialized'}), 500
except Exception as e:
logger.error(f"Failed to get size limits: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/admin/size-limits', methods=['POST'])
@rate_limit(requests_per_minute=5)
def update_size_limits():
"""Update request size limits"""
try:
# Simple authentication check
auth_token = request.headers.get('X-Admin-Token')
expected_token = app.config.get('ADMIN_TOKEN', 'default-admin-token')
if auth_token != expected_token:
return jsonify({'error': 'Unauthorized'}), 401
data = request.json
if not data:
return jsonify({'error': 'No data provided'}), 400
# Validate limits
valid_keys = {'max_content_length', 'max_audio_size', 'max_json_size', 'max_image_size'}
updates = {}
for key, value in data.items():
if key in valid_keys:
try:
# Accept values in MB and convert to bytes
if isinstance(value, str) and value.endswith('MB'):
value = float(value[:-2]) * 1024 * 1024
elif isinstance(value, str) and value.endswith('KB'):
value = float(value[:-2]) * 1024
else:
value = int(value)
# Enforce reasonable limits
if value < 1024: # Minimum 1KB
return jsonify({'error': f'{key} too small (min 1KB)'}), 400
if value > 500 * 1024 * 1024: # Maximum 500MB
return jsonify({'error': f'{key} too large (max 500MB)'}), 400
updates[key] = value
except ValueError:
return jsonify({'error': f'Invalid value for {key}'}), 400
if not updates:
return jsonify({'error': 'No valid limits provided'}), 400
# Update limits
old_limits = app.request_size_limiter.update_limits(**updates)
logger.info(f"Size limits updated by admin: {updates}")
return jsonify({
'success': True,
'old_limits': old_limits,
'new_limits': app.request_size_limiter.limits,
'new_limits_human': {
k: f"{v / 1024 / 1024:.1f}MB" if v > 1024 * 1024 else f"{v / 1024:.1f}KB"
for k, v in app.request_size_limiter.limits.items()
}
})
except Exception as e:
logger.error(f"Failed to update size limits: {str(e)}")
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5005, debug=True)