feat: Refactor rides app with unique constraints, mixins, and enhanced documentation

- Added migration to convert unique_together constraints to UniqueConstraint for RideModel.
- Introduced RideFormMixin for handling entity suggestions in ride forms.
- Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements.
- Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling.
- Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples.
- Implemented a benchmarking script for query performance analysis and optimization.
- Developed security documentation detailing measures, configurations, and a security checklist.
- Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields.
This commit is contained in:
pacnpal
2025-12-22 11:17:31 -05:00
parent 45d97b6e68
commit 2e35f8c5d9
71 changed files with 8036 additions and 1462 deletions

View File

@@ -4,3 +4,12 @@ from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
def ready(self):
"""
Application initialization.
Imports security checks to register them with Django's check framework.
"""
# Import security checks to register them
from . import checks # noqa: F401

372
backend/apps/core/checks.py Normal file
View File

@@ -0,0 +1,372 @@
"""
Django System Checks for Security Configuration.
This module implements Django system checks that validate security settings
at startup. These checks help catch security misconfigurations before
deployment.
Usage:
These checks run automatically when Django starts. They can also be run
manually with: python manage.py check --tag=security
Security checks included:
- SECRET_KEY validation (not default, sufficient entropy)
- DEBUG mode check (should be False in production)
- ALLOWED_HOSTS check (should be configured in production)
- Security headers validation
- HTTPS settings validation
- Cookie security settings
"""
import os
import re
from django.conf import settings
from django.core.checks import Error, Warning, register, Tags
# =============================================================================
# Secret Key Validation
# =============================================================================
@register(Tags.security)
def check_secret_key(app_configs, **kwargs):
"""
Check that SECRET_KEY is properly configured.
Validates:
- Key is not a known default/placeholder value
- Key has sufficient entropy (length and character variety)
"""
errors = []
secret_key = getattr(settings, 'SECRET_KEY', '')
# Check for empty or missing key
if not secret_key:
errors.append(
Error(
'SECRET_KEY is not set.',
hint='Set a strong, random SECRET_KEY in your environment.',
id='security.E001',
)
)
return errors
# Check for known insecure default values
insecure_defaults = [
'django-insecure',
'your-secret-key',
'change-me',
'changeme',
'secret',
'xxx',
'test',
'development',
'dev-key',
]
key_lower = secret_key.lower()
for default in insecure_defaults:
if default in key_lower:
errors.append(
Error(
f'SECRET_KEY appears to contain an insecure default value: "{default}"',
hint='Generate a new secret key using: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"',
id='security.E002',
)
)
break
# Check minimum length (Django recommends at least 50 characters)
if len(secret_key) < 50:
errors.append(
Warning(
f'SECRET_KEY is only {len(secret_key)} characters long.',
hint='A secret key should be at least 50 characters for proper security.',
id='security.W001',
)
)
# Check for sufficient character variety
has_upper = bool(re.search(r'[A-Z]', secret_key))
has_lower = bool(re.search(r'[a-z]', secret_key))
has_digit = bool(re.search(r'[0-9]', secret_key))
has_special = bool(re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]', secret_key))
char_types = sum([has_upper, has_lower, has_digit, has_special])
if char_types < 3:
errors.append(
Warning(
'SECRET_KEY lacks character variety.',
hint='A good secret key should contain uppercase, lowercase, digits, and special characters.',
id='security.W002',
)
)
return errors
# =============================================================================
# Debug Mode Check
# =============================================================================
@register(Tags.security)
def check_debug_mode(app_configs, **kwargs):
"""
Check that DEBUG is False in production-like environments.
"""
errors = []
# Check if we're in a production-like environment
env = os.environ.get('DJANGO_SETTINGS_MODULE', '')
is_production = 'production' in env.lower() or 'prod' in env.lower()
if is_production and settings.DEBUG:
errors.append(
Error(
'DEBUG is True in what appears to be a production environment.',
hint='Set DEBUG=False in production settings.',
id='security.E003',
)
)
# Also check if DEBUG is True with ALLOWED_HOSTS configured
# (indicates possible production deployment with debug on)
if settings.DEBUG and settings.ALLOWED_HOSTS and '*' not in settings.ALLOWED_HOSTS:
if len(settings.ALLOWED_HOSTS) > 0 and 'localhost' not in settings.ALLOWED_HOSTS[0]:
errors.append(
Warning(
'DEBUG is True but ALLOWED_HOSTS contains non-localhost values.',
hint='This may indicate DEBUG is accidentally enabled in a deployed environment.',
id='security.W003',
)
)
return errors
# =============================================================================
# ALLOWED_HOSTS Check
# =============================================================================
@register(Tags.security)
def check_allowed_hosts(app_configs, **kwargs):
"""
Check ALLOWED_HOSTS configuration.
"""
errors = []
allowed_hosts = getattr(settings, 'ALLOWED_HOSTS', [])
if not settings.DEBUG:
# In non-debug mode, ALLOWED_HOSTS must be set
if not allowed_hosts:
errors.append(
Error(
'ALLOWED_HOSTS is empty but DEBUG is False.',
hint='Set ALLOWED_HOSTS to a list of allowed hostnames.',
id='security.E004',
)
)
elif '*' in allowed_hosts:
errors.append(
Error(
'ALLOWED_HOSTS contains "*" which allows all hosts.',
hint='Specify explicit hostnames instead of wildcards.',
id='security.E005',
)
)
return errors
# =============================================================================
# Security Headers Check
# =============================================================================
@register(Tags.security)
def check_security_headers(app_configs, **kwargs):
"""
Check that security headers are properly configured.
"""
errors = []
# Check X-Frame-Options
x_frame_options = getattr(settings, 'X_FRAME_OPTIONS', None)
if x_frame_options not in ('DENY', 'SAMEORIGIN'):
errors.append(
Warning(
f'X_FRAME_OPTIONS is set to "{x_frame_options}" or not set.',
hint='Set X_FRAME_OPTIONS to "DENY" or "SAMEORIGIN" to prevent clickjacking.',
id='security.W004',
)
)
# Check content type sniffing protection
if not getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False):
errors.append(
Warning(
'SECURE_CONTENT_TYPE_NOSNIFF is not enabled.',
hint='Set SECURE_CONTENT_TYPE_NOSNIFF = True to prevent MIME type sniffing.',
id='security.W005',
)
)
# Check referrer policy
referrer_policy = getattr(settings, 'SECURE_REFERRER_POLICY', None)
if not referrer_policy:
errors.append(
Warning(
'SECURE_REFERRER_POLICY is not set.',
hint='Set SECURE_REFERRER_POLICY to control referrer header behavior.',
id='security.W006',
)
)
return errors
# =============================================================================
# HTTPS Settings Check
# =============================================================================
@register(Tags.security)
def check_https_settings(app_configs, **kwargs):
"""
Check HTTPS-related security settings for production.
"""
errors = []
# Skip these checks in debug mode
if settings.DEBUG:
return errors
# Check SSL redirect
if not getattr(settings, 'SECURE_SSL_REDIRECT', False):
errors.append(
Warning(
'SECURE_SSL_REDIRECT is not enabled.',
hint='Set SECURE_SSL_REDIRECT = True to redirect HTTP to HTTPS.',
id='security.W007',
)
)
# Check HSTS settings
hsts_seconds = getattr(settings, 'SECURE_HSTS_SECONDS', 0)
if hsts_seconds < 31536000: # Less than 1 year
errors.append(
Warning(
f'SECURE_HSTS_SECONDS is {hsts_seconds} (less than 1 year).',
hint='Set SECURE_HSTS_SECONDS to at least 31536000 (1 year) for HSTS preload eligibility.',
id='security.W008',
)
)
if not getattr(settings, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False):
errors.append(
Warning(
'SECURE_HSTS_INCLUDE_SUBDOMAINS is not enabled.',
hint='Set SECURE_HSTS_INCLUDE_SUBDOMAINS = True to include all subdomains in HSTS.',
id='security.W009',
)
)
return errors
# =============================================================================
# Cookie Security Check
# =============================================================================
@register(Tags.security)
def check_cookie_security(app_configs, **kwargs):
"""
Check cookie security settings for production.
"""
errors = []
# Skip in debug mode
if settings.DEBUG:
return errors
# Check session cookie security
if not getattr(settings, 'SESSION_COOKIE_SECURE', False):
errors.append(
Warning(
'SESSION_COOKIE_SECURE is not enabled.',
hint='Set SESSION_COOKIE_SECURE = True to only send session cookies over HTTPS.',
id='security.W010',
)
)
if not getattr(settings, 'SESSION_COOKIE_HTTPONLY', True):
errors.append(
Warning(
'SESSION_COOKIE_HTTPONLY is disabled.',
hint='Set SESSION_COOKIE_HTTPONLY = True to prevent JavaScript access to session cookies.',
id='security.W011',
)
)
# Check CSRF cookie security
if not getattr(settings, 'CSRF_COOKIE_SECURE', False):
errors.append(
Warning(
'CSRF_COOKIE_SECURE is not enabled.',
hint='Set CSRF_COOKIE_SECURE = True to only send CSRF cookies over HTTPS.',
id='security.W012',
)
)
# Check SameSite attributes
session_samesite = getattr(settings, 'SESSION_COOKIE_SAMESITE', 'Lax')
if session_samesite not in ('Strict', 'Lax'):
errors.append(
Warning(
f'SESSION_COOKIE_SAMESITE is set to "{session_samesite}".',
hint='Set SESSION_COOKIE_SAMESITE to "Strict" or "Lax" for CSRF protection.',
id='security.W013',
)
)
return errors
# =============================================================================
# Database Security Check
# =============================================================================
@register(Tags.security)
def check_database_security(app_configs, **kwargs):
"""
Check database connection security settings.
"""
errors = []
# Skip in debug mode
if settings.DEBUG:
return errors
databases = getattr(settings, 'DATABASES', {})
default_db = databases.get('default', {})
# Check for empty password
if not default_db.get('PASSWORD') and default_db.get('ENGINE', '').endswith('postgresql'):
errors.append(
Warning(
'Database password is empty.',
hint='Set a strong password for database authentication.',
id='security.W014',
)
)
# Check for SSL mode in PostgreSQL
options = default_db.get('OPTIONS', {})
if 'sslmode' not in str(options) and default_db.get('ENGINE', '').endswith('postgresql'):
errors.append(
Warning(
'Database SSL mode is not explicitly configured.',
hint='Consider setting sslmode in database OPTIONS for encrypted connections.',
id='security.W015',
)
)
return errors

