diff --git a/SECURITY.md b/SECURITY.md index 2c693641..769c50e5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -137,6 +137,13 @@ carry a `sink-safe: — ` comment. `scripts/check-html-sinks.mj fails CI on any new unannotated sink, so a raw-HTML injection point can't land silently. +## Known Limitations + +- **Tokens in `localStorage`.** The SPA keeps its JWT access/refresh tokens in + `localStorage`, so an XSS bug would expose them. The production CSP now + forbids inline and `eval` scripts (`script-src 'self'`) to reduce that risk; + a migration to `httpOnly` `SameSite` cookies is planned. + ## Extension Trust Model Extensions are **in-process code**. An installed extension runs with the panel's diff --git a/backend/app/middleware/security.py b/backend/app/middleware/security.py index 9c9b5ea9..3dfa8676 100644 --- a/backend/app/middleware/security.py +++ b/backend/app/middleware/security.py @@ -2,6 +2,10 @@ from flask import Flask +# sha256 of the build-injected import map (frontend/vite.config.js, from +# src/plugins/runtime/vendorManifest.js). test_security_headers recomputes it. +VENDOR_IMPORTMAP_HASH = 'sha256-lIJXjhqB4GwFLcjRTSjM91Q7vwXxH4Wbcn475CzkW4U=' + def register_security_headers(app: Flask): """Register security headers as an after_request handler.""" @@ -37,9 +41,13 @@ def register_security_headers(app: Flask): "form-action 'self' https://github.com", ] else: + # style-src keeps 'unsafe-inline' because React renders style="" attributes; + # script-src has no 'unsafe-inline'/'unsafe-eval': the FOUC snippet is + # /theme-init.js; the one inline script (build-injected vendor importmap) + # is allowed by hash; blob: is for runtime extensions (plugins/runtime/loader.js). csp_directives = [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "script-src 'self' '%s' blob:" % VENDOR_IMPORTMAP_HASH, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", diff --git a/backend/tests/test_security_headers.py b/backend/tests/test_security_headers.py index 2d3f06be..ba22cc4b 100644 --- a/backend/tests/test_security_headers.py +++ b/backend/tests/test_security_headers.py @@ -20,6 +20,14 @@ PANEL_NGINX_CONFIGS = [ / 'sites-available' / 'serverkit.conf', ] +ROOT = Path(__file__).resolve().parents[2] +ALL_CSP_CONFIGS = PANEL_NGINX_CONFIGS + [ + ROOT / 'nginx' / 'sites-available' / 'example.conf.template', +] + + +def _script_src(csp): + return next(d.strip() for d in csp.split(';') if d.strip().startswith('script-src ')) def _make_app(hsts_enabled, debug): @@ -86,3 +94,38 @@ def test_resolve_ssl_mode(monkeypatch): assert cfg._resolve_ssl_mode() == 'insecure' monkeypatch.delenv('SERVERKIT_SSL_MODE', raising=False) assert cfg._resolve_ssl_mode() == 'insecure' + + +def test_prod_csp_script_src_is_strict(): + r = _make_app(hsts_enabled=False, debug=False).test_client().get('/ping') + script_src = _script_src(r.headers['Content-Security-Policy']) + assert 'unsafe-eval' not in script_src + assert 'unsafe-inline' not in script_src + + +@pytest.mark.parametrize('config_path', ALL_CSP_CONFIGS) +def test_nginx_csp_script_src_is_strict(config_path): + line = next( + l for l in config_path.read_text(encoding='utf-8').splitlines() + if 'Content-Security-Policy' in l + ) + script_src = _script_src(line.split('"')[1]) + assert 'unsafe-eval' not in script_src + assert 'unsafe-inline' not in script_src + + +def test_index_html_has_no_inline_script(): + html = (ROOT / 'frontend' / 'index.html').read_text(encoding='utf-8') + assert ' + diff --git a/frontend/public/theme-init.js b/frontend/public/theme-init.js new file mode 100644 index 00000000..276873dc --- /dev/null +++ b/frontend/public/theme-init.js @@ -0,0 +1,26 @@ +// Prevent flash of unstyled content (FOUC) by setting theme + accent before React loads. +// External file (not inline) so the CSP needs no 'unsafe-inline' for scripts. +(function() { + var d = document.documentElement; + d.setAttribute('data-theme', localStorage.getItem('theme') || 'dark'); + var accent = localStorage.getItem('accent_color'); + if (accent) { + var m = accent.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); + if (m) { + var r = parseInt(m[1],16), g = parseInt(m[2],16), b = parseInt(m[3],16); + var hx = function(v){ return Math.max(0,Math.min(255,v)).toString(16).padStart(2,'0'); }; + var dk = function(v){ return Math.round(v*0.88); }; + var br = function(v){ return Math.round(v + (255-v)*0.30); }; // toward white + var dm = function(v){ return Math.round(v*0.70); }; + d.style.setProperty('--accent-primary', accent); + d.style.setProperty('--accent', accent); + d.style.setProperty('--accent-hover', '#'+hx(dk(r))+hx(dk(g))+hx(dk(b))); + d.style.setProperty('--accent-bright', '#'+hx(br(r))+hx(br(g))+hx(br(b))); + d.style.setProperty('--accent-dim', '#'+hx(dm(r))+hx(dm(g))+hx(dm(b))); + d.style.setProperty('--accent-bg', 'rgba('+r+','+g+','+b+',0.13)'); + d.style.setProperty('--accent-bg-soft', 'rgba('+r+','+g+','+b+',0.07)'); + d.style.setProperty('--accent-glow', 'rgba('+r+','+g+','+b+',0.35)'); + d.style.setProperty('--accent-shadow', 'rgba('+r+','+g+','+b+',0.3)'); + } + } +})(); diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 7b9d80d9..66d0624e 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -111,7 +111,7 @@ http { add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; # Content Security Policy - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://github.com;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-lIJXjhqB4GwFLcjRTSjM91Q7vwXxH4Wbcn475CzkW4U=' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://github.com;" always; # Connection limits limit_conn conn_limit 20; diff --git a/nginx/sites-available/example.conf.template b/nginx/sites-available/example.conf.template index 8ac8aff4..adf08076 100644 --- a/nginx/sites-available/example.conf.template +++ b/nginx/sites-available/example.conf.template @@ -58,7 +58,7 @@ server { add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-lIJXjhqB4GwFLcjRTSjM91Q7vwXxH4Wbcn475CzkW4U=' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; # Option 1: Proxy to a local app (Docker container, Node.js, etc.) location / { diff --git a/nginx/sites-available/serverkit.conf b/nginx/sites-available/serverkit.conf index a81a86a8..b92e11f4 100644 --- a/nginx/sites-available/serverkit.conf +++ b/nginx/sites-available/serverkit.conf @@ -105,7 +105,7 @@ server { # Content Security Policy # Note: 'unsafe-inline'/'unsafe-eval' are kept because many SPAs need them. # Once your build is verified to work without inline scripts, remove them. - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://github.com;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-lIJXjhqB4GwFLcjRTSjM91Q7vwXxH4Wbcn475CzkW4U=' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://github.com;" always; # ═══════════════════════════════════════════════════════════════════════════ # ServerKit frontend — served directly by host nginx from the built SPA