talk2me/config.py
Adolfo Delorenzo aec2d3b0aa 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>
2025-06-03 00:58:14 -06:00

203 lines
8.1 KiB
Python

# Configuration management with secrets integration
import os
import logging
from datetime import timedelta
from secrets_manager import get_secret, get_secrets_manager
logger = logging.getLogger(__name__)
class Config:
"""Base configuration with secrets management"""
def __init__(self):
self.secrets_manager = get_secrets_manager()
self._load_config()
def _load_config(self):
"""Load configuration from environment and secrets"""
# Flask configuration
self.SECRET_KEY = self._get_secret('FLASK_SECRET_KEY',
os.environ.get('SECRET_KEY', 'dev-key-change-this'))
# Security
self.SESSION_COOKIE_SECURE = self._get_bool('SESSION_COOKIE_SECURE', True)
self.SESSION_COOKIE_HTTPONLY = True
self.SESSION_COOKIE_SAMESITE = 'Lax'
self.PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
# TTS Configuration
self.TTS_SERVER_URL = os.environ.get('TTS_SERVER_URL', 'http://localhost:5050/v1/audio/speech')
self.TTS_API_KEY = self._get_secret('TTS_API_KEY', os.environ.get('TTS_API_KEY', ''))
# Upload configuration
self.UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', None)
# Request size limits (in bytes)
self.MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 50 * 1024 * 1024)) # 50MB
self.MAX_AUDIO_SIZE = int(os.environ.get('MAX_AUDIO_SIZE', 25 * 1024 * 1024)) # 25MB
self.MAX_JSON_SIZE = int(os.environ.get('MAX_JSON_SIZE', 1 * 1024 * 1024)) # 1MB
self.MAX_IMAGE_SIZE = int(os.environ.get('MAX_IMAGE_SIZE', 10 * 1024 * 1024)) # 10MB
# CORS configuration
self.CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',')
self.ADMIN_CORS_ORIGINS = os.environ.get('ADMIN_CORS_ORIGINS', 'http://localhost:*').split(',')
# Admin configuration
self.ADMIN_TOKEN = self._get_secret('ADMIN_TOKEN',
os.environ.get('ADMIN_TOKEN', 'default-admin-token'))
# Database configuration (for future use)
self.DATABASE_URL = self._get_secret('DATABASE_URL',
os.environ.get('DATABASE_URL', 'sqlite:///talk2me.db'))
# Redis configuration (for future use)
self.REDIS_URL = self._get_secret('REDIS_URL',
os.environ.get('REDIS_URL', 'redis://localhost:6379/0'))
# Whisper configuration
self.WHISPER_MODEL_SIZE = os.environ.get('WHISPER_MODEL_SIZE', 'base')
self.WHISPER_DEVICE = os.environ.get('WHISPER_DEVICE', 'auto')
# Ollama configuration
self.OLLAMA_HOST = os.environ.get('OLLAMA_HOST', 'http://localhost:11434')
self.OLLAMA_MODEL = os.environ.get('OLLAMA_MODEL', 'gemma3:27b')
# Rate limiting configuration
self.RATE_LIMIT_ENABLED = self._get_bool('RATE_LIMIT_ENABLED', True)
self.RATE_LIMIT_STORAGE_URL = self._get_secret('RATE_LIMIT_STORAGE_URL',
os.environ.get('RATE_LIMIT_STORAGE_URL', 'memory://'))
# Logging configuration
self.LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
self.LOG_FILE = os.environ.get('LOG_FILE', 'talk2me.log')
# Feature flags
self.ENABLE_PUSH_NOTIFICATIONS = self._get_bool('ENABLE_PUSH_NOTIFICATIONS', True)
self.ENABLE_OFFLINE_MODE = self._get_bool('ENABLE_OFFLINE_MODE', True)
self.ENABLE_STREAMING = self._get_bool('ENABLE_STREAMING', True)
self.ENABLE_MULTI_SPEAKER = self._get_bool('ENABLE_MULTI_SPEAKER', True)
# Performance tuning
self.WORKER_CONNECTIONS = int(os.environ.get('WORKER_CONNECTIONS', '1000'))
self.WORKER_TIMEOUT = int(os.environ.get('WORKER_TIMEOUT', '120'))
# Validate configuration
self._validate_config()
def _get_secret(self, key: str, default: str = None) -> str:
"""Get secret from secrets manager or environment"""
value = self.secrets_manager.get(key)
if value is None:
value = default
if value is None:
logger.warning(f"Configuration {key} not set")
return value
def _get_bool(self, key: str, default: bool = False) -> bool:
"""Get boolean configuration value"""
value = os.environ.get(key, '').lower()
if value in ('true', '1', 'yes', 'on'):
return True
elif value in ('false', '0', 'no', 'off'):
return False
return default
def _validate_config(self):
"""Validate configuration values"""
# Check required secrets
if not self.SECRET_KEY or self.SECRET_KEY == 'dev-key-change-this':
logger.warning("Using default SECRET_KEY - this is insecure for production!")
if not self.TTS_API_KEY:
logger.warning("TTS_API_KEY not configured - TTS functionality may not work")
if self.ADMIN_TOKEN == 'default-admin-token':
logger.warning("Using default ADMIN_TOKEN - this is insecure for production!")
# Validate URLs
if not self._is_valid_url(self.TTS_SERVER_URL):
logger.error(f"Invalid TTS_SERVER_URL: {self.TTS_SERVER_URL}")
# Check file permissions
if self.UPLOAD_FOLDER and not os.access(self.UPLOAD_FOLDER, os.W_OK):
logger.warning(f"Upload folder {self.UPLOAD_FOLDER} is not writable")
def _is_valid_url(self, url: str) -> bool:
"""Check if URL is valid"""
return url.startswith(('http://', 'https://'))
def to_dict(self) -> dict:
"""Export configuration as dictionary (excluding secrets)"""
config = {}
for key in dir(self):
if key.isupper() and not key.startswith('_'):
value = getattr(self, key)
# Mask sensitive values
if any(sensitive in key for sensitive in ['KEY', 'TOKEN', 'PASSWORD', 'SECRET']):
config[key] = '***MASKED***'
else:
config[key] = value
return config
class DevelopmentConfig(Config):
"""Development configuration"""
def _load_config(self):
super()._load_config()
self.DEBUG = True
self.TESTING = False
self.SESSION_COOKIE_SECURE = False # Allow HTTP in development
class ProductionConfig(Config):
"""Production configuration"""
def _load_config(self):
super()._load_config()
self.DEBUG = False
self.TESTING = False
# Enforce security in production
if not self.SECRET_KEY or self.SECRET_KEY == 'dev-key-change-this':
raise ValueError("SECRET_KEY must be set in production")
if self.ADMIN_TOKEN == 'default-admin-token':
raise ValueError("ADMIN_TOKEN must be changed in production")
class TestingConfig(Config):
"""Testing configuration"""
def _load_config(self):
super()._load_config()
self.DEBUG = True
self.TESTING = True
self.WTF_CSRF_ENABLED = False
self.RATE_LIMIT_ENABLED = False
# Configuration factory
def get_config(env: str = None) -> Config:
"""Get configuration based on environment"""
if env is None:
env = os.environ.get('FLASK_ENV', 'development')
configs = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig
}
config_class = configs.get(env, DevelopmentConfig)
return config_class()
# Convenience function for Flask app
def init_app(app):
"""Initialize Flask app with configuration"""
config = get_config()
# Apply configuration to app
for key in dir(config):
if key.isupper():
app.config[key] = getattr(config, key)
# Store config object
app.app_config = config
logger.info(f"Configuration loaded for environment: {os.environ.get('FLASK_ENV', 'development')}")