View File

@@ -65,6 +65,14 @@ class BusinessLogicError(ThrillWikiException):
status_code = 400
class ServiceError(ThrillWikiException):
"""Raised when a service operation fails."""
default_message = "Service operation failed"
error_code = "SERVICE_ERROR"
status_code = 500
class ExternalServiceError(ThrillWikiException):
"""Raised when external service calls fail."""

View File

@@ -0,0 +1,240 @@
"""
Security Audit Management Command.
Runs comprehensive security checks on the Django application and generates
a security audit report.
Usage:
python manage.py security_audit
python manage.py security_audit --output report.txt
python manage.py security_audit --verbose
"""
from django.core.management.base import BaseCommand
from django.core.checks import registry, Tags
from django.conf import settings
class Command(BaseCommand):
help = 'Run security audit and generate a report'
def add_arguments(self, parser):
parser.add_argument(
'--output',
type=str,
help='Output file for the security report',
)
parser.add_argument(
'--verbose',
action='store_true',
help='Show detailed information for each check',
)
def handle(self, *args, **options):
self.verbose = options.get('verbose', False)
output_file = options.get('output')
report_lines = []
self.log("=" * 60, report_lines)
self.log("ThrillWiki Security Audit Report", report_lines)
self.log("=" * 60, report_lines)
self.log("", report_lines)
# Run Django's built-in security checks
self.log("Running Django Security Checks...", report_lines)
self.log("-" * 40, report_lines)
self.run_django_checks(report_lines)
# Run custom configuration checks
self.log("", report_lines)
self.log("Configuration Analysis...", report_lines)
self.log("-" * 40, report_lines)
self.check_configuration(report_lines)
# Run middleware checks
self.log("", report_lines)
self.log("Middleware Analysis...", report_lines)
self.log("-" * 40, report_lines)
self.check_middleware(report_lines)
# Summary
self.log("", report_lines)
self.log("=" * 60, report_lines)
self.log("Audit Complete", report_lines)
self.log("=" * 60, report_lines)
# Write to file if specified
if output_file:
with open(output_file, 'w') as f:
f.write('\n'.join(report_lines))
self.stdout.write(
self.style.SUCCESS(f'\nReport saved to: {output_file}')
)
def log(self, message, report_lines):
"""Log message to both stdout and report."""
self.stdout.write(message)
report_lines.append(message)
def run_django_checks(self, report_lines):
"""Run Django's security checks."""
errors = registry.run_checks(tags=[Tags.security])
if not errors:
self.log(
self.style.SUCCESS(" ✓ All Django security checks passed"),
report_lines
)
else:
for error in errors:
if error.is_serious():
prefix = self.style.ERROR(" ✗ ERROR")
else:
prefix = self.style.WARNING(" ! WARNING")
self.log(f"{prefix}: {error.msg}", report_lines)
if error.hint and self.verbose:
self.log(f" Hint: {error.hint}", report_lines)
def check_configuration(self, report_lines):
"""Check various configuration settings."""
checks = [
('DEBUG mode', not settings.DEBUG, 'DEBUG should be False'),
(
'SECRET_KEY length',
len(settings.SECRET_KEY) >= 50,
f'Length: {len(settings.SECRET_KEY)}'
),
(
'ALLOWED_HOSTS',
bool(settings.ALLOWED_HOSTS) and '*' not in settings.ALLOWED_HOSTS,
str(settings.ALLOWED_HOSTS)
),
(
'CSRF_TRUSTED_ORIGINS',
bool(getattr(settings, 'CSRF_TRUSTED_ORIGINS', [])),
str(getattr(settings, 'CSRF_TRUSTED_ORIGINS', []))
),
(
'X_FRAME_OPTIONS',
getattr(settings, 'X_FRAME_OPTIONS', '') in ('DENY', 'SAMEORIGIN'),
str(getattr(settings, 'X_FRAME_OPTIONS', 'Not set'))
),
(
'SECURE_CONTENT_TYPE_NOSNIFF',
getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False),
str(getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False))
),
(
'SECURE_BROWSER_XSS_FILTER',
getattr(settings, 'SECURE_BROWSER_XSS_FILTER', False),
str(getattr(settings, 'SECURE_BROWSER_XSS_FILTER', False))
),
(
'SESSION_COOKIE_HTTPONLY',
getattr(settings, 'SESSION_COOKIE_HTTPONLY', True),
str(getattr(settings, 'SESSION_COOKIE_HTTPONLY', 'Not set'))
),
(
'CSRF_COOKIE_HTTPONLY',
getattr(settings, 'CSRF_COOKIE_HTTPONLY', True),
str(getattr(settings, 'CSRF_COOKIE_HTTPONLY', 'Not set'))
),
]
# Production-only checks
if not settings.DEBUG:
checks.extend([
(
'SECURE_SSL_REDIRECT',
getattr(settings, 'SECURE_SSL_REDIRECT', False),
str(getattr(settings, 'SECURE_SSL_REDIRECT', False))
),
(
'SESSION_COOKIE_SECURE',
getattr(settings, 'SESSION_COOKIE_SECURE', False),
str(getattr(settings, 'SESSION_COOKIE_SECURE', False))
),
(
'CSRF_COOKIE_SECURE',
getattr(settings, 'CSRF_COOKIE_SECURE', False),
str(getattr(settings, 'CSRF_COOKIE_SECURE', False))
),
(
'SECURE_HSTS_SECONDS',
getattr(settings, 'SECURE_HSTS_SECONDS', 0) >= 31536000,
str(getattr(settings, 'SECURE_HSTS_SECONDS', 0))
),
])
for name, is_secure, value in checks:
if is_secure:
status = self.style.SUCCESS("")
else:
status = self.style.WARNING("!")
msg = f" {status} {name}"
if self.verbose:
msg += f" ({value})"
self.log(msg, report_lines)
def check_middleware(self, report_lines):
"""Check security-related middleware is properly configured."""
middleware = getattr(settings, 'MIDDLEWARE', [])
required_middleware = [
('django.middleware.security.SecurityMiddleware', 'SecurityMiddleware'),
('django.middleware.csrf.CsrfViewMiddleware', 'CSRF Middleware'),
('django.middleware.clickjacking.XFrameOptionsMiddleware', 'X-Frame-Options'),
]
custom_security_middleware = [
('apps.core.middleware.security_headers.SecurityHeadersMiddleware', 'Security Headers'),
('apps.core.middleware.rate_limiting.AuthRateLimitMiddleware', 'Rate Limiting'),
]
# Check required middleware
for mw_path, mw_name in required_middleware:
if mw_path in middleware:
self.log(
f" {self.style.SUCCESS('')} {mw_name} is enabled",
report_lines
)
else:
self.log(
f" {self.style.ERROR('')} {mw_name} is NOT enabled",
report_lines
)
# Check custom security middleware
for mw_path, mw_name in custom_security_middleware:
if mw_path in middleware:
self.log(
f" {self.style.SUCCESS('')} {mw_name} is enabled",
report_lines
)
else:
self.log(
f" {self.style.WARNING('!')} {mw_name} is not enabled (optional)",
report_lines
)
# Check middleware order
try:
security_idx = middleware.index('django.middleware.security.SecurityMiddleware')
session_idx = middleware.index('django.contrib.sessions.middleware.SessionMiddleware')
if security_idx < session_idx:
self.log(
f" {self.style.SUCCESS('')} Middleware ordering is correct",
report_lines
)
else:
self.log(
f" {self.style.WARNING('!')} SecurityMiddleware should come before SessionMiddleware",
report_lines
)
except ValueError:
pass # Middleware not found, already reported above

View File

