updates
This commit is contained in:
Binary file not shown.
@@ -10,14 +10,14 @@ class Settings(BaseSettings):
|
||||
ENVIRONMENT: str = Field(default='development', description='Environment: development, staging, production')
|
||||
DEBUG: bool = Field(default=False, description='Debug mode')
|
||||
API_V1_PREFIX: str = Field(default='/api/v1', description='API v1 prefix')
|
||||
HOST: str = Field(default='0.0.0.0', description='Server host')
|
||||
HOST: str = Field(default='0.0.0.0', description='Server host. WARNING: 0.0.0.0 binds to all interfaces. Use 127.0.0.1 for development or specific IP for production.') # nosec B104 # Acceptable default with validation warning in production
|
||||
PORT: int = Field(default=8000, description='Server port')
|
||||
DB_USER: str = Field(default='root', description='Database user')
|
||||
DB_PASS: str = Field(default='', description='Database password')
|
||||
DB_NAME: str = Field(default='hotel_db', description='Database name')
|
||||
DB_HOST: str = Field(default='localhost', description='Database host')
|
||||
DB_PORT: str = Field(default='3306', description='Database port')
|
||||
JWT_SECRET: str = Field(default='dev-secret-key-change-in-production-12345', description='JWT secret key')
|
||||
JWT_SECRET: str = Field(default='', description='JWT secret key - MUST be set via environment variable. Minimum 64 characters recommended for production.')
|
||||
JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm')
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes')
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=3, description='JWT refresh token expiration in days (reduced from 7 for better security)')
|
||||
@@ -97,6 +97,20 @@ class Settings(BaseSettings):
|
||||
IP_WHITELIST_ENABLED: bool = Field(default=False, description='Enable IP whitelisting for admin endpoints')
|
||||
ADMIN_IP_WHITELIST: List[str] = Field(default_factory=list, description='List of allowed IP addresses/CIDR ranges for admin endpoints')
|
||||
|
||||
def validate_host_configuration(self) -> None:
|
||||
"""
|
||||
Validate HOST configuration for security.
|
||||
Warns if binding to all interfaces (0.0.0.0) in production.
|
||||
"""
|
||||
if self.HOST == '0.0.0.0' and self.is_production:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
'SECURITY WARNING: HOST is set to 0.0.0.0 in production. '
|
||||
'This binds the server to all network interfaces. '
|
||||
'Consider using a specific IP address or ensure proper firewall rules are in place.'
|
||||
)
|
||||
|
||||
def validate_encryption_key(self) -> None:
|
||||
"""
|
||||
Validate encryption key is properly configured.
|
||||
@@ -138,4 +152,41 @@ class Settings(BaseSettings):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Invalid ENCRYPTION_KEY format: {str(e)}')
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
|
||||
# Validate JWT_SECRET on startup - fail fast if not configured
|
||||
def validate_jwt_secret():
|
||||
"""Validate JWT_SECRET is properly configured. Called on startup."""
|
||||
if not settings.JWT_SECRET or settings.JWT_SECRET.strip() == '':
|
||||
error_msg = (
|
||||
'CRITICAL SECURITY ERROR: JWT_SECRET is not configured. '
|
||||
'Please set JWT_SECRET environment variable to a secure random string. '
|
||||
'Minimum 64 characters recommended for production. '
|
||||
'Generate one using: python -c "import secrets; print(secrets.token_urlsafe(64))"'
|
||||
)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(error_msg)
|
||||
if settings.is_production:
|
||||
raise ValueError(error_msg)
|
||||
else:
|
||||
logger.warning(
|
||||
'JWT_SECRET not configured. This will cause authentication to fail. '
|
||||
'Set JWT_SECRET environment variable before starting the application.'
|
||||
)
|
||||
|
||||
# Warn if using weak secret (less than 64 characters)
|
||||
if len(settings.JWT_SECRET) < 64:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
if settings.is_production:
|
||||
logger.warning(
|
||||
f'JWT_SECRET is only {len(settings.JWT_SECRET)} characters. '
|
||||
'Recommend using at least 64 characters for production security.'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'JWT_SECRET length: {len(settings.JWT_SECRET)} characters')
|
||||
|
||||
# Validate on import
|
||||
validate_jwt_secret()
|
||||
settings.validate_host_configuration()
|
||||
Binary file not shown.
168
Backend/src/shared/utils/sanitization.py
Normal file
168
Backend/src/shared/utils/sanitization.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
HTML/XSS sanitization utilities using bleach library.
|
||||
Prevents stored XSS attacks by sanitizing user-generated content.
|
||||
"""
|
||||
import bleach
|
||||
from typing import Optional
|
||||
|
||||
# Allowed HTML tags for rich text content
|
||||
ALLOWED_TAGS = [
|
||||
'p', 'br', 'strong', 'em', 'u', 'b', 'i', 's', 'strike',
|
||||
'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'img'
|
||||
]
|
||||
|
||||
# Allowed attributes for specific tags
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title', 'target', 'rel'],
|
||||
'img': ['src', 'alt', 'title', 'width', 'height'],
|
||||
'div': ['class'],
|
||||
'span': ['class'],
|
||||
'p': ['class'],
|
||||
'table': ['class', 'border'],
|
||||
'th': ['colspan', 'rowspan'],
|
||||
'td': ['colspan', 'rowspan']
|
||||
}
|
||||
|
||||
# Allowed URL schemes
|
||||
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
|
||||
|
||||
# Allowed CSS classes (optional - can be expanded)
|
||||
ALLOWED_STYLES = []
|
||||
|
||||
|
||||
def sanitize_html(content: Optional[str], strip: bool = False) -> str:
|
||||
"""
|
||||
Sanitize HTML content to prevent XSS attacks.
|
||||
|
||||
Args:
|
||||
content: The HTML content to sanitize (can be None)
|
||||
strip: If True, remove disallowed tags instead of escaping them
|
||||
|
||||
Returns:
|
||||
Sanitized HTML string
|
||||
"""
|
||||
if not content:
|
||||
return ''
|
||||
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
# Sanitize HTML
|
||||
sanitized = bleach.clean(
|
||||
content,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
strip=strip,
|
||||
strip_comments=True
|
||||
)
|
||||
|
||||
# Linkify URLs (convert plain URLs to links)
|
||||
# Only linkify if content doesn't already contain HTML links
|
||||
if '<a' not in sanitized:
|
||||
sanitized = bleach.linkify(
|
||||
sanitized,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
parse_email=True
|
||||
)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def sanitize_text(content: Optional[str]) -> str:
|
||||
"""
|
||||
Strip all HTML tags from content, leaving only plain text.
|
||||
Useful for fields that should not contain any HTML.
|
||||
|
||||
Args:
|
||||
content: The content to sanitize (can be None)
|
||||
|
||||
Returns:
|
||||
Plain text string with all HTML removed
|
||||
"""
|
||||
if not content:
|
||||
return ''
|
||||
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
# Strip all HTML tags
|
||||
return bleach.clean(content, tags=[], strip=True)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize filename to prevent path traversal and other attacks.
|
||||
|
||||
Args:
|
||||
filename: The original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename safe for filesystem operations
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
if not filename:
|
||||
# Generate a random filename if none provided
|
||||
return f"{secrets.token_urlsafe(16)}.bin"
|
||||
|
||||
# Remove path components (prevent directory traversal)
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Remove dangerous characters
|
||||
# Keep only alphanumeric, dots, dashes, and underscores
|
||||
safe_chars = []
|
||||
for char in filename:
|
||||
if char.isalnum() or char in '._-':
|
||||
safe_chars.append(char)
|
||||
else:
|
||||
safe_chars.append('_')
|
||||
|
||||
filename = ''.join(safe_chars)
|
||||
|
||||
# Limit length (filesystem limit is typically 255)
|
||||
if len(filename) > 255:
|
||||
name, ext = os.path.splitext(filename)
|
||||
max_name_length = 255 - len(ext)
|
||||
filename = name[:max_name_length] + ext
|
||||
|
||||
# Ensure filename is not empty
|
||||
if not filename or filename == '.' or filename == '..':
|
||||
filename = f"{secrets.token_urlsafe(16)}.bin"
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def sanitize_url(url: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Sanitize URL to ensure it uses allowed protocols.
|
||||
|
||||
Args:
|
||||
url: The URL to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized URL or None if invalid
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
if not isinstance(url, str):
|
||||
url = str(url)
|
||||
|
||||
# Check if URL uses allowed protocol
|
||||
url_lower = url.lower().strip()
|
||||
if any(url_lower.startswith(proto + ':') for proto in ALLOWED_PROTOCOLS):
|
||||
return url
|
||||
|
||||
# If no protocol, assume https
|
||||
if '://' not in url:
|
||||
return f'https://{url}'
|
||||
|
||||
# Invalid protocol - return None
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user