This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -95,10 +95,16 @@ else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f'Allowed CORS origins: {", ".join(settings.CORS_ORIGINS)}')
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS or [], allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*'])
# SECURITY: Use explicit headers instead of wildcard to prevent header injection
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS or [],
allow_credentials=True,
allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allow_headers=['Content-Type', 'Authorization', 'X-XSRF-TOKEN', 'X-Requested-With', 'X-Request-ID', 'Accept', 'Accept-Language']
)
uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR
uploads_dir.mkdir(exist_ok=True)
app.mount('/uploads', StaticFiles(directory=str(uploads_dir)), name='uploads')
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(IntegrityError, integrity_error_handler)
@@ -108,18 +114,18 @@ app.add_exception_handler(Exception, general_exception_handler)
@app.get('/health', tags=['health'])
@app.get('/api/health', tags=['health'])
async def health_check(db: Session=Depends(get_db)):
"""Comprehensive health check endpoint"""
"""
Public health check endpoint.
Returns minimal information for security - no sensitive details exposed.
"""
health_status = {
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat(),
'service': settings.APP_NAME,
'version': settings.APP_VERSION,
'environment': settings.ENVIRONMENT,
# SECURITY: Don't expose service name, version, or environment in public endpoint
'checks': {
'api': 'ok',
'database': 'unknown',
'disk_space': 'unknown',
'memory': 'unknown'
'database': 'unknown'
# SECURITY: Don't expose disk_space or memory details publicly
}
}
@@ -131,60 +137,26 @@ async def health_check(db: Session=Depends(get_db)):
except OperationalError as e:
health_status['status'] = 'unhealthy'
health_status['checks']['database'] = 'error'
health_status['error'] = str(e)
# SECURITY: Don't expose database error details publicly
logger.error(f'Database health check failed: {str(e)}')
# Remove error details from response
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status)
except Exception as e:
health_status['status'] = 'unhealthy'
health_status['checks']['database'] = 'error'
health_status['error'] = str(e)
# SECURITY: Don't expose error details publicly
logger.error(f'Health check failed: {str(e)}')
# Remove error details from response
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status)
# Check disk space (if available)
try:
import shutil
disk = shutil.disk_usage('/')
free_percent = (disk.free / disk.total) * 100
if free_percent < 10:
health_status['checks']['disk_space'] = 'warning'
health_status['status'] = 'degraded'
else:
health_status['checks']['disk_space'] = 'ok'
health_status['disk_space'] = {
'free_gb': round(disk.free / (1024**3), 2),
'total_gb': round(disk.total / (1024**3), 2),
'free_percent': round(free_percent, 2)
}
except Exception:
health_status['checks']['disk_space'] = 'unknown'
# Check memory (if available)
try:
import psutil
memory = psutil.virtual_memory()
if memory.percent > 90:
health_status['checks']['memory'] = 'warning'
if health_status['status'] == 'healthy':
health_status['status'] = 'degraded'
else:
health_status['checks']['memory'] = 'ok'
health_status['memory'] = {
'used_percent': round(memory.percent, 2),
'available_gb': round(memory.available / (1024**3), 2),
'total_gb': round(memory.total / (1024**3), 2)
}
except ImportError:
# psutil not available, skip memory check
health_status['checks']['memory'] = 'unavailable'
except Exception:
health_status['checks']['memory'] = 'unknown'
# SECURITY: Disk space and memory checks removed from public endpoint
# These details should only be available on internal/admin health endpoint
# Determine overall status
if health_status['status'] == 'healthy' and any(
check == 'warning' for check in health_status['checks'].values()
check == 'error' for check in health_status['checks'].values()
):
health_status['status'] = 'degraded'
health_status['status'] = 'unhealthy'
status_code = status.HTTP_200_OK
if health_status['status'] == 'unhealthy':
@@ -195,8 +167,110 @@ async def health_check(db: Session=Depends(get_db)):
return JSONResponse(status_code=status_code, content=health_status)
@app.get('/metrics', tags=['monitoring'])
async def metrics():
return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()}
async def metrics(
current_user = Depends(lambda: None)
):
"""
Protected metrics endpoint - requires admin or staff authentication.
SECURITY: Prevents information disclosure to unauthorized users.
"""
from ..security.middleware.auth import authorize_roles
# Only allow admin and staff to access metrics
# Use authorize_roles as dependency - it will check authorization automatically
admin_or_staff = authorize_roles('admin', 'staff')
# FastAPI will inject dependencies when this dependency is resolved
current_user = admin_or_staff()
return {
'status': 'success',
'service': settings.APP_NAME,
'version': settings.APP_VERSION,
'environment': settings.ENVIRONMENT,
'timestamp': datetime.utcnow().isoformat()
}
# Custom route for serving uploads with CORS headers
# This route takes precedence over the mount below
from fastapi.responses import FileResponse
import re
@app.options('/uploads/{file_path:path}')
async def serve_upload_file_options(file_path: str, request: Request):
"""Handle CORS preflight for upload files."""
origin = request.headers.get('origin')
if origin:
if settings.is_development:
if re.match(r'http://(localhost|127\.0\.0\.1)(:\d+)?', origin):
return JSONResponse(
content={},
headers={
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '3600'
}
)
elif origin in (settings.CORS_ORIGINS or []):
return JSONResponse(
content={},
headers={
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '3600'
}
)
return JSONResponse(content={})
@app.get('/uploads/{file_path:path}')
@app.head('/uploads/{file_path:path}')
async def serve_upload_file(file_path: str, request: Request):
"""Serve uploaded files with proper CORS headers."""
file_location = uploads_dir / file_path
# Security: Prevent directory traversal
try:
resolved_path = file_location.resolve()
resolved_uploads = uploads_dir.resolve()
if not str(resolved_path).startswith(str(resolved_uploads)):
raise HTTPException(status_code=403, detail="Access denied")
except (ValueError, OSError):
raise HTTPException(status_code=404, detail="File not found")
if not file_location.exists() or not file_location.is_file():
raise HTTPException(status_code=404, detail="File not found")
# Get origin from request
origin = request.headers.get('origin')
# Prepare response
response = FileResponse(str(file_location))
# Add CORS headers if origin matches
if origin:
if settings.is_development:
if re.match(r'http://(localhost|127\.0\.0\.1)(:\d+)?', origin):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = '*'
response.headers['Access-Control-Expose-Headers'] = '*'
elif origin in (settings.CORS_ORIGINS or []):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = '*'
response.headers['Access-Control-Expose-Headers'] = '*'
return response
# Mount static files as fallback (routes take precedence)
from starlette.staticfiles import StaticFiles
app.mount('/uploads-static', StaticFiles(directory=str(uploads_dir)), name='uploads-static')
# Import all route modules from feature-based structure
from .auth.routes import auth_routes, user_routes
from .rooms.routes import room_routes, advanced_room_routes, rate_plan_routes
@@ -219,6 +293,7 @@ from .security.routes import security_routes, compliance_routes
from .system.routes import system_settings_routes, workflow_routes, task_routes, approval_routes, backup_routes
from .ai.routes import ai_assistant_routes
from .compliance.routes import gdpr_routes
from .compliance.routes.gdpr_admin_routes import router as gdpr_admin_routes
from .integrations.routes import webhook_routes, api_key_routes
from .auth.routes import session_routes
@@ -274,6 +349,7 @@ app.include_router(blog_routes.router, prefix=api_prefix)
app.include_router(ai_assistant_routes.router, prefix=api_prefix)
app.include_router(approval_routes.router, prefix=api_prefix)
app.include_router(gdpr_routes.router, prefix=api_prefix)
app.include_router(gdpr_admin_routes, prefix=api_prefix)
app.include_router(webhook_routes.router, prefix=api_prefix)
app.include_router(api_key_routes.router, prefix=api_prefix)
app.include_router(session_routes.router, prefix=api_prefix)
@@ -281,57 +357,38 @@ app.include_router(backup_routes.router, prefix=api_prefix)
logger.info('All routes registered successfully')
def ensure_jwt_secret():
"""Generate and save JWT secret if it's using the default value.
In production, fail fast if default secret is used for security.
In development, auto-generate a secure secret if needed.
"""
default_secret = 'dev-secret-key-change-in-production-12345'
Validate JWT secret is properly configured.
SECURITY: JWT_SECRET must be explicitly set via environment variable.
No default values are acceptable for security.
"""
current_secret = settings.JWT_SECRET
# Security check: Fail fast in production if using default secret
if settings.is_production and (not current_secret or current_secret == default_secret):
error_msg = (
'CRITICAL SECURITY ERROR: JWT_SECRET is using default value in production! '
'Please set a secure JWT_SECRET in your environment variables.'
)
logger.error(error_msg)
raise ValueError(error_msg)
# Development mode: Auto-generate if needed
if not current_secret or current_secret == default_secret:
new_secret = secrets.token_urlsafe(64)
os.environ['JWT_SECRET'] = new_secret
env_file = Path(__file__).parent.parent / '.env'
if env_file.exists():
try:
env_content = env_file.read_text(encoding='utf-8')
jwt_pattern = re.compile(r'^JWT_SECRET=.*$', re.MULTILINE)
if jwt_pattern.search(env_content):
env_content = jwt_pattern.sub(f'JWT_SECRET={new_secret}', env_content)
else:
jwt_section_pattern = re.compile(r'(# =+.*JWT.*=+.*\n)', re.IGNORECASE | re.MULTILINE)
match = jwt_section_pattern.search(env_content)
if match:
insert_pos = match.end()
env_content = env_content[:insert_pos] + f'JWT_SECRET={new_secret}\n' + env_content[insert_pos:]
else:
env_content += f'\nJWT_SECRET={new_secret}\n'
env_file.write_text(env_content, encoding='utf-8')
logger.info('✓ JWT secret generated and saved to .env file')
except Exception as e:
logger.warning(f'Could not update .env file: {e}')
logger.info(f'Generated JWT secret (add to .env manually): JWT_SECRET={new_secret}')
# SECURITY: JWT_SECRET validation is now handled in settings.py
# This function is kept for backward compatibility and logging
if not current_secret or current_secret.strip() == '':
if settings.is_production:
# This should not happen as settings validation should catch it
error_msg = (
'CRITICAL SECURITY ERROR: JWT_SECRET is not configured. '
'Please set JWT_SECRET environment variable before starting the application.'
)
logger.error(error_msg)
raise ValueError(error_msg)
else:
logger.info(f'Generated JWT secret (add to .env file): JWT_SECRET={new_secret}')
logger.info('✓ Secure JWT secret generated automatically')
logger.warning(
'JWT_SECRET is not configured. Authentication will fail. '
'Set JWT_SECRET environment variable before starting the application.'
)
else:
# Validate secret strength
if len(current_secret) < 64:
if settings.is_production:
logger.warning(
f'JWT_SECRET is only {len(current_secret)} characters. '
'Recommend using at least 64 characters for production security.'
)
logger.info('✓ JWT secret is configured')
@app.on_event('startup')
@@ -375,7 +432,34 @@ async def shutdown_event():
logger.info(f'{settings.APP_NAME} shutting down gracefully')
if __name__ == '__main__':
import uvicorn
import os
import signal
import sys
from pathlib import Path
def signal_handler(sig, frame):
"""Handle Ctrl+C gracefully."""
logger.info('\nReceived interrupt signal (Ctrl+C). Shutting down gracefully...')
sys.exit(0)
# Register signal handler for graceful shutdown on Ctrl+C
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
base_dir = Path(__file__).parent.parent
src_dir = str(base_dir / 'src')
uvicorn.run('src.main:app', host=settings.HOST, port=settings.PORT, reload=settings.is_development, log_level=settings.LOG_LEVEL.lower(), reload_dirs=[src_dir] if settings.is_development else None, reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3'], reload_delay=0.5)
# Enable hot reload in development mode or if explicitly enabled via environment variable
use_reload = settings.is_development or os.getenv('ENABLE_RELOAD', 'false').lower() == 'true'
if use_reload:
logger.info('Hot reload enabled - server will restart on code changes')
logger.info('Press Ctrl+C to stop the server')
uvicorn.run(
'src.main:app',
host=settings.HOST,
port=settings.PORT,
reload=use_reload,
log_level=settings.LOG_LEVEL.lower(),
reload_dirs=[src_dir] if use_reload else None,
reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3', 'venv/**', '.venv/**'],
reload_delay=0.5
)