@@ -0,0 +1,253 @@
"""
Rate Limiting Middleware for ThrillWiki.
This middleware provides rate limiting for authentication endpoints to prevent
brute force attacks, credential stuffing, and account enumeration.
Security Note:
Rate limiting is applied at the IP level and user level (if authenticated).
Limits are configurable and should be adjusted based on actual usage patterns.
Usage:
Add 'apps.core.middleware.rate_limiting.AuthRateLimitMiddleware'
to MIDDLEWARE in settings.py.
"""
import logging
from typing import Callable, Optional, Tuple
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.conf import settings
logger = logging.getLogger(__name__)
class AuthRateLimitMiddleware:
"""
Middleware that rate limits authentication-related endpoints.
Protects against:
- Brute force login attacks
- Password reset abuse
- Account enumeration through timing attacks
"""
# Endpoints to rate limit
RATE_LIMITED_PATHS = {
# Login endpoints
'/api/v1/auth/login/': {'per_minute': 5, 'per_hour': 30, 'per_day': 100},
'/accounts/login/': {'per_minute': 5, 'per_hour': 30, 'per_day': 100},
# Signup endpoints
'/api/v1/auth/signup/': {'per_minute': 3, 'per_hour': 10, 'per_day': 20},
'/accounts/signup/': {'per_minute': 3, 'per_hour': 10, 'per_day': 20},
# Password reset endpoints
'/api/v1/auth/password-reset/': {'per_minute': 2, 'per_hour': 5, 'per_day': 10},
'/accounts/password/reset/': {'per_minute': 2, 'per_hour': 5, 'per_day': 10},
# Token endpoints
'/api/v1/auth/token/': {'per_minute': 10, 'per_hour': 60, 'per_day': 200},
'/api/v1/auth/token/refresh/': {'per_minute': 20, 'per_hour': 120, 'per_day': 500},
}
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
# Only rate limit POST requests to auth endpoints
if request.method != 'POST':
return self.get_response(request)
# Check if this path should be rate limited
limits = self._get_rate_limits(request.path)
if not limits:
return self.get_response(request)
# Get client identifier (IP address)
client_ip = self._get_client_ip(request)
# Check rate limits
is_allowed, message = self._check_rate_limits(
client_ip, request.path, limits
)
if not is_allowed:
logger.warning(
f"Rate limit exceeded for {client_ip} on {request.path}"
)
return self._rate_limit_response(message)
# Process request
response = self.get_response(request)
# Only increment counter for failed auth attempts (non-2xx responses)
if response.status_code >= 400:
self._increment_counters(client_ip, request.path)
return response
def _get_rate_limits(self, path: str) -> Optional[dict]:
"""Get rate limits for a path, if any."""
# Exact match
if path in self.RATE_LIMITED_PATHS:
return self.RATE_LIMITED_PATHS[path]
# Prefix match (for paths with trailing slashes)
path_without_slash = path.rstrip('/')
for limited_path, limits in self.RATE_LIMITED_PATHS.items():
if path_without_slash == limited_path.rstrip('/'):
return limits
return None
def _get_client_ip(self, request: HttpRequest) -> str:
"""
Get the client's IP address from the request.
Handles common proxy headers (X-Forwarded-For, X-Real-IP).
"""
# Check for forwarded headers (set by reverse proxies)
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
# Take the first IP in the chain (client IP)
return x_forwarded_for.split(',')[0].strip()
x_real_ip = request.META.get('HTTP_X_REAL_IP')
if x_real_ip:
return x_real_ip
return request.META.get('REMOTE_ADDR', 'unknown')
def _check_rate_limits(
self,
client_ip: str,
path: str,
limits: dict
) -> Tuple[bool, str]:
"""
Check if the client has exceeded rate limits.
Returns:
Tuple of (is_allowed, reason_if_blocked)
"""
# Create a safe cache key from path
path_key = path.replace('/', '_').strip('_')
# Check per-minute limit
minute_key = f"auth_rate:{client_ip}:{path_key}:minute"
minute_count = cache.get(minute_key, 0)
if minute_count >= limits.get('per_minute', 10):
return False, "Too many requests. Please wait a minute before trying again."
# Check per-hour limit
hour_key = f"auth_rate:{client_ip}:{path_key}:hour"
hour_count = cache.get(hour_key, 0)
if hour_count >= limits.get('per_hour', 60):
return False, "Too many requests. Please try again later."
# Check per-day limit
day_key = f"auth_rate:{client_ip}:{path_key}:day"
day_count = cache.get(day_key, 0)
if day_count >= limits.get('per_day', 200):
return False, "Daily limit exceeded. Please try again tomorrow."
return True, ""
def _increment_counters(self, client_ip: str, path: str) -> None:
"""Increment rate limit counters."""
path_key = path.replace('/', '_').strip('_')
# Increment per-minute counter
minute_key = f"auth_rate:{client_ip}:{path_key}:minute"
try:
cache.incr(minute_key)
except ValueError:
cache.set(minute_key, 1, 60)
# Increment per-hour counter
hour_key = f"auth_rate:{client_ip}:{path_key}:hour"
try:
cache.incr(hour_key)
except ValueError:
cache.set(hour_key, 1, 3600)
# Increment per-day counter
day_key = f"auth_rate:{client_ip}:{path_key}:day"
try:
cache.incr(day_key)
except ValueError:
cache.set(day_key, 1, 86400)
def _rate_limit_response(self, message: str) -> JsonResponse:
"""Generate a rate limit exceeded response."""
return JsonResponse(
{
'error': message,
'code': 'RATE_LIMIT_EXCEEDED',
},
status=429, # Too Many Requests
)
class SecurityEventLogger:
"""
Utility class for logging security-relevant events.
Use this to log:
- Failed authentication attempts
- Permission denied events
- Suspicious activity
"""
@staticmethod
def log_failed_login(
request: HttpRequest,
username: str,
reason: str = "Invalid credentials"
) -> None:
"""Log a failed login attempt."""
client_ip = AuthRateLimitMiddleware._get_client_ip(
AuthRateLimitMiddleware, request
)
logger.warning(
f"Failed login attempt - IP: {client_ip}, Username: {username}, "
f"Reason: {reason}, User-Agent: {request.META.get('HTTP_USER_AGENT', 'unknown')}"
)
@staticmethod
def log_permission_denied(
request: HttpRequest,
resource: str,
action: str = "access"
) -> None:
"""Log a permission denied event."""
client_ip = AuthRateLimitMiddleware._get_client_ip(
AuthRateLimitMiddleware, request
)
user = getattr(request, 'user', None)
username = user.username if user and user.is_authenticated else 'anonymous'
logger.warning(
f"Permission denied - IP: {client_ip}, User: {username}, "
f"Resource: {resource}, Action: {action}"
)
@staticmethod
def log_suspicious_activity(
request: HttpRequest,
activity_type: str,
details: str = ""
) -> None:
"""Log suspicious activity."""
client_ip = AuthRateLimitMiddleware._get_client_ip(
AuthRateLimitMiddleware, request
)
user = getattr(request, 'user', None)
username = user.username if user and user.is_authenticated else 'anonymous'
logger.error(
f"Suspicious activity detected - Type: {activity_type}, "
f"IP: {client_ip}, User: {username}, Details: {details}"
)

View File

@@ -114,18 +114,52 @@ class RequestLoggingMiddleware(MiddlewareMixin):
return response
# Sensitive field patterns that should be masked in logs
# Security: Comprehensive list of sensitive data patterns
SENSITIVE_PATTERNS = [
'password',
'passwd',
'pwd',
'token',
'secret',
'key',
'api_key',
'apikey',
'auth',
'authorization',
'credential',
'ssn',
'social_security',
'credit_card',
'creditcard',
'card_number',
'cvv',
'cvc',
'pin',
'access_token',
'refresh_token',
'jwt',
'session',
'cookie',
'private',
]
def _safe_log_data(self, data):
"""Safely log data, truncating if too large and masking sensitive fields."""
"""
Safely log data, truncating if too large and masking sensitive fields.
Security measures:
- Masks all sensitive field names
- Masks email addresses (shows only domain)
- Truncates long values to prevent log flooding
- Recursively processes nested dictionaries and lists
"""
try:
# Convert to string representation
if isinstance(data, dict):
# Mask sensitive fields
safe_data = {}
for key, value in data.items():
if any(sensitive in key.lower() for sensitive in ['password', 'token', 'secret', 'key']):
safe_data[key] = '***MASKED***'
else:
safe_data[key] = value
safe_data = self._mask_sensitive_dict(data)
data_str = json.dumps(safe_data, indent=2, default=str)
elif isinstance(data, list):
safe_data = [self._mask_sensitive_value(item) for item in data]
data_str = json.dumps(safe_data, indent=2, default=str)
else:
data_str = json.dumps(data, indent=2, default=str)
@@ -136,3 +170,37 @@ class RequestLoggingMiddleware(MiddlewareMixin):
return data_str
except Exception:
return str(data)[:500] + '...[ERROR_LOGGING]'
def _mask_sensitive_dict(self, data, depth=0):
"""Recursively mask sensitive fields in a dictionary."""
if depth > 5: # Prevent infinite recursion
return '***DEPTH_LIMIT***'
safe_data = {}
for key, value in data.items():
key_lower = str(key).lower()
# Check if key contains any sensitive pattern
if any(pattern in key_lower for pattern in self.SENSITIVE_PATTERNS):
safe_data[key] = '***MASKED***'
else:
safe_data[key] = self._mask_sensitive_value(value, depth)
return safe_data
def _mask_sensitive_value(self, value, depth=0):
"""Mask a single value, handling different types."""
if isinstance(value, dict):
return self._mask_sensitive_dict(value, depth + 1)
elif isinstance(value, list):
return [self._mask_sensitive_value(item, depth + 1) for item in value[:10]] # Limit list items
elif isinstance(value, str):
# Mask email addresses (show only domain)
if '@' in value and '.' in value.split('@')[-1]:
parts = value.split('@')
if len(parts) == 2:
return f"***@{parts[1]}"
# Truncate long strings
if len(value) > 200:
return value[:200] + '...[TRUNCATED]'
return value

View File

