updates
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user