From cf2946cfa67157db716e15544fdd547945ec0c3e Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sun, 22 Mar 2026 15:42:48 -0400 Subject: [PATCH] fix(security): remove CSP upgrade-insecure-requests and HSTS over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helmet's defaults are designed for HTTPS-only deployments. Two directives were actively breaking plain-HTTP self-hosted instances: - upgrade-insecure-requests: causes browsers to upgrade all JS/CSS/asset fetches to HTTPS → ERR_SSL_PROTOCOL_ERROR → completely blank page - Strict-Transport-Security: permanently instructs browsers to refuse HTTP for 1 year, even after the header is removed from the server Also handle docker socket GID=0 (root:root) edge case in entrypoint and add [entrypoint] diagnostic log lines for easier debugging. --- CHANGELOG.md | 5 ++++ backend/src/index.ts | 29 ++++++++++++++++-- docker-entrypoint.sh | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d340c75..9593a0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Blank page on HTTP deployments (CSP `upgrade-insecure-requests`):** Helmet's default CSP included `upgrade-insecure-requests`, which causes browsers to silently upgrade all HTTP sub-resource fetches (JS, CSS, images) to HTTPS. On a plain-HTTP self-hosted deployment this produces a completely blank page with `ERR_SSL_PROTOCOL_ERROR`. The directive is now explicitly omitted from the CSP. +- **HSTS permanently breaking HTTP access:** Helmet's default `Strict-Transport-Security` header was being sent over HTTP, instructing browsers to refuse non-HTTPS connections for 1 year. HSTS is now disabled and must only be re-enabled by users who terminate HTTPS at a reverse proxy in front of Sencho. +- **Docker socket EACCES root:root edge case:** Entrypoint now handles the case where the Docker socket is owned by root:root (GID 0) in addition to the standard root:docker case. Diagnostic `[entrypoint]` log lines are emitted at startup to make group detection visible in `docker logs`. + ### Added - **Resources Hub — Managed/Unmanaged Separation:** All Docker resources (images, volumes, networks) are now classified as `managed` (belonging to a Sencho stack), `external` (belonging to another Compose project), or `unused`/`system`. Classification is exposed via a new `GET /api/system/resources` endpoint that makes 4 parallel Docker API calls once and returns all three resource types in a single round trip. - **Docker Disk Footprint widget:** Replaces the Reclaimable Space donut chart with an interactive horizontal stacked bar showing Sencho Managed vs External Projects vs Reclaimable bytes. Clicking a segment filters the Images and Volumes tabs simultaneously. diff --git a/backend/src/index.ts b/backend/src/index.ts index dcc7e27b..46a0c095 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -65,9 +65,32 @@ const getCookieOptions = (req: Request) => ({ // Middleware // Security headers (X-Frame-Options, X-Content-Type-Options, etc.) -// crossOriginEmbedderPolicy is disabled because the Monaco editor uses workers -// that don't set the required COEP headers. -app.use(helmet({ crossOriginEmbedderPolicy: false })); +// crossOriginEmbedderPolicy: disabled — Monaco editor workers lack COEP headers. +// hsts: disabled — HSTS must only be set when the app is served over HTTPS. +// Enabling it over HTTP permanently breaks browser access for 1 year. +// contentSecurityPolicy.upgrade-insecure-requests: removed — this directive +// tells browsers to silently upgrade all HTTP sub-resource fetches to HTTPS. +// On a plain-HTTP self-hosted deployment (the common case) this causes every +// JS/CSS asset to fail with ERR_SSL_PROTOCOL_ERROR, producing a blank page. +app.use(helmet({ + crossOriginEmbedderPolicy: false, + hsts: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + baseUri: ["'self'"], + fontSrc: ["'self'", 'https:', 'data:'], + formAction: ["'self'"], + frameAncestors: ["'self'"], + imgSrc: ["'self'", 'data:'], + objectSrc: ["'none'"], + scriptSrc: ["'self'"], + scriptSrcAttr: ["'none'"], + styleSrc: ["'self'", 'https:', "'unsafe-inline'"], + // 'upgrade-insecure-requests' is intentionally absent — see comment above. + }, + }, +})); // CORS — in production restrict to the configured frontend origin. // In development, mirror the request origin so Vite's dev server works. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..6071a15a --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,71 @@ +#!/bin/sh +set -e + +# Resolve the data directory, mirroring DatabaseService.ts logic. +DATA_DIR="${DATA_DIR:-/app/data}" + +# If running as root (the default Docker container start), fix volume ownership, +# fix Docker socket group access, then drop privileges before executing the app. +# +# This is the industry-standard pattern used by the official PostgreSQL, Redis, +# and MariaDB Docker images, and by Docker management tools like Portainer and +# Dockge that also require access to /var/run/docker.sock as a non-root user. +# +# The UID guard also ensures compatibility with strict environments like +# Kubernetes (runAsNonRoot: true) or OpenShift, where the container is forced to +# run as a random high UID. In that case both blocks are skipped and the app +# exec's directly without crashing. +if [ "$(id -u)" = '0' ]; then + + # ── 1. Fix data volume ownership ──────────────────────────────────────── + # Handles host volumes previously created by root or a different UID, which + # would cause SQLITE_READONLY errors when the non-root sencho user starts. + # Only touches files with wrong user OR group (efficient on large dirs). + mkdir -p "$DATA_DIR" + find "$DATA_DIR" \( \! -user sencho -o \! -group sencho \) \ + -exec chown sencho:sencho '{}' + + echo "[entrypoint] Data directory ownership ensured: $DATA_DIR" + + # ── 2. Fix Docker socket group access ─────────────────────────────────── + # The Docker socket on the host is owned by the host's docker group, whose + # GID varies by Linux distribution and does not match any group inside the + # container by default. + # + # Solution: detect the socket's GID at runtime, create a matching group + # inside the container if one doesn't already exist, then add sencho to it. + # su-exec calls getgrouplist() which reads /etc/group, so supplementary + # groups added here ARE inherited by the Node process. + if [ -S /var/run/docker.sock ]; then + DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock) + DOCKER_SOCK_MODE=$(stat -c '%a' /var/run/docker.sock) + echo "[entrypoint] Docker socket found: GID=$DOCKER_SOCK_GID mode=$DOCKER_SOCK_MODE" + + if [ "$DOCKER_SOCK_GID" = "0" ]; then + # Socket is owned by root:root. The only way to access it without + # running as root is to make it group-readable and assign a shared + # group. We create a 'docker-sock' group with GID 0 for clarity. + echo "[entrypoint] WARNING: Docker socket is root:root — adding sencho to root group" + addgroup sencho root 2>/dev/null || true + else + if ! getent group "$DOCKER_SOCK_GID" > /dev/null 2>&1; then + addgroup -S -g "$DOCKER_SOCK_GID" docker-host + echo "[entrypoint] Created group docker-host with GID $DOCKER_SOCK_GID" + fi + DOCKER_GROUP=$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1) + addgroup sencho "$DOCKER_GROUP" 2>/dev/null || true + echo "[entrypoint] Added sencho to group '$DOCKER_GROUP' (GID $DOCKER_SOCK_GID)" + fi + else + echo "[entrypoint] WARNING: /var/run/docker.sock not found — Docker features will be unavailable" + fi + + echo "[entrypoint] Dropping privileges to sencho (uid=$(id -u sencho))" + + # ── 3. Drop privileges ────────────────────────────────────────────────── + # Replace this shell process with su-exec so that Node becomes PID 1 and + # receives SIGTERM/SIGINT directly from Docker. su-exec calls getgrouplist() + # for named users, so all supplementary groups set above are inherited. + exec su-exec sencho "$@" +fi + +exec "$@"