The previous fix was applied to server/app.js (the test factory) but index.js has its own independent Helmet configuration which is what the production server actually executes. Both files now gate upgrade-insecure-requests on TRUST_PROXY instead of NODE_ENV.
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const cookieParser = require('cookie-parser');
|
|
const helmet = require('helmet');
|
|
const rateLimit = require('express-rate-limit');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
require('dotenv').config();
|
|
|
|
// Setup logging with levels
|
|
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
|
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
|
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
|
|
|
|
// Log file lives in DATA_DIR so the non-root container user can write to it
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
const LOG_PATH = path.join(DATA_DIR, 'server.log');
|
|
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
|
const LOG_KEEP = 3; // keep 3 rotated files
|
|
|
|
function rotateLogIfNeeded() {
|
|
try {
|
|
const stat = fs.statSync(LOG_PATH);
|
|
if (stat.size < LOG_MAX_BYTES) return;
|
|
for (let i = LOG_KEEP - 1; i >= 1; i--) {
|
|
const src = `${LOG_PATH}.${i}`;
|
|
const dst = `${LOG_PATH}.${i + 1}`;
|
|
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
}
|
|
fs.renameSync(LOG_PATH, `${LOG_PATH}.1`);
|
|
} catch { /* ignore rotation errors — don't crash the server */ }
|
|
}
|
|
|
|
rotateLogIfNeeded();
|
|
const logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' });
|
|
const originalConsoleLog = console.log;
|
|
const originalConsoleError = console.error;
|
|
const originalConsoleWarn = console.warn;
|
|
const originalConsoleDebug = console.debug;
|
|
|
|
function shouldLog(level) {
|
|
return level >= currentLevel;
|
|
}
|
|
|
|
console.debug = function(...args) {
|
|
if (!shouldLog(LOG_LEVELS.debug)) return;
|
|
const message = args.join(' ');
|
|
originalConsoleDebug.apply(console, args);
|
|
logFile.write(`[${new Date().toISOString()}] DEBUG: ${message}\n`);
|
|
};
|
|
|
|
console.log = function(...args) {
|
|
if (!shouldLog(LOG_LEVELS.info)) return;
|
|
const message = args.join(' ');
|
|
originalConsoleLog.apply(console, args);
|
|
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
|
};
|
|
|
|
console.warn = function(...args) {
|
|
if (!shouldLog(LOG_LEVELS.warn)) return;
|
|
const message = args.join(' ');
|
|
originalConsoleWarn.apply(console, args);
|
|
logFile.write(`[${new Date().toISOString()}] WARN: ${message}\n`);
|
|
};
|
|
|
|
console.error = function(...args) {
|
|
if (!shouldLog(LOG_LEVELS.error)) return;
|
|
const message = args.join(' ');
|
|
originalConsoleError.apply(console, args);
|
|
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
|
};
|
|
|
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
|
const sonarrRoutes = require('./routes/sonarr');
|
|
const radarrRoutes = require('./routes/radarr');
|
|
const embyRoutes = require('./routes/emby');
|
|
const dashboardRoutes = require('./routes/dashboard');
|
|
const authRoutes = require('./routes/auth');
|
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Startup environment validation
|
|
// ---------------------------------------------------------------------------
|
|
const cookieSecret = process.env.COOKIE_SECRET;
|
|
if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
|
console.error('[Security] COOKIE_SECRET is not set in production — aborting.');
|
|
process.exit(1);
|
|
} else if (!cookieSecret) {
|
|
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
|
|
}
|
|
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
|
|
console.error('[Config] EMBY_URL is required');
|
|
process.exit(1);
|
|
}
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3001;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
|
// req.ip reflects the real client IP (not 127.0.0.1) and
|
|
// req.secure is true when the upstream TLS is terminated by the proxy.
|
|
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
|
// ---------------------------------------------------------------------------
|
|
if (process.env.TRUST_PROXY) {
|
|
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
|
? parseInt(process.env.TRUST_PROXY, 10)
|
|
: process.env.TRUST_PROXY;
|
|
app.set('trust proxy', trustValue);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helmet v7 — security response headers
|
|
// CSP uses a per-request nonce injected into index.html so inline scripts
|
|
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
|
// ---------------------------------------------------------------------------
|
|
app.use((req, res, next) => {
|
|
// Generate a fresh nonce for every request
|
|
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
|
next();
|
|
});
|
|
|
|
app.use((req, res, next) => {
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
fontSrc: ["'self'", 'data:'],
|
|
connectSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
frameAncestors: ["'none'"],
|
|
formAction: ["'self'"],
|
|
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
|
|
}
|
|
},
|
|
hsts: {
|
|
maxAge: 31536000, // 1 year
|
|
includeSubDomains: true,
|
|
preload: true
|
|
},
|
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
crossOriginEmbedderPolicy: false // not needed for this SPA
|
|
})(req, res, next);
|
|
});
|
|
|
|
// Permissions-Policy — disable powerful browser features not needed by the app
|
|
app.use((req, res, next) => {
|
|
res.setHeader(
|
|
'Permissions-Policy',
|
|
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
|
);
|
|
next();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// General API rate limiter — applies to all /api/* routes
|
|
// More specific limiters (e.g. login) apply on top of this.
|
|
// ---------------------------------------------------------------------------
|
|
const apiLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 300, // 300 requests per IP per window (generous for polling)
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many requests, please try again later' }
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Body parsing & cookies
|
|
// ---------------------------------------------------------------------------
|
|
app.use(cookieParser(cookieSecret || undefined));
|
|
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Health / readiness endpoints (no auth, no rate-limit)
|
|
// Used by Docker HEALTHCHECK and orchestrators.
|
|
// ---------------------------------------------------------------------------
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'ok', uptime: process.uptime() });
|
|
});
|
|
|
|
app.get('/ready', (req, res) => {
|
|
// Confirm critical config is present
|
|
const ready = !!(process.env.EMBY_URL);
|
|
if (ready) {
|
|
res.json({ status: 'ready' });
|
|
} else {
|
|
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static files — served before API routes
|
|
// index.html is served manually so we can inject the CSP nonce
|
|
// ---------------------------------------------------------------------------
|
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
|
|
|
// Serve all static assets (js, css, images, icons) except index.html.
|
|
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
|
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
|
app.use(express.static(PUBLIC_DIR, {
|
|
index: false,
|
|
setHeaders(res, filePath) {
|
|
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Serve index.html with CSP nonce injected into <script> tags
|
|
function serveIndex(req, res) {
|
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
|
if (err) return res.status(500).send('Internal Server Error');
|
|
const nonce = res.locals.cspNonce;
|
|
// Only inject nonce into <script> tags — style-src 'self' already permits
|
|
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
|
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
|
// the old nonce which no longer matches the per-request CSP header).
|
|
const patched = html
|
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.send(patched);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
|
// CSRF protection applies to all state-changing /api/* requests except
|
|
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
|
// ---------------------------------------------------------------------------
|
|
app.use('/api', apiLimiter);
|
|
app.use('/api/auth', authRoutes);
|
|
|
|
// All routes below this point require CSRF validation on mutating methods
|
|
app.use('/api', verifyCsrf);
|
|
app.use('/api/sabnzbd', sabnzbdRoutes);
|
|
app.use('/api/sonarr', sonarrRoutes);
|
|
app.use('/api/radarr', radarrRoutes);
|
|
app.use('/api/emby', embyRoutes);
|
|
app.use('/api/dashboard', dashboardRoutes);
|
|
|
|
// SPA catch-all — serve index.html for any unmatched path
|
|
app.get('*', serveIndex);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global error handler — never leak stack traces to clients
|
|
// ---------------------------------------------------------------------------
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
console.error('[Server] Unhandled error:', err.message);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`=================================`);
|
|
console.log(` sofarr - Your Downloads Dashboard`);
|
|
console.log(` Server running on port ${PORT}`);
|
|
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'}`);
|
|
console.log(`=================================`);
|
|
startPoller();
|
|
});
|