From da0898f52ad6d0ce6976bdb45cada88c7e36a76a Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 10:50:38 +0100 Subject: [PATCH] feat: native HTTPS support with bundled snakeoil default cert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/index.js: - Import http and https modules - Resolve TLS_ENABLED early (before Helmet) so upgradeInsecureRequests CSP directive fires when TLS is active directly (not only via proxy) - loadTlsCredentials() reads TLS_CERT/TLS_KEY (defaulting to bundled snakeoil) and returns null on failure (graceful HTTP fallback) - Start https.createServer or http.createServer depending on credentials - Startup banner now shows protocol, TLS cert path, and snakeoil warning certs/: - Add bundled snakeoil self-signed certificate (RSA 2048, 10yr, SAN for localhost + 127.0.0.1) for out-of-the-box HTTPS without configuration - .gitignore allows only snakeoil.{crt,key} — real certs must not be committed Dockerfile: - COPY certs/ into image so snakeoil default is always available - HEALTHCHECK updated to https:// with --no-check-certificate docker-compose.yaml: - Port now exposes HTTPS directly by default - TLS_CERT/TLS_KEY/TLS_ENABLED/TRUST_PROXY documented with Option A/B - cert volume mount examples added (commented out) - healthcheck updated to https with --no-check-certificate .env.sample: - New TLS/HTTPS section with TLS_ENABLED, TLS_CERT, TLS_KEY - openssl self-signed cert generation example included docs/ARCHITECTURE.md: - Configuration table: TLS_ENABLED, TLS_CERT, TLS_KEY env vars added - Docker image section: TLS default behaviour documented - Docker Compose example: Option A (direct TLS) / Option B (proxy) layout - Security checklist: HTTPS now first item, updated for TLS modes --- .env.sample | 18 +++++++++++++++ Dockerfile | 7 +++++- certs/.gitignore | 6 +++++ certs/snakeoil.crt | 22 ++++++++++++++++++ certs/snakeoil.key | 28 +++++++++++++++++++++++ docker-compose.yaml | 26 +++++++++++++++++----- docs/ARCHITECTURE.md | 22 +++++++++++++----- server/index.js | 53 +++++++++++++++++++++++++++++++++++++++++--- 8 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 certs/.gitignore create mode 100644 certs/snakeoil.crt create mode 100644 certs/snakeoil.key diff --git a/.env.sample b/.env.sample index 4618eea..5aba643 100644 --- a/.env.sample +++ b/.env.sample @@ -19,6 +19,24 @@ LOG_LEVEL=info # Generate with: openssl rand -hex 32 COOKIE_SECRET=your-cookie-secret-here +# ============================================================================= +# TLS / HTTPS +# ============================================================================= + +# TLS is enabled by default using the bundled snakeoil self-signed certificate +# (valid for localhost/127.0.0.1, 10-year expiry). +# Set TLS_CERT and TLS_KEY to use your own certificate (recommended). +# Set TLS_ENABLED=false to run in plain HTTP mode (e.g. behind a TLS proxy). +# +# To generate a self-signed cert for your own hostname: +# openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt \ +# -days 365 -nodes -subj "/CN=yourhostname" \ +# -addext "subjectAltName=DNS:yourhostname,IP:192.168.x.x" +# +# TLS_ENABLED=true +# TLS_CERT=/path/to/server.crt +# TLS_KEY=/path/to/server.key + # ============================================================================= # REVERSE PROXY & DEPLOYMENT # ============================================================================= diff --git a/Dockerfile b/Dockerfile index 63f3dff..c5c8e09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,9 @@ COPY --from=deps /app/node_modules ./node_modules COPY --chown=root:root server/ ./server/ COPY --chown=root:root public/ ./public/ COPY --chown=root:root package.json ./ +# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only). +# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars. +COPY --chown=root:root certs/ ./certs/ # Persistent data directory owned by node user (token store, logs) RUN mkdir -p /app/data && chown node:node /app/data @@ -47,7 +50,9 @@ USER node EXPOSE 3001 # HEALTHCHECK — Docker will restart the container if this fails 3 times +# --no-check-certificate handles self-signed / snakeoil certs. +# Remove that flag when using a CA-signed certificate. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:3001/health || exit 1 + CMD wget -qO- --no-check-certificate https://localhost:3001/health || exit 1 CMD ["node", "server/index.js"] diff --git a/certs/.gitignore b/certs/.gitignore new file mode 100644 index 0000000..fac9498 --- /dev/null +++ b/certs/.gitignore @@ -0,0 +1,6 @@ +# Ignore all cert/key files EXCEPT the bundled snakeoil development defaults. +# Never commit real TLS certificates or private keys to version control. +* +!.gitignore +!snakeoil.crt +!snakeoil.key diff --git a/certs/snakeoil.crt b/certs/snakeoil.crt new file mode 100644 index 0000000..9af3c74 --- /dev/null +++ b/certs/snakeoil.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDoTCCAomgAwIBAgIUPgupZzf+zgMp4ylvf1NBRIWNpeUwDQYJKoZIhvcNAQEL +BQAwUjELMAkGA1UEBhMCR0IxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh +bDEPMA0GA1UECgwGc29mYXJyMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjYwNTE3 +MDk0NDQ4WhcNMzYwNTE0MDk0NDQ4WjBSMQswCQYDVQQGEwJHQjEOMAwGA1UECAwF +TG9jYWwxDjAMBgNVBAcMBUxvY2FsMQ8wDQYDVQQKDAZzb2ZhcnIxEjAQBgNVBAMM +CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL5Y05DF +9U3k27SGByJqdbKThRRabX0hsK0zckkbXoaj0U4odZatUicAqkm6yWzpqQyZM6qH +XX68LXqJk8r2VIdTTtKe5QU1WUAsW6KD0c514YC1VZ3a9ivQ2/tfdQsm8YxM/ECq +e2XFklfvE72HkHF4fM9LCMS/LeazazF0ogNTkyE27g9Ry/ofR2P4MymLtyBbQ1NA +B1zYRIhJ5HqFRMszBKhWi1zRgUQQNBuYA5wtxnXA9QNSz6ObtdWStfJ/C1Kuitpe +OVrB/TKCp1OKBpZTd4mBKlvdJWos9eZPzP4vLnsReT4pHBx6J2Jd1dC97/MAXLYP +mIXP+04zK5sWRUsCAwEAAaNvMG0wHQYDVR0OBBYEFCpYKb3zMgv4qThe8ukFrVIl +lhD3MB8GA1UdIwQYMBaAFCpYKb3zMgv4qThe8ukFrVIllhD3MA8GA1UdEwEB/wQF +MAMBAf8wGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA +A4IBAQBQ5Jg0pn4XW/560laNl7XAoGuMwrIy5j6zDlIgFE8DWAb0NGNyu/FkCVIZ +ruLNktq+w+kTeneAuYW3CGyac2HZo9T60VtQNL/k17hrDw1F6kMpAAYm6aiyFtE9 +Stw07PMvpvjxKvetPLOQsfk0/hh0Nh2PiVVdqvVE/gGoraboHEfH+eGf/Arzg7s4 +CzZTEz0OJP5i7VkZAvFygShPx/gHY77ojeHPl2LN3KI7s43TrjYZjxbUDwWDpGp0 +BIsDFP+9NAvA5I74biChfEEopmBczVbQRtqBxBX1JTa2aT5w/ifUNyKVKIGp49Q8 +o59gDmbCXhypom7OsyxBLZgyVWU1 +-----END CERTIFICATE----- diff --git a/certs/snakeoil.key b/certs/snakeoil.key new file mode 100644 index 0000000..253cb09 --- /dev/null +++ b/certs/snakeoil.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+WNOQxfVN5Nu0 +hgcianWyk4UUWm19IbCtM3JJG16Go9FOKHWWrVInAKpJusls6akMmTOqh11+vC16 +iZPK9lSHU07SnuUFNVlALFuig9HOdeGAtVWd2vYr0Nv7X3ULJvGMTPxAqntlxZJX +7xO9h5BxeHzPSwjEvy3ms2sxdKIDU5MhNu4PUcv6H0dj+DMpi7cgW0NTQAdc2ESI +SeR6hUTLMwSoVotc0YFEEDQbmAOcLcZ1wPUDUs+jm7XVkrXyfwtSroraXjlawf0y +gqdTigaWU3eJgSpb3SVqLPXmT8z+Ly57EXk+KRwceidiXdXQve/zAFy2D5iFz/tO +MyubFkVLAgMBAAECggEAEk0RCyNCJBSdqSKWLtFq8o0rdBsDpugyOymuOQHOjOxu +oQo6NnWaNF5kgQVAyfd0EpGFfavyw2iNVaUPo1zoU9ogwuOWSnHOj64UIcp0+PN6 +VHknohWqdukBLyiGfnJM/0ieaKTbi2i7dGsfDA+rxbUoO6s2GYPC6O9inlUqVJGU +fBXKTbCneW1+hJQFcOKPW2+qwiUxbudG9neNTYFOm9a7Bfa6MLJ22mkfYVrHYDzo +gESo8sHXbEtvemka9jN0N/GoXgUi7iFXbqslPUoakCF7sgG0cXuUM9duSt67ynLj +j7Ix+QiKAZgvSO/83b7vK74Xmd6XFUif41VMZ2iEGQKBgQDqp/ZSCAakarRpBRN4 +psKuhaYiBKurb5GezTOSfEGWxmng2s0bHcx74alKKNN8Tu2mNx6yafQdRNQdM+fG +dn8JnQUGXYpGwQbLHZ/aO0M64WBxfQku4JNGh+VZ3ZyUC+So28vzCYqx8qwJi94L +2sF8787hzHO1INzVaeXzQ89pEwKBgQDPqRzsJsP+zZmA4x16CtoWY7Z1d4z0GTkA +erlXNinu76AIvra6aUld/65ymMyNIIpLZoci55ZGxBY7g8U29e10iT+xmxAgq3VT +Tn438MbDXEgA+HvdRXCFPvNQQk0ZDegazdhTdtuhj+IHcm4M94zfVM3MSJOr0hJf +JGaVIGEx6QKBgDZLftckPEU221+haQv1qf4vtm0Qn5gfTJZt7Izsa1CzwDPi7Kpl +jrbrU/xwzd5pdNuMzXGCypUrI9lN9Ucai/JxfoQmiKQubZ/5zs7z/25UT7hysflC +xVEAiLTubhhjWBkqIlqtzoW2HNBoqIwdpb9+zWO5ptw2KmLHCgnrmsY5AoGBAKOt +YCaix4lm9L8qRGmVdCCBp6ce+/LKjqtaEAw1nQe/yBwcdlqn8jQs+4tH9LKoG1kj +DxDsCP7uP7fZPPD9FpTsOU/8MNIPUwK+s63UElaZvgdF1BusR+w+mfmAyNQeqfu2 +k/P1k1fc2QOVpjiCRn8hkLSb4AlmIyTqxBB23SVBAoGATMtuk1r+SS9SDJW73CI1 +jLkJkPGrBvX0dFZGtedpwLVhUTDnqKIsy2F1iGDRGIAy5IAiLEFx44qQrhUau7zR +/2avY0Ua9To9LW0k/matzJUKvXxYm59jh/GDp6LFU89hsL2zSd6z0Aknvh1SryCb +OSbN8wfCz53+7qea4NQEB4E= +-----END PRIVATE KEY----- diff --git a/docker-compose.yaml b/docker-compose.yaml index 145b885..5478d3c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,14 +4,23 @@ services: container_name: sofarr restart: unless-stopped ports: - - "127.0.0.1:3001:3001" # bind to loopback only — expose via reverse proxy + # Direct HTTPS (default — uses bundled snakeoil cert if TLS_CERT not set) + - "3001:3001" + # Uncomment the line below and comment out the above to bind to loopback + # only when using a reverse proxy (set TLS_ENABLED=false in that case): + # - "127.0.0.1:3001:3001" environment: - PORT=3001 - NODE_ENV=production - LOG_LEVEL=info - # Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik) - # so Express trusts X-Forwarded-For and X-Forwarded-Proto headers. - - TRUST_PROXY=1 + # --- TLS --- + # Default: TLS enabled using bundled snakeoil cert (self-signed). + # Supply your own cert/key by mounting them and setting these paths: + # - TLS_CERT=/app/certs/server.crt + # - TLS_KEY=/app/certs/server.key + # Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead. + # If using a reverse proxy, also set TRUST_PROXY=1 below. + # - TRUST_PROXY=1 # --- Replace placeholders with real values or use Docker secrets --- - COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32 - EMBY_URL=https://emby.example.com @@ -21,8 +30,11 @@ services: - SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}] - QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}] volumes: - # Persistent volume for SQLite token store and log file + # Persistent volume for token store and log file - sofarr-data:/app/data + # Mount your own TLS certificate and key (optional — snakeoil used if omitted) + # - /path/to/your/server.crt:/app/certs/server.crt:ro + # - /path/to/your/server.key:/app/certs/server.key:ro # Run as the built-in non-root 'node' user (UID/GID 1000) user: "1000:1000" # Read-only root filesystem; only the data volume is writable @@ -35,7 +47,9 @@ services: - ALL # drop all Linux capabilities cap_add: [] # add back none — Node.js needs no special caps healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"] + # Uses --no-check-certificate for self-signed / snakeoil certs. + # Remove that flag if using a CA-signed certificate. + test: ["CMD", "wget", "-qO-", "--no-check-certificate", "https://localhost:3001/health"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 70699ee..0fbfad9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -661,6 +661,9 @@ The status panel refreshes on a fixed 5-second timer and shows each SSE client w | `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). | | `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. | | `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. | +| `TLS_ENABLED` | No | `true` | Set to `false` to disable HTTPS and run plain HTTP (e.g. when TLS is terminated by a reverse proxy). | +| `TLS_CERT` | No | `certs/snakeoil.crt` | Path to the TLS certificate file (PEM). Defaults to the bundled self-signed snakeoil certificate. | +| `TLS_KEY` | No | `certs/snakeoil.key` | Path to the TLS private key file (PEM). Defaults to the bundled snakeoil key. | #### Emby @@ -726,6 +729,7 @@ The production image uses a two-stage build on `node:22-alpine`: Key environment variables set in the image: - `NODE_ENV=production` — enables production startup validation and logging - `DATA_DIR=/app/data` — token store and log file location +- TLS is **enabled by default** using the bundled snakeoil self-signed certificate (`certs/snakeoil.crt`). Set `TLS_CERT`/`TLS_KEY` to your own certificate, or set `TLS_ENABLED=false` when terminating TLS at a reverse proxy. ### Docker Compose @@ -736,12 +740,17 @@ services: container_name: sofarr restart: unless-stopped ports: - - "3001:3001" + - "3001:3001" # HTTPS by default (snakeoil cert if no TLS_CERT set) environment: - NODE_ENV=production - DATA_DIR=/app/data - COOKIE_SECRET=change-me-to-a-long-random-string - - TRUST_PROXY=1 # set if behind nginx/Traefik + # Option A: direct TLS (default). Supply your own cert/key: + # - TLS_CERT=/app/certs/server.crt + # - TLS_KEY=/app/certs/server.key + # Option B: behind a TLS-terminating reverse proxy: + # - TLS_ENABLED=false + # - TRUST_PROXY=1 - EMBY_URL=https://emby.example.com - EMBY_API_KEY=your-emby-api-key - SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] @@ -751,7 +760,10 @@ services: - POLL_INTERVAL=5000 - LOG_LEVEL=info volumes: - - sofarr-data:/app/data # persists tokens.json and server.log + - sofarr-data:/app/data + # Uncomment to supply your own certificate (Option A): + # - /path/to/server.crt:/app/certs/server.crt:ro + # - /path/to/server.key:/app/certs/server.key:ro volumes: sofarr-data: @@ -759,10 +771,10 @@ volumes: ### Security hardening checklist +- **Use HTTPS** — TLS is on by default (snakeoil cert). Supply `TLS_CERT`/`TLS_KEY` pointing to a CA-signed certificate for trusted HTTPS. Alternatively terminate TLS at a reverse proxy and set `TLS_ENABLED=false` + `TRUST_PROXY=1`. - **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery. -- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires. +- **Set `TRUST_PROXY=1`** only when a TLS-terminating reverse proxy sits in front — ensures `req.secure` is correct and the CSP `upgrade-insecure-requests` + `secure` cookie flag fire correctly. - **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates. -- **Use HTTPS** — set `TRUST_PROXY=1` to enable the CSP `upgrade-insecure-requests` directive, the `secure` cookie flag, and HSTS (1-year `maxAge`). - **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP. ### CI / CD diff --git a/server/index.js b/server/index.js index b2e9583..21f1c88 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,8 @@ const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); const fs = require('fs'); +const http = require('http'); +const https = require('https'); require('dotenv').config(); // Setup logging with levels @@ -99,6 +101,9 @@ if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') { const app = express(); const PORT = process.env.PORT || 3001; +// Resolve TLS_ENABLED early — used in Helmet CSP and server startup +const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false'; + // --------------------------------------------------------------------------- // Trust proxy — required when behind Nginx/Caddy/Traefik so that // req.ip reflects the real client IP (not 127.0.0.1) and @@ -137,7 +142,7 @@ app.use((req, res, next) => { baseUri: ["'self'"], frameAncestors: ["'none'"], formAction: ["'self'"], - upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null + upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null } }, hsts: { @@ -258,10 +263,52 @@ app.use((err, req, res, next) => { res.status(500).json({ error: 'Internal server error' }); }); -app.listen(PORT, () => { +// --------------------------------------------------------------------------- +// TLS / HTTPS support +// Set TLS_CERT and TLS_KEY to paths of your certificate and private key. +// If unset, defaults to the bundled snakeoil self-signed certificate +// (localhost/127.0.0.1 only — suitable for local testing). +// Set TLS_ENABLED=false to force plain HTTP even if cert files exist. +// --------------------------------------------------------------------------- +const CERTS_DIR = path.join(__dirname, '../certs'); +const TLS_CERT_PATH = process.env.TLS_CERT || path.join(CERTS_DIR, 'snakeoil.crt'); +const TLS_KEY_PATH = process.env.TLS_KEY || path.join(CERTS_DIR, 'snakeoil.key'); + +function loadTlsCredentials() { + if (!TLS_ENABLED) return null; + try { + return { + cert: fs.readFileSync(TLS_CERT_PATH), + key: fs.readFileSync(TLS_KEY_PATH) + }; + } catch (err) { + console.warn(`[TLS] Could not load certificate files — falling back to HTTP. (${err.message})`); + return null; + } +} + +const tlsCredentials = loadTlsCredentials(); +const server = tlsCredentials + ? https.createServer(tlsCredentials, app) + : http.createServer(app); + +const protocol = tlsCredentials ? 'https' : 'http'; +const isSnakeoil = TLS_ENABLED && + (!process.env.TLS_CERT || process.env.TLS_CERT === TLS_CERT_PATH); + +server.listen(PORT, () => { console.log(`=================================`); console.log(` sofarr - Your Downloads Dashboard`); - console.log(` Server running on port ${PORT}`); + console.log(` Server running on ${protocol}://localhost:${PORT}`); + if (tlsCredentials && isSnakeoil) { + console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`); + console.warn(` [TLS] Set TLS_CERT and TLS_KEY for a trusted certificate.`); + console.warn(` [TLS] Set TLS_ENABLED=false to disable TLS entirely.`); + } else if (tlsCredentials) { + console.log(` [TLS] Certificate: ${TLS_CERT_PATH}`); + } else { + console.warn(` [TLS] Running in plain HTTP mode — not suitable for production.`); + } console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`); console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`); console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);