feat: native HTTPS support with bundled snakeoil default cert
All checks were successful
Build and Push Docker Image / build (push) Successful in 32s
CI / Security audit (push) Successful in 48s
CI / Tests & coverage (push) Successful in 56s

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
This commit is contained in:
2026-05-17 10:50:38 +01:00
parent 224ec33a14
commit da0898f52a
8 changed files with 167 additions and 15 deletions

View File

@@ -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
# =============================================================================

View File

@@ -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"]

6
certs/.gitignore vendored Normal file
View File

@@ -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

22
certs/snakeoil.crt Normal file
View File

@@ -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-----

28
certs/snakeoil.key Normal file
View File

@@ -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-----

View File

@@ -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

View File

@@ -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

View File

@@ -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'}`);