feat(security): production hardening for external deployment
Container (Dockerfile):
- Multi-stage build (deps + runtime) for minimal attack surface
- Upgrade base image from node:18-alpine to node:22-alpine
- Run as non-root 'node' user (UID 1000); source files owned by root
- /app/data directory owned by node for SQLite + logs
- Docker HEALTHCHECK: wget /health every 30s
docker-compose.yaml:
- Port bound to 127.0.0.1 only (expose via reverse proxy)
- read_only: true filesystem; /tmp tmpfs for Node.js
- no-new-privileges:true, cap_drop: ALL
- Named volume sofarr-data for persistent data
- TRUST_PROXY, COOKIE_SECRET, NODE_ENV added
Helmet v7 + CSP nonce:
- Upgrade helmet@4 → helmet@7, express-rate-limit@6 → @7
- CSP with per-request nonce injected into index.html script/link tags
(replaces blanket unsafe-inline; nonce changes every request)
- HSTS: max-age=1yr, includeSubDomains, preload
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy: camera/mic/geolocation/payment/usb all off
- index.html served dynamically with nonce injection; static assets
served normally via express.static({index:false})
Trust proxy:
- TRUST_PROXY env var configures app.set('trust proxy') so rate
limiting and secure cookies work correctly behind Nginx/Caddy
Session & auth:
- Token store migrated from in-memory Map to SQLite via better-sqlite3
(server/utils/tokenStore.js): survives restarts, WAL mode, 31-day TTL
- CSRF double-submit cookie pattern (server/middleware/verifyCsrf.js):
POST/PUT/PATCH/DELETE on /api/* require X-CSRF-Token header matching
the csrf_token cookie; timing-safe comparison
- CSRF token issued on login + GET /api/auth/csrf; cleared on logout
- Login input validation: username/password length + type checked before
hitting Emby
- skipSuccessfulRequests:true on login rate limiter (only count failures)
- express.json({ limit: '64kb' }) to reject oversized payloads
Rate limiting:
- General API limiter: 300 req/15min per IP on all /api/* routes
- Login limiter unchanged (10 failures/15min) but now only counts fails
Logging:
- Log file moved from /app/server.log to DATA_DIR/server.log (writable
by non-root node user in container)
- Size-based rotation: rotate at 10 MB, keep 3 files (server.log.1-3)
- DATA_DIR defaults to ./data locally, /app/data in container
Error handling:
- Global Express error handler: catches unhandled errors, logs message,
returns generic 500 (no stack traces to clients)
Health/readiness:
- GET /health: returns {status:'ok', uptime:N} — used by HEALTHCHECK
- GET /ready: returns 503 if EMBY_URL not configured
Error sanitization (sanitizeError.js):
- Added patterns for password= params, bearer tokens, Basic auth in URLs
Supply chain:
- Remove unused cors dependency
- add better-sqlite3@^9
- CI: upgrade to Node 22, raise audit level to --audit-level=high
- .gitignore: add data/, *.db, *.db-wal, *.db-shm
Docs:
- SECURITY.md: threat model, hardening checklist, proxy examples,
header table, rate limit table, Docker secrets guidance
- .env.example + .env.sample: TRUST_PROXY, DATA_DIR documented
This commit is contained in:
@@ -4,6 +4,7 @@ let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
let isAdmin = false;
|
||||
let showAll = false;
|
||||
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
@@ -118,9 +119,15 @@ function dismissSplash(startTime) {
|
||||
async function checkAuthentication() {
|
||||
const splashStart = Date.now();
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
|
||||
// Fetch both auth state and a fresh CSRF token in parallel
|
||||
const [meRes, csrfRes] = await Promise.all([
|
||||
fetch('/api/auth/me'),
|
||||
fetch('/api/auth/csrf')
|
||||
]);
|
||||
const data = await meRes.json();
|
||||
const csrfData = await csrfRes.json();
|
||||
if (csrfData.csrfToken) csrfToken = csrfData.csrfToken;
|
||||
|
||||
if (data.authenticated) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
@@ -160,6 +167,8 @@ async function handleLogin(e) {
|
||||
if (data.success) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
// Store CSRF token returned by login for use in subsequent requests
|
||||
if (data.csrfToken) csrfToken = data.csrfToken;
|
||||
// Fade out login, then show splash while loading data.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
@@ -185,9 +194,11 @@ async function handleLogout() {
|
||||
try {
|
||||
stopAutoRefresh();
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||
});
|
||||
currentUser = null;
|
||||
csrfToken = null;
|
||||
downloads = [];
|
||||
showLogin();
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user