@@ -0,0 +1,196 @@
"""
Security Headers Middleware for ThrillWiki.
This middleware adds additional security headers to all HTTP responses,
providing defense-in-depth against common web vulnerabilities.
Headers added:
- Content-Security-Policy: Controls resource loading to prevent XSS
- Permissions-Policy: Restricts browser feature access
- Cross-Origin-Embedder-Policy: Prevents cross-origin embedding
- Cross-Origin-Resource-Policy: Restricts cross-origin resource access
Usage:
Add 'apps.core.middleware.security_headers.SecurityHeadersMiddleware'
to MIDDLEWARE in settings.py (after SecurityMiddleware).
"""
from django.conf import settings
class SecurityHeadersMiddleware:
"""
Middleware that adds security headers to HTTP responses.
This provides defense-in-depth by adding headers that Django's
SecurityMiddleware doesn't handle.
"""
def __init__(self, get_response):
self.get_response = get_response
# Build CSP header at startup for performance
self._csp_header = self._build_csp_header()
self._permissions_policy_header = self._build_permissions_policy_header()
def __call__(self, request):
response = self.get_response(request)
return self._add_security_headers(response, request)
def _add_security_headers(self, response, request):
"""Add security headers to the response."""
# Content-Security-Policy
# Only add CSP for HTML responses to avoid breaking API/JSON responses
content_type = response.get("Content-Type", "")
if "text/html" in content_type:
if not response.get("Content-Security-Policy"):
response["Content-Security-Policy"] = self._csp_header
# Permissions-Policy (successor to Feature-Policy)
if not response.get("Permissions-Policy"):
response["Permissions-Policy"] = self._permissions_policy_header
# Cross-Origin-Embedder-Policy
# Requires resources to be CORS-enabled or same-origin
# Using 'unsafe-none' for now as 'require-corp' can break third-party resources
if not response.get("Cross-Origin-Embedder-Policy"):
response["Cross-Origin-Embedder-Policy"] = "unsafe-none"
# Cross-Origin-Resource-Policy
# Controls how resources can be shared with other origins
if not response.get("Cross-Origin-Resource-Policy"):
response["Cross-Origin-Resource-Policy"] = "same-origin"
return response
def _build_csp_header(self):
"""
Build the Content-Security-Policy header value.
CSP directives explained:
- default-src: Fallback for other fetch directives
- script-src: Sources for JavaScript
- style-src: Sources for CSS
- img-src: Sources for images
- font-src: Sources for fonts
- connect-src: Sources for fetch, XHR, WebSocket
- frame-ancestors: Controls framing (replaces X-Frame-Options)
- form-action: Valid targets for form submissions
- base-uri: Restricts base element URLs
- object-src: Sources for plugins (Flash, etc.)
"""
# Check if we're in debug mode
debug = getattr(settings, "DEBUG", False)
# Base directives (production-focused)
directives = {
"default-src": ["'self'"],
"script-src": [
"'self'",
# Allow HTMX inline scripts with nonce (would need nonce middleware)
# For now, using 'unsafe-inline' for HTMX compatibility
"'unsafe-inline'" if debug else "'self'",
# CDNs for external scripts
"https://cdn.jsdelivr.net",
"https://unpkg.com",
"https://challenges.cloudflare.com", # Turnstile
],
"style-src": [
"'self'",
"'unsafe-inline'", # Required for Tailwind and inline styles
"https://cdn.jsdelivr.net",
"https://fonts.googleapis.com",
],
"img-src": [
"'self'",
"data:",
"blob:",
"https:", # Allow HTTPS images (needed for user uploads, maps, etc.)
],
"font-src": [
"'self'",
"https://fonts.gstatic.com",
"https://cdn.jsdelivr.net",
],
"connect-src": [
"'self'",
"https://api.forwardemail.net",
"https://challenges.cloudflare.com",
"https://*.cloudflare.com",
# Map tile servers
"https://*.openstreetmap.org",
"https://*.tile.openstreetmap.org",
],
"frame-src": [
"'self'",
"https://challenges.cloudflare.com", # Turnstile widget
],
"frame-ancestors": ["'self'"],
"form-action": ["'self'"],
"base-uri": ["'self'"],
"object-src": ["'none'"],
"upgrade-insecure-requests": [], # Upgrade HTTP to HTTPS
}
# Add debug-specific relaxations
if debug:
# Allow webpack dev server connections in development
directives["connect-src"].extend([
"ws://localhost:*",
"http://localhost:*",
"http://127.0.0.1:*",
])
# Build header string
parts = []
for directive, sources in directives.items():
if sources:
parts.append(f"{directive} {' '.join(sources)}")
else:
# Directives like upgrade-insecure-requests don't need values
parts.append(directive)
return "; ".join(parts)
def _build_permissions_policy_header(self):
"""
Build the Permissions-Policy header value.
This header controls which browser features the page can use.
"""
# Get permissions policy from settings or use defaults
policy = getattr(settings, "PERMISSIONS_POLICY", {
"accelerometer": [],
"ambient-light-sensor": [],
"autoplay": [],
"camera": [],
"display-capture": [],
"document-domain": [],
"encrypted-media": [],
"fullscreen": ["self"],
"geolocation": ["self"],
"gyroscope": [],
"interest-cohort": [],
"magnetometer": [],
"microphone": [],
"midi": [],
"payment": [],
"picture-in-picture": [],
"publickey-credentials-get": [],
"screen-wake-lock": [],
"sync-xhr": [],
"usb": [],
"web-share": ["self"],
"xr-spatial-tracking": [],
})
parts = []
for feature, allowlist in policy.items():
if not allowlist:
# Empty list means disallow completely
parts.append(f"{feature}=()")
else:
# Convert allowlist to proper format
formatted = " ".join(allowlist)
parts.append(f"{feature}=({formatted})")
return ", ".join(parts)

View File

@@ -223,7 +223,7 @@ class MapResponse:
"query_time_ms": self.query_time_ms,
"filters_applied": self.filters_applied,
"pagination": {
"has_more": False, # TODO: Implement pagination
"has_more": False, # TODO(THRILLWIKI-102): Implement pagination for map data
"total_pages": 1,
},
},

View File

