mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
fix(security): drop unsafe-inline/unsafe-eval from script-src
Move the theme FOUC snippet to /theme-init.js, allow the build-injected vendor importmap by sha256, keep blob: for runtime extensions. Document the localStorage token limitation in SECURITY.md.
This commit is contained in:
1 parent
b95c516c76
commit
81aa19dafd
8 files changed
+89
-31
No files matched your search
@@ -137,6 +137,13 @@ carry a `sink-safe: <sanitizer> — <why>` 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
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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 '<script>' not in html
|
||||
|
||||
|
||||
def test_csp_importmap_hash_matches_vendor_manifest():
|
||||
import base64, hashlib, json, re
|
||||
from app.middleware.security import VENDOR_IMPORTMAP_HASH
|
||||
src = (ROOT / 'frontend/src/plugins/runtime/vendorManifest.js').read_text(encoding='utf-8')
|
||||
pairs = re.findall(r"specifier: '([^']+)', file: '([^']+)'", src)
|
||||
body = json.dumps({'imports': {s: '/serverkit-vendor/' + f for s, f in pairs}}, separators=(',', ':'))
|
||||
digest = base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()
|
||||
assert VENDOR_IMPORTMAP_HASH == 'sha256-' + digest
|
||||
for path in ALL_CSP_CONFIGS:
|
||||
assert VENDOR_IMPORTMAP_HASH in path.read_text(encoding='utf-8')
|
||||
+1
-27
@@ -13,33 +13,7 @@
|
||||
No Google Fonts / third-party CDN: a self-hosted control panel must not
|
||||
leak every user's IP + usage to Google. Falls back to system-ui until
|
||||
the woff2 files are present (run frontend/scripts/fetch-fonts.mjs). -->
|
||||
<script>
|
||||
// Prevent flash of unstyled content (FOUC) by setting theme + accent before React loads
|
||||
(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)');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
})();
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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 / {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user