diff --git a/.replit b/.replit index 8f36374f..3520ba63 100644 --- a/.replit +++ b/.replit @@ -62,10 +62,6 @@ externalPort = 3000 localPort = 45245 externalPort = 3001 -[[ports]] -localPort = 46739 -externalPort = 3002 - [deployment] deploymentTarget = "autoscale" run = [ diff --git a/apps/core/middleware/security_headers.py b/apps/core/middleware/security_headers.py new file mode 100644 index 00000000..67d74e92 --- /dev/null +++ b/apps/core/middleware/security_headers.py @@ -0,0 +1,97 @@ +""" +Modern Security Headers Middleware for ThrillWiki +Implements Content Security Policy and other modern security headers. +""" + +import secrets +import base64 +from django.conf import settings +from django.utils.deprecation import MiddlewareMixin + + +class SecurityHeadersMiddleware(MiddlewareMixin): + """ + Middleware to add modern security headers to all responses. + """ + + def _generate_nonce(self): + """Generate a cryptographically secure nonce for CSP.""" + # Generate 16 random bytes and encode as base64 + return base64.b64encode(secrets.token_bytes(16)).decode('ascii') + + def _modify_csp_with_nonce(self, csp_policy, nonce): + """Modify CSP policy to include nonce for script-src.""" + if not csp_policy: + return csp_policy + + # Look for script-src directive and add nonce + directives = csp_policy.split(';') + modified_directives = [] + + for directive in directives: + directive = directive.strip() + if directive.startswith('script-src '): + # Add nonce to script-src directive + directive += f" 'nonce-{nonce}'" + modified_directives.append(directive) + + return '; '.join(modified_directives) + + def process_request(self, request): + """Generate and store nonce for this request.""" + # Generate a nonce for this request + nonce = self._generate_nonce() + # Store it in request so templates can access it + request.csp_nonce = nonce + return None + + def process_response(self, request, response): + """Add security headers to the response.""" + + # Content Security Policy with nonce support + if hasattr(settings, 'SECURE_CONTENT_SECURITY_POLICY'): + csp_policy = settings.SECURE_CONTENT_SECURITY_POLICY + # Apply nonce if we have one for this request + if hasattr(request, 'csp_nonce'): + csp_policy = self._modify_csp_with_nonce(csp_policy, request.csp_nonce) + response['Content-Security-Policy'] = csp_policy + + # Cross-Origin Opener Policy + if hasattr(settings, 'SECURE_CROSS_ORIGIN_OPENER_POLICY'): + response['Cross-Origin-Opener-Policy'] = settings.SECURE_CROSS_ORIGIN_OPENER_POLICY + + # Referrer Policy + if hasattr(settings, 'SECURE_REFERRER_POLICY'): + response['Referrer-Policy'] = settings.SECURE_REFERRER_POLICY + + # Permissions Policy + if hasattr(settings, 'SECURE_PERMISSIONS_POLICY'): + response['Permissions-Policy'] = settings.SECURE_PERMISSIONS_POLICY + + # Additional security headers + response['X-Content-Type-Options'] = 'nosniff' + response['X-Frame-Options'] = getattr(settings, 'X_FRAME_OPTIONS', 'DENY') + response['X-XSS-Protection'] = '1; mode=block' + + # Cache Control headers for better performance + # Prevent caching of HTML pages to ensure users get fresh content + if response.get('Content-Type', '').startswith('text/html'): + response['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response['Pragma'] = 'no-cache' + response['Expires'] = '0' + + # Strict Transport Security (if SSL is enabled) + if getattr(settings, 'SECURE_SSL_REDIRECT', False): + hsts_seconds = getattr(settings, 'SECURE_HSTS_SECONDS', 31536000) + hsts_include_subdomains = getattr(settings, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', True) + hsts_preload = getattr(settings, 'SECURE_HSTS_PRELOAD', False) + + hsts_header = f'max-age={hsts_seconds}' + if hsts_include_subdomains: + hsts_header += '; includeSubDomains' + if hsts_preload: + hsts_header += '; preload' + + response['Strict-Transport-Security'] = hsts_header + + return response \ No newline at end of file diff --git a/config/django/base.py b/config/django/base.py index 13a385d8..3bb5bee2 100644 --- a/config/django/base.py +++ b/config/django/base.py @@ -122,6 +122,7 @@ MIDDLEWARE = [ "django.middleware.cache.UpdateCacheMiddleware", "corsheaders.middleware.CorsMiddleware", # CORS middleware for API "django.middleware.security.SecurityMiddleware", + "apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Modern security headers "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", diff --git a/config/settings/security.py b/config/settings/security.py index 32586aa2..28d8374c 100644 --- a/config/settings/security.py +++ b/config/settings/security.py @@ -34,3 +34,37 @@ SESSION_COOKIE_SAMESITE = env("SESSION_COOKIE_SAMESITE", default="Lax") CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False) CSRF_COOKIE_HTTPONLY = env.bool("CSRF_COOKIE_HTTPONLY", default=True) CSRF_COOKIE_SAMESITE = env("CSRF_COOKIE_SAMESITE", default="Lax") + +# Content Security Policy (CSP) - Tightened security without unsafe directives +SECURE_CONTENT_SECURITY_POLICY = env( + "SECURE_CONTENT_SECURITY_POLICY", + default=( + "default-src 'self'; " + "script-src 'self' " + "https://unpkg.com https://cdnjs.cloudflare.com; " + "style-src 'self' " + "https://fonts.googleapis.com https://cdnjs.cloudflare.com; " + "img-src 'self' data: https: blob:; " + "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; " + "connect-src 'self'; " + "media-src 'self'; " + "object-src 'none'; " + "frame-src 'none'; " + "worker-src 'self'; " + "manifest-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "upgrade-insecure-requests;" + ) +) + +# Additional modern security headers +SECURE_CROSS_ORIGIN_OPENER_POLICY = env("SECURE_CROSS_ORIGIN_OPENER_POLICY", default="same-origin") +SECURE_REFERRER_POLICY = env("SECURE_REFERRER_POLICY", default="strict-origin-when-cross-origin") +SECURE_PERMISSIONS_POLICY = env( + "SECURE_PERMISSIONS_POLICY", + default="geolocation=(), camera=(), microphone=(), payment=()" +) + +# X-Frame-Options alternative - more flexible +X_FRAME_OPTIONS = env("X_FRAME_OPTIONS", default="DENY") diff --git a/static/css/inline-styles.css b/static/css/inline-styles.css new file mode 100644 index 00000000..502b3652 --- /dev/null +++ b/static/css/inline-styles.css @@ -0,0 +1,29 @@ +/* Inline styles that were moved from the base template for CSP compliance */ + +[x-cloak] { + display: none !important; +} + +.dropdown-menu { + position: absolute; + right: 0; + margin-top: 0.5rem; + width: 12rem; + border-radius: 0.375rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + z-index: 50; + overflow: hidden; +} + +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: block; +} + +.htmx-request.htmx-indicator { + display: block; +} \ No newline at end of file diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 2e75e5d1..a14b6601 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2957,6 +2957,11 @@ left: calc(var(--spacing) * 4); } } + .focus\:z-50 { + &:focus { + z-index: 50; + } + } .focus\:border-blue-500 { &:focus { border-color: var(--color-blue-500); diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 00000000..44fa008b --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,16 @@ +/** + * Theme management script + * Prevents flash of wrong theme by setting theme class immediately + */ +(function() { + let theme = localStorage.getItem("theme"); + if (!theme) { + theme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + localStorage.setItem("theme", theme); + } + if (theme === "dark") { + document.documentElement.classList.add("dark"); + } +})(); \ No newline at end of file diff --git a/templates/base/base.html b/templates/base/base.html index 95951dca..f88b1251 100644 --- a/templates/base/base.html +++ b/templates/base/base.html @@ -5,30 +5,68 @@ + +