@@ -297,7 +297,7 @@ class CompanyLocationAdapter(BaseLocationAdapter):
"""Convert CompanyHeadquarters to UnifiedLocation."""
# Note: CompanyHeadquarters doesn't have coordinates, so we need to geocode
# For now, we'll skip companies without coordinates
# TODO: Implement geocoding service integration
# TODO(THRILLWIKI-101): Implement geocoding service integration for company HQs
return None
def get_queryset(

View File

@@ -0,0 +1,275 @@
"""
Safe HTML Template Tags and Filters for ThrillWiki.
This module provides template tags and filters for safely rendering
HTML content without XSS vulnerabilities.
Security Note:
Always use these filters instead of |safe for user-generated content.
The |safe filter should only be used for content that has been
pre-sanitized in the view layer.
Usage:
{% load safe_html %}
{# Sanitize user content #}
{{ user_description|sanitize }}
{# Minimal sanitization for comments #}
{{ comment_text|sanitize_minimal }}
{# Strip all HTML #}
{{ raw_text|strip_html }}
{# Safe JSON for JavaScript #}
{{ data|json_safe }}
{# Render trusted icon SVG #}
{% icon "check" class="w-4 h-4" %}
"""
import json
from django import template
from django.utils.safestring import mark_safe
from apps.core.utils.html_sanitizer import (
sanitize_html,
sanitize_minimal as _sanitize_minimal,
sanitize_svg,
strip_html as _strip_html,
sanitize_for_json,
escape_js_string as _escape_js_string,
sanitize_url as _sanitize_url,
sanitize_attribute_value,
)
register = template.Library()
# =============================================================================
# HTML Sanitization Filters
# =============================================================================
@register.filter(name='sanitize', is_safe=True)
def sanitize_filter(value):
"""
Sanitize HTML content to prevent XSS attacks.
Allows common formatting tags while stripping dangerous content.
Usage:
{{ user_content|sanitize }}
"""
if not value:
return ''
return mark_safe(sanitize_html(str(value)))
@register.filter(name='sanitize_minimal', is_safe=True)
def sanitize_minimal_filter(value):
"""
Sanitize HTML with minimal allowed tags.
Only allows basic text formatting: p, br, strong, em, i, b, a
Usage:
{{ comment|sanitize_minimal }}
"""
if not value:
return ''
return mark_safe(_sanitize_minimal(str(value)))
@register.filter(name='sanitize_svg', is_safe=True)
def sanitize_svg_filter(value):
"""
Sanitize SVG content for safe inline rendering.
Usage:
{{ icon_svg|sanitize_svg }}
"""
if not value:
return ''
return mark_safe(sanitize_svg(str(value)))
@register.filter(name='strip_html')
def strip_html_filter(value):
"""
Remove all HTML tags from content.
Usage:
{{ html_content|strip_html }}
"""
if not value:
return ''
return _strip_html(str(value))
# =============================================================================
# JavaScript/JSON Context Filters
# =============================================================================
@register.filter(name='json_safe', is_safe=True)
def json_safe_filter(value):
"""
Safely serialize data for embedding in JavaScript.
This is safer than using |safe for JSON data as it properly
escapes </script> and other dangerous sequences.
Usage:
<script>
const data = {{ python_dict|json_safe }};
</script>
"""
if value is None:
return 'null'
return mark_safe(sanitize_for_json(value))
@register.filter(name='escapejs_safe')
def escapejs_safe_filter(value):
"""
Escape a string for safe use in JavaScript string literals.
Usage:
<script>
const message = '{{ user_input|escapejs_safe }}';
</script>
"""
if not value:
return ''
return _escape_js_string(str(value))
# =============================================================================
# URL and Attribute Filters
# =============================================================================
@register.filter(name='sanitize_url')
def sanitize_url_filter(value):
"""
Sanitize a URL to prevent javascript: and other dangerous protocols.
Usage:
<a href="{{ user_url|sanitize_url }}">Link</a>
"""
if not value:
return ''
return _sanitize_url(str(value))
@register.filter(name='attr_safe')
def attr_safe_filter(value):
"""
Escape a value for safe use in HTML attributes.
Usage:
<div data-value="{{ user_value|attr_safe }}">
"""
if not value:
return ''
return sanitize_attribute_value(str(value))
# =============================================================================
# Icon Template Tags
# =============================================================================
# Predefined safe SVG icons
# These are trusted and can be rendered without sanitization
BUILTIN_ICONS = {
'check': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>''',
'x': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>''',
'plus': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>''',
'minus': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" /></svg>''',
'chevron-down': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /></svg>''',
'chevron-up': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /></svg>''',
'chevron-left': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>''',
'chevron-right': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>''',
'search': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /></svg>''',
'menu': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>''',
'user': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /></svg>''',
'cog': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>''',
'trash': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>''',
'pencil': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg>''',
'eye': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>''',
'eye-slash': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>''',
'arrow-left': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /></svg>''',
'arrow-right': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>''',
'info': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg>''',
'warning': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg>''',
'error': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /></svg>''',
'success': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>''',
'loading': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" {attrs}><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>''',
'external-link': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>''',
'download': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>''',
'upload': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /></svg>''',
'star': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" /></svg>''',
'star-filled': '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {attrs}><path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" /></svg>''',
'heart': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" /></svg>''',
'heart-filled': '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {attrs}><path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" /></svg>''',
}
@register.simple_tag
def icon(name, **kwargs):
"""
Render a trusted SVG icon.
This tag renders predefined SVG icons that are trusted and safe.
Custom attributes can be passed to customize the icon.
Usage:
{% icon "check" class="w-4 h-4 text-green-500" %}
{% icon "x" class="w-6 h-6" aria_hidden="true" %}
Args:
name: The icon name (from BUILTIN_ICONS)
**kwargs: Additional HTML attributes for the SVG
Returns:
Safe HTML for the icon SVG
"""
svg_template = BUILTIN_ICONS.get(name)
if not svg_template:
# Return empty string for unknown icons (fail silently)
return ''
# Build attributes string
attrs_list = []
for key, value in kwargs.items():
# Convert underscore to hyphen for HTML attributes (e.g., aria_hidden -> aria-hidden)
attr_name = key.replace('_', '-')
# Escape attribute values to prevent XSS
safe_value = sanitize_attribute_value(str(value))
attrs_list.append(f'{attr_name}="{safe_value}"')
attrs_str = ' '.join(attrs_list)
# Substitute attributes into template
svg = svg_template.format(attrs=attrs_str)
return mark_safe(svg)
@register.simple_tag
def icon_class(name, size='w-5 h-5', extra_class=''):
"""
Render a trusted SVG icon with common class presets.
Usage:
{% icon_class "check" size="w-4 h-4" extra_class="text-green-500" %}
Args:
name: The icon name
size: Size classes (default: "w-5 h-5")
extra_class: Additional CSS classes
Returns:
Safe HTML for the icon SVG
"""
classes = f'{size} {extra_class}'.strip()
return icon(name, **{'class': classes})

View File

@@ -0,0 +1,161 @@
"""
Centralized error handling utilities.
This module provides standardized error handling for views across the application,
ensuring consistent logging, user messages, and API responses.
"""
import logging
from typing import Any, Dict, Optional
from django.contrib import messages
from django.http import HttpRequest
from rest_framework import status
from rest_framework.response import Response
from apps.core.exceptions import ThrillWikiException
logger = logging.getLogger(__name__)
class ErrorHandler:
"""Centralized error handling for views."""
@staticmethod
def handle_view_error(
request: HttpRequest,
error: Exception,
user_message: str = "An error occurred",
log_message: Optional[str] = None,
level: str = "error",
) -> None:
"""
Handle errors in template views.
Logs the error with appropriate context and displays a user-friendly
message using Django's messages framework.
Args:
request: HTTP request object
error: Exception that occurred
user_message: Message to show to the user (should be user-friendly)
log_message: Message to log (defaults to str(error) with user_message prefix)
level: Log level - one of "error", "warning", "info"
Example:
try:
ParkService.create_park(...)
except ServiceError as e:
ErrorHandler.handle_view_error(
request,
e,
user_message="Failed to create park",
log_message=f"Park creation failed for user {request.user.id}"
)
"""
log_msg = log_message or f"{user_message}: {str(error)}"
if level == "error":
logger.error(log_msg, exc_info=True)
elif level == "warning":
logger.warning(log_msg)
else:
logger.info(log_msg)
messages.error(request, user_message)
@staticmethod
def handle_api_error(
error: Exception,
user_message: str = "An error occurred",
log_message: Optional[str] = None,
status_code: int = status.HTTP_400_BAD_REQUEST,
) -> Response:
"""
Handle errors in API views.
Logs the error and returns a standardized DRF Response with error details.
Args:
error: Exception that occurred
user_message: Message to return to the client (should be user-friendly)
log_message: Message to log (defaults to str(error) with user_message prefix)
status_code: HTTP status code to return
Returns:
DRF Response with error details in standard format
Example:
try:
result = ParkService.create_park(...)
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to create park photo",
status_code=status.HTTP_400_BAD_REQUEST
)
"""
log_msg = log_message or f"{user_message}: {str(error)}"
logger.error(log_msg, exc_info=True)
# Build error response
error_data: Dict[str, Any] = {
"error": user_message,
"detail": str(error),
}
# Include additional details for ThrillWikiException subclasses
if isinstance(error, ThrillWikiException):
error_data["error_code"] = error.error_code
if error.details:
error_data["details"] = error.details
return Response(error_data, status=status_code)
@staticmethod
def handle_success(
request: HttpRequest,
message: str,
level: str = "success",
) -> None:
"""
Handle success messages in template views.
Args:
request: HTTP request object
message: Success message to display
level: Message level - one of "success", "info", "warning"
"""
if level == "success":
messages.success(request, message)
elif level == "info":
messages.info(request, message)
elif level == "warning":
messages.warning(request, message)
@staticmethod
def api_success_response(
data: Any = None,
message: str = "Success",
status_code: int = status.HTTP_200_OK,
) -> Response:
"""
Create a standardized success response for API views.
Args:
data: Response data (optional)
message: Success message
status_code: HTTP status code
Returns:
DRF Response with success data in standard format
"""
response_data: Dict[str, Any] = {
"status": "success",
"message": message,
}
if data is not None:
response_data["data"] = data
return Response(response_data, status=status_code)

View File

@@ -0,0 +1,432 @@
"""
File Upload Security Utilities for ThrillWiki.
This module provides comprehensive file validation and security checks for
file uploads, including:
- File type validation (MIME type and magic number verification)
- File size validation
- Filename sanitization
- Image-specific validation
Security Note:
Always validate uploaded files before saving them. Never trust user-provided
file extensions or Content-Type headers alone.
Usage:
from apps.core.utils.file_scanner import validate_image_upload, sanitize_filename
# In view
try:
validate_image_upload(uploaded_file)
# Safe to save file
except FileValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
# Sanitize filename
safe_name = sanitize_filename(uploaded_file.name)
"""
import os
import re
import uuid
from io import BytesIO
from typing import Optional, Set, Tuple
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
class FileValidationError(ValidationError):
"""Custom exception for file validation errors."""
pass
# =============================================================================
# Image Magic Number Signatures
# =============================================================================
# Magic number signatures for common image formats
# Format: (magic_bytes, offset, description)
IMAGE_SIGNATURES = {
'jpeg': [
(b'\xFF\xD8\xFF\xE0', 0, 'JPEG (JFIF)'),
(b'\xFF\xD8\xFF\xE1', 0, 'JPEG (EXIF)'),
(b'\xFF\xD8\xFF\xE2', 0, 'JPEG (ICC)'),
(b'\xFF\xD8\xFF\xE3', 0, 'JPEG (Samsung)'),
(b'\xFF\xD8\xFF\xE8', 0, 'JPEG (SPIFF)'),
(b'\xFF\xD8\xFF\xDB', 0, 'JPEG (Raw)'),
],
'png': [
(b'\x89PNG\r\n\x1a\n', 0, 'PNG'),
],
'gif': [
(b'GIF87a', 0, 'GIF87a'),
(b'GIF89a', 0, 'GIF89a'),
],
'webp': [
(b'RIFF', 0, 'RIFF'), # WebP starts with RIFF header
],
'bmp': [
(b'BM', 0, 'BMP'),
],
}
# All allowed MIME types
ALLOWED_IMAGE_MIME_TYPES: Set[str] = frozenset({
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
})
# Allowed file extensions
ALLOWED_IMAGE_EXTENSIONS: Set[str] = frozenset({
'.jpg', '.jpeg', '.png', '.gif', '.webp',
})
# Maximum file size (10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024
# Minimum file size (prevent empty files)
MIN_FILE_SIZE = 100 # 100 bytes
# =============================================================================
# File Validation Functions
# =============================================================================
def validate_image_upload(
file: UploadedFile,
max_size: int = MAX_FILE_SIZE,
allowed_types: Optional[Set[str]] = None,
allowed_extensions: Optional[Set[str]] = None,
) -> bool:
"""
Validate an uploaded image file for security.
Performs multiple validation checks:
1. File size validation
2. File extension validation
3. MIME type validation (from Content-Type header)
4. Magic number validation (actual file content check)
5. Image integrity validation (using PIL)
Args:
file: The uploaded file object
max_size: Maximum allowed file size in bytes
allowed_types: Set of allowed MIME types
allowed_extensions: Set of allowed file extensions
Returns:
True if all validations pass
Raises:
FileValidationError: If any validation fails
"""
if allowed_types is None:
allowed_types = ALLOWED_IMAGE_MIME_TYPES
if allowed_extensions is None:
allowed_extensions = ALLOWED_IMAGE_EXTENSIONS
# 1. Check if file exists
if not file:
raise FileValidationError("No file provided")
# 2. Check file size
if file.size > max_size:
raise FileValidationError(
f"File too large. Maximum size is {max_size // (1024 * 1024)}MB"
)
if file.size < MIN_FILE_SIZE:
raise FileValidationError("File too small or empty")
# 3. Check file extension
filename = file.name or ''
ext = os.path.splitext(filename)[1].lower()
if ext not in allowed_extensions:
raise FileValidationError(
f"Invalid file extension '{ext}'. Allowed: {', '.join(allowed_extensions)}"
)
# 4. Check Content-Type header
content_type = getattr(file, 'content_type', '')
if content_type and content_type not in allowed_types:
raise FileValidationError(
f"Invalid file type '{content_type}'. Allowed: {', '.join(allowed_types)}"
)
# 5. Validate magic numbers (actual file content)
if not _validate_magic_number(file):
raise FileValidationError(
"File content doesn't match file extension. File may be corrupted or malicious."
)
# 6. Validate image integrity using PIL
if not _validate_image_integrity(file):
raise FileValidationError(
"Invalid or corrupted image file"
)
return True
def _validate_magic_number(file: UploadedFile) -> bool:
"""
Validate file content against known magic number signatures.
This is more reliable than checking the file extension or Content-Type
header, which can be easily spoofed.
Args:
file: The uploaded file object
Returns:
True if magic number matches an allowed image type
"""
# Read the file header
file.seek(0)
header = file.read(16)
file.seek(0)
# Check against known signatures
for format_name, signatures in IMAGE_SIGNATURES.items():
for magic, offset, description in signatures:
if len(header) >= offset + len(magic):
if header[offset:offset + len(magic)] == magic:
# Special handling for WebP (must also have WEBP marker)
if format_name == 'webp':
if len(header) >= 12 and header[8:12] == b'WEBP':
return True
else:
return True
return False
def _validate_image_integrity(file: UploadedFile) -> bool:
"""
Validate image integrity using PIL.
This catches corrupted images and various image-related attacks.
Args:
file: The uploaded file object
Returns:
True if image can be opened and verified
"""
try:
from PIL import Image
file.seek(0)
# Read into BytesIO to avoid issues with file-like objects
img_data = BytesIO(file.read())
file.seek(0)
with Image.open(img_data) as img:
# Verify the image is not truncated or corrupted
img.verify()
# Re-open for size check (verify() can only be called once)
img_data.seek(0)
with Image.open(img_data) as img2:
# Check for reasonable image dimensions
# Prevent decompression bombs
max_dimension = 10000
if img2.width > max_dimension or img2.height > max_dimension:
raise FileValidationError(
f"Image dimensions too large. Maximum is {max_dimension}x{max_dimension}"
)
# Check for very small dimensions (might be suspicious)
if img2.width < 1 or img2.height < 1:
raise FileValidationError("Invalid image dimensions")
return True
except FileValidationError:
raise
except Exception:
return False
# =============================================================================
# Filename Sanitization
# =============================================================================
def sanitize_filename(filename: str, max_length: int = 100) -> str:
"""
Sanitize a filename to prevent directory traversal and other attacks.
This function:
- Removes path separators and directory traversal attempts
- Removes special characters
- Truncates to maximum length
- Ensures the filename is not empty
Args:
filename: The original filename
max_length: Maximum length for the filename
Returns:
Sanitized filename
"""
if not filename:
return f"file_{uuid.uuid4().hex[:8]}"
# Get just the filename (remove any path components)
filename = os.path.basename(filename)
# Split into name and extension
name, ext = os.path.splitext(filename)
# Remove or replace dangerous characters from name
# Allow alphanumeric, hyphens, underscores, dots
name = re.sub(r'[^\w\-.]', '_', name)
# Remove leading dots and underscores (hidden file prevention)
name = name.lstrip('._')
# Collapse multiple underscores
name = re.sub(r'_+', '_', name)
# Ensure name is not empty
if not name:
name = f"file_{uuid.uuid4().hex[:8]}"
# Sanitize extension
ext = ext.lower()
ext = re.sub(r'[^\w.]', '', ext)
# Combine and truncate
result = f"{name[:max_length - len(ext)]}{ext}"
return result
def generate_unique_filename(original_filename: str, prefix: str = '') -> str:
"""
Generate a unique filename using UUID while preserving extension.
Args:
original_filename: The original filename
prefix: Optional prefix for the filename
Returns:
Unique filename with UUID
"""
ext = os.path.splitext(original_filename)[1].lower()
# Sanitize extension
ext = re.sub(r'[^\w.]', '', ext)
# Generate unique filename
unique_id = uuid.uuid4().hex[:12]
if prefix:
return f"{sanitize_filename(prefix)}_{unique_id}{ext}"
return f"{unique_id}{ext}"
# =============================================================================
# Rate Limiting for Uploads
# =============================================================================
# Rate limiting configuration
UPLOAD_RATE_LIMITS = {
'per_minute': 10,
'per_hour': 100,
'per_day': 500,
}
def check_upload_rate_limit(user_id: int, cache_backend=None) -> Tuple[bool, str]:
"""
Check if user has exceeded upload rate limits.
Args:
user_id: The user's ID
cache_backend: Optional Django cache backend (uses default if not provided)
Returns:
Tuple of (is_allowed, reason_if_blocked)
"""
if cache_backend is None:
from django.core.cache import cache
cache_backend = cache
# Check per-minute limit
minute_key = f"upload_rate:{user_id}:minute"
minute_count = cache_backend.get(minute_key, 0)
if minute_count >= UPLOAD_RATE_LIMITS['per_minute']:
return False, "Upload rate limit exceeded. Please wait a minute."
# Check per-hour limit
hour_key = f"upload_rate:{user_id}:hour"
hour_count = cache_backend.get(hour_key, 0)
if hour_count >= UPLOAD_RATE_LIMITS['per_hour']:
return False, "Hourly upload limit exceeded. Please try again later."
# Check per-day limit
day_key = f"upload_rate:{user_id}:day"
day_count = cache_backend.get(day_key, 0)
if day_count >= UPLOAD_RATE_LIMITS['per_day']:
return False, "Daily upload limit exceeded. Please try again tomorrow."
return True, ""
def increment_upload_count(user_id: int, cache_backend=None) -> None:
"""
Increment upload count for rate limiting.
Args:
user_id: The user's ID
cache_backend: Optional Django cache backend
"""
if cache_backend is None:
from django.core.cache import cache
cache_backend = cache
# Increment per-minute counter (expires in 60 seconds)
minute_key = f"upload_rate:{user_id}:minute"
try:
cache_backend.incr(minute_key)
except ValueError:
cache_backend.set(minute_key, 1, 60)
# Increment per-hour counter (expires in 3600 seconds)
hour_key = f"upload_rate:{user_id}:hour"
try:
cache_backend.incr(hour_key)
except ValueError:
cache_backend.set(hour_key, 1, 3600)
# Increment per-day counter (expires in 86400 seconds)
day_key = f"upload_rate:{user_id}:day"
try:
cache_backend.incr(day_key)
except ValueError:
cache_backend.set(day_key, 1, 86400)
# =============================================================================
# Antivirus Integration Point
# =============================================================================
def scan_file_for_malware(file: UploadedFile) -> Tuple[bool, str]:
"""
Placeholder for antivirus/malware scanning integration.
This function should be implemented to integrate with a virus scanner
like ClamAV. Currently it returns True (safe) for all files.
Args:
file: The uploaded file object
Returns:
Tuple of (is_safe, reason_if_unsafe)
"""
# TODO(THRILLWIKI-110): Implement ClamAV integration for malware scanning
# This requires ClamAV daemon to be running and python-clamav to be installed
return True, ""

View File

@@ -0,0 +1,382 @@
"""
HTML Sanitization Utilities for ThrillWiki.
This module provides functions for sanitizing user-generated HTML content
to prevent XSS (Cross-Site Scripting) attacks while allowing safe HTML
formatting.
Security Note:
Always sanitize user-generated content before rendering with |safe filter
or mark_safe(). Never trust user input.
Usage:
from apps.core.utils.html_sanitizer import sanitize_html, sanitize_for_json
# In views
context['description'] = sanitize_html(user_input)
# For JSON/JavaScript contexts
json_safe = sanitize_for_json(data)
"""
import json
import re
from html import escape as html_escape
from typing import Any
try:
import bleach
BLEACH_AVAILABLE = True
except ImportError:
BLEACH_AVAILABLE = False
# =============================================================================
# Allowed HTML Configuration
# =============================================================================
# Default allowed HTML tags for user-generated content
ALLOWED_TAGS = frozenset([
# Text formatting
'p', 'br', 'hr',
'strong', 'b', 'em', 'i', 'u', 's', 'strike',
'sub', 'sup', 'small', 'mark',
# Headers
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
# Lists
'ul', 'ol', 'li',
# Links (with restrictions on attributes)
'a',
# Block elements
'blockquote', 'pre', 'code',
'div', 'span',
# Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
])
# Allowed attributes for each tag
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'rel', 'target'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'div': ['class'],
'span': ['class'],
'p': ['class'],
'table': ['class'],
'th': ['class', 'colspan', 'rowspan'],
'td': ['class', 'colspan', 'rowspan'],
'*': ['class'], # Allow class on all elements
}
# Allowed URL protocols
ALLOWED_PROTOCOLS = frozenset([
'http', 'https', 'mailto', 'tel',
])
# Minimal tags for comments and short text
MINIMAL_TAGS = frozenset([
'p', 'br', 'strong', 'b', 'em', 'i', 'a',
])
# Tags allowed in icon SVGs (for icon template rendering)
SVG_TAGS = frozenset([
'svg', 'path', 'g', 'circle', 'rect', 'line', 'polyline', 'polygon',
'ellipse', 'text', 'tspan', 'defs', 'use', 'symbol', 'clipPath',
'mask', 'linearGradient', 'radialGradient', 'stop', 'title',
])
SVG_ATTRIBUTES = {
'svg': ['viewBox', 'width', 'height', 'fill', 'stroke', 'class',
'xmlns', 'aria-hidden', 'role'],
'path': ['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap',
'stroke-linejoin', 'class', 'fill-rule', 'clip-rule'],
'g': ['fill', 'stroke', 'transform', 'class'],
'circle': ['cx', 'cy', 'r', 'fill', 'stroke', 'class'],
'rect': ['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'class'],
'line': ['x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'class'],
'polyline': ['points', 'fill', 'stroke', 'class'],
'polygon': ['points', 'fill', 'stroke', 'class'],
'*': ['class', 'fill', 'stroke'],
}
# =============================================================================
# Sanitization Functions
# =============================================================================
def sanitize_html(
html: str | None,
allowed_tags: frozenset | None = None,
allowed_attributes: dict | None = None,
allowed_protocols: frozenset | None = None,
strip: bool = True,
) -> str:
"""
Sanitize HTML content to prevent XSS attacks.
Args:
html: The HTML string to sanitize
allowed_tags: Set of allowed HTML tag names
allowed_attributes: Dict mapping tag names to allowed attributes
allowed_protocols: Set of allowed URL protocols
strip: If True, remove disallowed tags; if False, escape them
Returns:
Sanitized HTML string safe for rendering
Example:
>>> sanitize_html('<script>alert("xss")</script><p>Hello</p>')
'<p>Hello</p>'
"""
if not html:
return ''
if not isinstance(html, str):
html = str(html)
if not BLEACH_AVAILABLE:
# Fallback: escape all HTML if bleach is not available
return html_escape(html)
tags = allowed_tags if allowed_tags is not None else ALLOWED_TAGS
attrs = allowed_attributes if allowed_attributes is not None else ALLOWED_ATTRIBUTES
protocols = allowed_protocols if allowed_protocols is not None else ALLOWED_PROTOCOLS
return bleach.clean(
html,
tags=tags,
attributes=attrs,
protocols=protocols,
strip=strip,
)
def sanitize_minimal(html: str | None) -> str:
"""
Sanitize HTML with minimal allowed tags.
Use this for user comments, short descriptions, etc.
Args:
html: The HTML string to sanitize
Returns:
Sanitized HTML with only basic formatting tags allowed
"""
return sanitize_html(
html,
allowed_tags=MINIMAL_TAGS,
allowed_attributes={'a': ['href', 'title']},
)
def sanitize_svg(svg: str | None) -> str:
"""
Sanitize SVG content for safe inline rendering.
This is specifically for icon SVGs that need to be rendered inline.
Removes potentially dangerous elements while preserving SVG structure.
Args:
svg: The SVG string to sanitize
Returns:
Sanitized SVG string safe for inline rendering
"""
if not svg:
return ''
if not isinstance(svg, str):
svg = str(svg)
if not BLEACH_AVAILABLE:
# Fallback: escape all if bleach is not available
return html_escape(svg)
return bleach.clean(
svg,
tags=SVG_TAGS,
attributes=SVG_ATTRIBUTES,
strip=True,
)
def strip_html(html: str | None) -> str:
"""
Remove all HTML tags from a string.
Use this for contexts where no HTML is allowed at all.
Args:
html: The HTML string to strip
Returns:
Plain text with all HTML tags removed
"""
if not html:
return ''
if not isinstance(html, str):
html = str(html)
if BLEACH_AVAILABLE:
return bleach.clean(html, tags=[], strip=True)
else:
# Fallback: use regex to strip tags
return re.sub(r'<[^>]+>', '', html)
# =============================================================================
# JSON/JavaScript Context Sanitization
# =============================================================================
def sanitize_for_json(data: Any) -> str:
"""
Safely serialize data for embedding in JavaScript/JSON contexts.
This prevents XSS when embedding data in <script> tags or JavaScript.
Args:
data: The data to serialize (dict, list, or primitive)
Returns:
JSON string safe for embedding in JavaScript
Example:
>>> sanitize_for_json({'name': '</script><script>alert("xss")'})
'{"name": "\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"xss\\")"}'
"""
# JSON encode with safe characters escaped
return json.dumps(data, ensure_ascii=False).replace(
'<', '\\u003c'
).replace(
'>', '\\u003e'
).replace(
'&', '\\u0026'
).replace(
"'", '\\u0027'
)
def escape_js_string(s: str | None) -> str:
"""
Escape a string for safe use in JavaScript string literals.
Args:
s: The string to escape
Returns:
Escaped string safe for JavaScript contexts
"""
if not s:
return ''
if not isinstance(s, str):
s = str(s)
# Escape backslashes first, then other special characters
return s.replace('\\', '\\\\').replace(
"'", "\\'"
).replace(
'"', '\\"'
).replace(
'\n', '\\n'
).replace(
'\r', '\\r'
).replace(
'<', '\\u003c'
).replace(
'>', '\\u003e'
).replace(
'&', '\\u0026'
)
# =============================================================================
# URL Sanitization
# =============================================================================
def sanitize_url(url: str | None, allowed_protocols: frozenset | None = None) -> str:
"""
Sanitize a URL to prevent javascript: and other dangerous protocols.
Args:
url: The URL to sanitize
allowed_protocols: Set of allowed URL protocols
Returns:
Sanitized URL or empty string if unsafe
"""
if not url:
return ''
if not isinstance(url, str):
url = str(url)
url = url.strip()
if not url:
return ''
protocols = allowed_protocols if allowed_protocols is not None else ALLOWED_PROTOCOLS
# Check for allowed protocols
url_lower = url.lower()
# Check for javascript:, data:, vbscript:, etc.
if ':' in url_lower:
protocol = url_lower.split(':')[0]
if protocol not in protocols:
# Allow relative URLs and anchor links
if not (url.startswith('/') or url.startswith('#') or url.startswith('?')):
return ''
return url
# =============================================================================
# Attribute Sanitization
# =============================================================================
def sanitize_attribute_value(value: str | None) -> str:
"""
Sanitize a value for use in HTML attributes.
Args:
value: The attribute value to sanitize
Returns:
Sanitized value safe for HTML attribute contexts
"""
if not value:
return ''
if not isinstance(value, str):
value = str(value)
# HTML escape for attribute context
return html_escape(value, quote=True)
def sanitize_class_name(name: str | None) -> str:
"""
Sanitize a CSS class name.
Args:
name: The class name to sanitize
Returns:
Sanitized class name containing only safe characters
"""
if not name:
return ''
if not isinstance(name, str):
name = str(name)
# Only allow alphanumeric, hyphens, and underscores
return re.sub(r'[^a-zA-Z0-9_-]', '', name)

View File

@@ -0,0 +1,79 @@
"""
Base view classes with common patterns.
This module provides base view classes that implement common patterns
such as automatic query optimization with select_related and prefetch_related.
"""
from typing import List
from django.db.models import QuerySet
from django.views.generic import DetailView, ListView
class OptimizedListView(ListView):
"""
ListView with automatic query optimization.
Automatically applies select_related and prefetch_related based on
class attributes, reducing the need for boilerplate code in get_queryset.
Attributes:
select_related_fields: List of fields to pass to select_related()
prefetch_related_fields: List of fields to pass to prefetch_related()
Example:
class RideListView(OptimizedListView):
model = Ride
select_related_fields = ['park', 'manufacturer']
prefetch_related_fields = ['photos']
"""
select_related_fields: List[str] = []
prefetch_related_fields: List[str] = []
def get_queryset(self) -> QuerySet:
"""Get queryset with optimizations applied."""
queryset = super().get_queryset()
if self.select_related_fields:
queryset = queryset.select_related(*self.select_related_fields)
if self.prefetch_related_fields:
queryset = queryset.prefetch_related(*self.prefetch_related_fields)
return queryset
class OptimizedDetailView(DetailView):
"""
DetailView with automatic query optimization.
Automatically applies select_related and prefetch_related based on
class attributes, reducing the need for boilerplate code in get_queryset.
Attributes:
select_related_fields: List of fields to pass to select_related()
prefetch_related_fields: List of fields to pass to prefetch_related()
Example:
class RideDetailView(OptimizedDetailView):
model = Ride
select_related_fields = ['park', 'park__location', 'manufacturer']
prefetch_related_fields = ['photos', 'coaster_stats']
"""
select_related_fields: List[str] = []
prefetch_related_fields: List[str] = []
def get_queryset(self) -> QuerySet:
"""Get queryset with optimizations applied."""
queryset = super().get_queryset()
if self.select_related_fields:
queryset = queryset.select_related(*self.select_related_fields)
if self.prefetch_related_fields:
queryset = queryset.prefetch_related(*self.prefetch_related_fields)
return queryset

View File

@@ -6,8 +6,6 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from typing import Optional, List
from ..services.entity_fuzzy_matching import (
@@ -244,10 +242,12 @@ class EntityNotFoundView(APIView):
)
@method_decorator(csrf_exempt, name="dispatch")
class QuickEntitySuggestionView(APIView):
"""
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
Security Note: This endpoint only accepts GET requests, which are inherently
safe from CSRF attacks. No CSRF exemption is needed.
"""
permission_classes = [AllowAny]

View File

@@ -1,5 +1,4 @@
from django.views.generic.edit import FormView
from django.shortcuts import get_object_or_404
class InlineEditView(FormView):

View File

@@ -636,7 +636,9 @@ class MapCacheView(MapAPIView):
def delete(self, request: HttpRequest) -> JsonResponse:
"""Clear all map cache (admin only)."""
# TODO: Add admin permission check
# TODO(THRILLWIKI-103): Add admin permission check for cache clear
if not (request.user.is_authenticated and request.user.is_staff):
return self._error_response("Admin access required", 403)
try:
unified_map_service.invalidate_cache()
@@ -655,7 +657,9 @@ class MapCacheView(MapAPIView):
def post(self, request: HttpRequest) -> JsonResponse:
"""Invalidate specific cache entries."""
# TODO: Add admin permission check
# TODO(THRILLWIKI-103): Add admin permission check for cache invalidation
if not (request.user.is_authenticated and request.user.is_staff):
return self._error_response("Admin access required", 403)
try:
data = json.loads(request.body)

View File

@@ -1,5 +1,4 @@
from django.views.generic.edit import FormView
from django.http import HttpResponse
class HTMXModalFormView(FormView):

View File

@@ -1,6 +1,7 @@
"""
Core views for the application.
"""
import json
import logging
from typing import Any, Dict, Optional, Type
@@ -93,18 +94,14 @@ class GlobalSearchView(TemplateView):
# Real implementation should query multiple models.
if q:
# Return a small payload of mocked results to keep this scaffold safe
results = [
{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}
]
results = [{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}]
suggestions = [{"text": q, "url": "#"}]
context = {"results": results, "suggestions": suggestions}
# If HTMX request, render dropdown partial
if request.headers.get("HX-Request") == "true":
return render(
request, "core/search/partials/search_dropdown.html", context
)
return render(request, "core/search/partials/search_dropdown.html", context)
return render(request, self.template_name, context)
@@ -117,25 +114,75 @@ class GlobalSearchView(TemplateView):
# Default transition metadata for styling
TRANSITION_METADATA = {
# Approval transitions
"approve": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
"transition_to_approved": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
"approve": {
"style": "green",
"icon": "check",
"requires_confirm": True,
"confirm_message": "Are you sure you want to approve this?",
},
"transition_to_approved": {
"style": "green",
"icon": "check",
"requires_confirm": True,
"confirm_message": "Are you sure you want to approve this?",
},
# Rejection transitions
"reject": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
"transition_to_rejected": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
"reject": {
"style": "red",
"icon": "times",
"requires_confirm": True,
"confirm_message": "Are you sure you want to reject this?",
},
"transition_to_rejected": {
"style": "red",
"icon": "times",
"requires_confirm": True,
"confirm_message": "Are you sure you want to reject this?",
},
# Escalation transitions
"escalate": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
"transition_to_escalated": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
"escalate": {
"style": "yellow",
"icon": "arrow-up",
"requires_confirm": True,
"confirm_message": "Are you sure you want to escalate this?",
},
"transition_to_escalated": {
"style": "yellow",
"icon": "arrow-up",
"requires_confirm": True,
"confirm_message": "Are you sure you want to escalate this?",
},
# Assignment transitions
"assign": {"style": "blue", "icon": "user-plus", "requires_confirm": False},
"unassign": {"style": "gray", "icon": "user-minus", "requires_confirm": False},
# Status transitions
"start": {"style": "blue", "icon": "play", "requires_confirm": False},
"complete": {"style": "green", "icon": "check-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to complete this?"},
"cancel": {"style": "red", "icon": "ban", "requires_confirm": True, "confirm_message": "Are you sure you want to cancel this?"},
"complete": {
"style": "green",
"icon": "check-circle",
"requires_confirm": True,
"confirm_message": "Are you sure you want to complete this?",
},
"cancel": {
"style": "red",
"icon": "ban",
"requires_confirm": True,
"confirm_message": "Are you sure you want to cancel this?",
},
"reopen": {"style": "blue", "icon": "redo", "requires_confirm": False},
# Resolution transitions
"resolve": {"style": "green", "icon": "check-double", "requires_confirm": True, "confirm_message": "Are you sure you want to resolve this?"},
"dismiss": {"style": "gray", "icon": "times-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to dismiss this?"},
"resolve": {
"style": "green",
"icon": "check-double",
"requires_confirm": True,
"confirm_message": "Are you sure you want to resolve this?",
},
"dismiss": {
"style": "gray",
"icon": "times-circle",
"requires_confirm": True,
"confirm_message": "Are you sure you want to dismiss this?",
},
# Default
"default": {"style": "gray", "icon": "arrow-right", "requires_confirm": False},
}
@@ -155,7 +202,9 @@ def get_transition_metadata(transition_name: str) -> Dict[str, Any]:
return TRANSITION_METADATA["default"].copy()
def add_toast_trigger(response: HttpResponse, message: str, toast_type: str = "success") -> HttpResponse:
def add_toast_trigger(
response: HttpResponse, message: str, toast_type: str = "success"
) -> HttpResponse:
"""
Add HX-Trigger header to trigger Alpine.js toast.
@@ -167,17 +216,12 @@ def add_toast_trigger(response: HttpResponse, message: str, toast_type: str = "s
Returns:
Modified response with HX-Trigger header
"""
trigger_data = {
"showToast": {
"message": message,
"type": toast_type
}
}
trigger_data = {"showToast": {"message": message, "type": toast_type}}
response["HX-Trigger"] = json.dumps(trigger_data)
return response
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(csrf_protect, name="dispatch")
class FSMTransitionView(View):
"""
Generic view for handling FSM state transitions via HTMX.
@@ -212,12 +256,16 @@ class FSMTransitionView(View):
The model class or None if not found
"""
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
content_type = ContentType.objects.get(
app_label=app_label, model=model_name
)
return content_type.model_class()
except ContentType.DoesNotExist:
return None
def get_object(self, model_class: Type[Model], pk: Any, slug: Optional[str] = None) -> Model:
def get_object(
self, model_class: Type[Model], pk: Any, slug: Optional[str] = None
) -> Model:
"""
Get the model instance.
@@ -249,7 +297,9 @@ class FSMTransitionView(View):
"""
return getattr(obj, transition_name, None)
def validate_transition(self, obj: Model, transition_name: str, user) -> tuple[bool, Optional[str]]:
def validate_transition(
self, obj: Model, transition_name: str, user
) -> tuple[bool, Optional[str]]:
"""
Validate that the transition can proceed.
@@ -264,18 +314,26 @@ class FSMTransitionView(View):
method = self.get_transition_method(obj, transition_name)
if method is None:
return False, f"Transition '{transition_name}' not found on {obj.__class__.__name__}"
return (
False,
f"Transition '{transition_name}' not found on {obj.__class__.__name__}",
)
if not callable(method):
return False, f"'{transition_name}' is not a callable method"
# Check if the transition can proceed
if not can_proceed(method, user):
return False, f"Transition '{transition_name}' is not allowed from current state"
return (
False,
f"Transition '{transition_name}' is not allowed from current state",
)
return True, None
def execute_transition(self, obj: Model, transition_name: str, user, **kwargs) -> None:
def execute_transition(
self, obj: Model, transition_name: str, user, **kwargs
) -> None:
"""
Execute the transition on the object.
@@ -297,7 +355,9 @@ class FSMTransitionView(View):
def get_success_message(self, obj: Model, transition_name: str) -> str:
"""Generate a success message for the transition."""
# Clean up transition name for display
display_name = transition_name.replace("transition_to_", "").replace("_", " ").title()
display_name = (
transition_name.replace("transition_to_", "").replace("_", " ").title()
)
model_name = obj._meta.verbose_name.title()
return f"{model_name} has been {display_name.lower()}d successfully."
@@ -321,9 +381,9 @@ class FSMTransitionView(View):
model_name = obj._meta.model_name
# Special handling for parks and rides - return status section
if app_label == 'parks' and model_name == 'park':
if app_label == "parks" and model_name == "park":
return "parks/partials/park_status_actions.html"
elif app_label == 'rides' and model_name == 'ride':
elif app_label == "rides" and model_name == "ride":
return "rides/partials/ride_status_actions.html"
# Check for model-specific templates in order of preference
@@ -337,6 +397,7 @@ class FSMTransitionView(View):
# Use template loader to check if template exists
from django.template.loader import select_template
from django.template import TemplateDoesNotExist
try:
template = select_template(possible_templates)
return template.template.name
@@ -344,10 +405,7 @@ class FSMTransitionView(View):
return "htmx/updated_row.html"
def format_success_response(
self,
request: HttpRequest,
obj: Model,
transition_name: str
self, request: HttpRequest, obj: Model, transition_name: str
) -> HttpResponse:
"""
Format a successful transition response.
@@ -381,17 +439,20 @@ class FSMTransitionView(View):
return add_toast_trigger(response, message, "success")
# Regular request - return JSON
return JsonResponse({
"success": True,
"message": message,
"new_state": getattr(obj, obj.state_field_name, None) if hasattr(obj, "state_field_name") else None,
})
return JsonResponse(
{
"success": True,
"message": message,
"new_state": (
getattr(obj, obj.state_field_name, None)
if hasattr(obj, "state_field_name")
else None
),
}
)
def format_error_response(
self,
request: HttpRequest,
error: Exception,
status_code: int = 400
self, request: HttpRequest, error: Exception, status_code: int = 400
) -> HttpResponse:
"""
Format an error response.
@@ -408,10 +469,13 @@ class FSMTransitionView(View):
return add_toast_trigger(response, message, "error")
# Regular request - return JSON
return JsonResponse({
"success": False,
"error": error_data,
}, status=status_code)
return JsonResponse(
{
"success": False,
"error": error_data,
},
status=status_code,
)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Handle POST request to execute a transition."""
@@ -425,24 +489,22 @@ class FSMTransitionView(View):
if not all([app_label, model_name, transition_name]):
return self.format_error_response(
request,
ValueError("Missing required parameters: app_label, model_name, and transition_name"),
400
ValueError(
"Missing required parameters: app_label, model_name, and transition_name"
),
400,
)
if not pk and not slug:
return self.format_error_response(
request,
ValueError("Missing required parameter: pk or slug"),
400
request, ValueError("Missing required parameter: pk or slug"), 400
)
# Get the model class
model_class = self.get_model_class(app_label, model_name)
if model_class is None:
return self.format_error_response(
request,
ValueError(f"Model '{app_label}.{model_name}' not found"),
404
request, ValueError(f"Model '{app_label}.{model_name}' not found"), 404
)
# Get the object
@@ -450,13 +512,13 @@ class FSMTransitionView(View):
obj = self.get_object(model_class, pk, slug)
except ObjectDoesNotExist:
return self.format_error_response(
request,
ValueError(f"Object not found: {model_name} with pk={pk}"),
404
request, ValueError(f"Object not found: {model_name} with pk={pk}"), 404
)
# Validate the transition
can_execute, error_msg = self.validate_transition(obj, transition_name, request.user)
can_execute, error_msg = self.validate_transition(
obj, transition_name, request.user
)
if not can_execute:
return self.format_error_response(
request,
@@ -466,7 +528,7 @@ class FSMTransitionView(View):
current_state=getattr(obj, "status", None),
requested_transition=transition_name,
),
400
400,
)
# Execute the transition
@@ -509,7 +571,5 @@ class FSMTransitionView(View):
f"Unexpected error during transition '{transition_name}' on {model_class.__name__}(pk={obj.pk})"
)
return self.format_error_response(
request,
ValueError(f"An unexpected error occurred: {str(e)}"),
500
request, ValueError(f"An unexpected error occurred: {str(e)}"), 500
)