From a38e2e617bc8b6e94bff0b419ab0df36e9e4a6fc Mon Sep 17 00:00:00 2001 From: Juri Ehret Date: Fri, 15 May 2026 15:57:04 +0200 Subject: [PATCH] chore(security): harden response headers + session cookie Editor's IMSESSID cookie now ships with HttpOnly, SameSite=Lax, and Secure when the request is HTTPS (direct or via X-Forwarded-Proto from a TLS-terminating proxy). nginx no longer leaks its version, php no longer emits X-Powered-By, and every response carries CSP, X-Content- Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy. CSP is tight (no script-src 'unsafe-inline') but allow-lists cdn.jsdelivr.net since the basic theme pulls UIkit from there. style-src keeps 'unsafe-inline' because UIkit / jQuery inject inline style attributes at runtime. Verified via curl + a full editor login smoke test against the local docker stack. --- docker/Dockerfile | 1 + docker/nginx.conf | 24 +++++++++++++++++++++++- editor/index.php | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index dba0c97..138a2ca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -31,6 +31,7 @@ RUN { \ echo 'opcache.fast_shutdown=1'; \ echo 'realpath_cache_size=4M'; \ echo 'realpath_cache_ttl=600'; \ + echo 'expose_php=Off'; \ } > /usr/local/etc/php/conf.d/scriptor-demo.ini # Composer + a writable home for it (composer.json may also pull dev diff --git a/docker/nginx.conf b/docker/nginx.conf index 8557e77..c7f4437 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -29,15 +29,30 @@ http { tcp_nopush on; keepalive_timeout 65; client_max_body_size 16M; + server_tokens off; access_log /dev/stdout; + # Security headers shared by every location. nginx's add_header is + # shadowed when an inner location declares its own add_header — so + # locations that need a per-location header (e.g. /uploads/ for + # Cache-Control) repeat the security set below. + map $sent_http_content_type $scriptor_security_csp { + default "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"; + } + server { listen 80; server_name _; root /var/www/scriptor/public; index index.php; + add_header Content-Security-Policy $scriptor_security_csp always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), interest-cohort=()" always; + # Dotfiles (.git lives outside the webroot anyway, but .htaccess # is here, .env would be if it existed). location ~ /\.(?!well-known) { @@ -49,7 +64,14 @@ http { # (path includes itemId/fieldId/). location /uploads/ { expires 30d; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, immutable" always; + # Re-declare security headers because the parent's + # add_header set is shadowed when this block has its own. + add_header Content-Security-Policy $scriptor_security_csp always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), interest-cohort=()" always; } # Front controller. We deliberately leave `$uri/` OUT of the diff --git a/editor/index.php b/editor/index.php index 2075a50..7ddf177 100644 --- a/editor/index.php +++ b/editor/index.php @@ -10,6 +10,20 @@ if (! isset($_SESSION)) { session_name('IMSESSID'); + // X-Forwarded-Proto so the cookie picks up Secure when sitting + // behind a TLS-terminating reverse proxy (nginx-proxy on Hetzner, + // Caddy on the live site). Falls back to direct HTTPS / off for + // local dev (http://scriptor.cms via ServBay). + $proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null; + $secure = $proto === 'https' + || (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); session_start(); }