feat: production hardening v1.2.0
Some checks failed
Build and Push Docker Image / build (push) Successful in 59s
CI / Security audit (push) Successful in 1m5s
CI / Tests & coverage (push) Successful in 1m24s
Docs Check / Markdown lint (push) Failing after 45s
Docs Check / Mermaid diagram parse check (push) Successful in 1m27s
CI / Security audit (pull_request) Successful in 51s
CI / Tests & coverage (pull_request) Successful in 1m1s
Docs Check / Markdown lint (pull_request) Failing after 39s
Docs Check / Mermaid diagram parse check (pull_request) Successful in 1m12s
Some checks failed
Build and Push Docker Image / build (push) Successful in 59s
CI / Security audit (push) Successful in 1m5s
CI / Tests & coverage (push) Successful in 1m24s
Docs Check / Markdown lint (push) Failing after 45s
Docs Check / Mermaid diagram parse check (push) Successful in 1m27s
CI / Security audit (pull_request) Successful in 51s
CI / Tests & coverage (pull_request) Successful in 1m1s
Docs Check / Markdown lint (pull_request) Failing after 39s
Docs Check / Mermaid diagram parse check (pull_request) Successful in 1m12s
Phase 1 - Licensing & Compliance: - Add MIT LICENSE file - Add copyright headers to server/index.js, poller.js, config.js, sanitizeError.js, and new loadSecrets.js Phase 2 - Security Hardening: - Add server/utils/loadSecrets.js: Docker secrets support via _FILE env var pattern (COOKIE_SECRET_FILE, EMBY_API_KEY_FILE, etc.) - Add SSRF/URL validation in config.js: validates all configured service instance URLs for scheme and well-formedness at startup - Add SIGTERM/SIGINT graceful shutdown: stops poller, drains HTTP connections, 10s force-exit fallback - Warn at startup if COOKIE_SECRET is shorter than 32 characters - Validate EMBY_URL scheme at startup - Improve sanitizeError: redact host:port from axios error URLs while preserving path/query for other redaction patterns Phase 3 - Config Robustness: - Weak COOKIE_SECRET warning (< 32 chars) - EMBY_URL validated via validateInstanceUrl on startup Phase 4 - Docker & Deployment: - .dockerignore: add tests/, coverage/, vitest.config.js, CHANGELOG.md, SECURITY.md, LICENSE, .markdownlint.json - docker-compose.yaml: add commented Option B (Docker secrets _FILE pattern) alongside existing plain-env Option A Phase 5 - Docs & Release Readiness: - Add CHANGELOG.md with entries from v1.0.0 to v1.2.0 - Update SECURITY.md: supported versions table, fix Docker secrets note to reflect _FILE support now implemented - Add public/.well-known/security.txt for responsible disclosure - Bump version to 1.2.0
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
@@ -8,6 +9,7 @@ const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
require('dotenv').config();
|
||||
require('./utils/loadSecrets')();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
@@ -84,6 +86,7 @@ const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup environment validation
|
||||
@@ -94,11 +97,16 @@ if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||
process.exit(1);
|
||||
} else if (!cookieSecret) {
|
||||
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
|
||||
} else if (cookieSecret.length < 32) {
|
||||
console.warn('[Security] COOKIE_SECRET is shorter than 32 characters — use openssl rand -hex 32');
|
||||
}
|
||||
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Config] EMBY_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.EMBY_URL) {
|
||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -318,3 +326,27 @@ server.listen(PORT, () => {
|
||||
console.log(`=================================`);
|
||||
startPoller();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graceful shutdown — handle SIGTERM (Docker stop) and SIGINT (Ctrl+C)
|
||||
// Stop the poller, close the HTTP server (stops accepting new connections),
|
||||
// then let Node drain existing keep-alive connections and exit cleanly.
|
||||
// ---------------------------------------------------------------------------
|
||||
const { stopPoller } = require('./utils/poller');
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`[Server] ${signal} received — shutting down gracefully`);
|
||||
stopPoller();
|
||||
server.close(() => {
|
||||
console.log('[Server] HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
// Force exit after 10 s if connections don't drain
|
||||
setTimeout(() => {
|
||||
console.error('[Server] Forced exit after 10 s timeout');
|
||||
process.exit(1);
|
||||
}, 10000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
// Validate that a configured service URL is well-formed and uses http(s).
|
||||
// Emits a warning (never throws) so a misconfigured instance degrades
|
||||
// gracefully rather than crashing the whole server.
|
||||
function validateInstanceUrl(url, instanceId) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has no URL configured`);
|
||||
return false;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has an invalid URL: "${url}"`);
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" URL must use http or https, got "${parsed.protocol}"`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
|
||||
// Try to parse JSON array format first
|
||||
if (envVar) {
|
||||
@@ -9,10 +32,11 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
const instances = JSON.parse(cleaned);
|
||||
if (Array.isArray(instances) && instances.length > 0) {
|
||||
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
|
||||
return instances.map((inst, idx) => ({
|
||||
...inst,
|
||||
id: inst.name || `instance-${idx + 1}`
|
||||
}));
|
||||
return instances.map((inst, idx) => {
|
||||
const id = inst.name || `instance-${idx + 1}`;
|
||||
validateInstanceUrl(inst.url, id);
|
||||
return { ...inst, id };
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
|
||||
@@ -22,6 +46,7 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
// Fall back to legacy single-instance format
|
||||
if (legacyUrl && legacyKey) {
|
||||
logToFile(`[Config] Using legacy single-instance format`);
|
||||
validateInstanceUrl(legacyUrl, 'default');
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
@@ -74,5 +99,6 @@ module.exports = {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getQbittorrentInstances,
|
||||
parseInstances
|
||||
parseInstances,
|
||||
validateInstanceUrl
|
||||
};
|
||||
|
||||
52
server/utils/loadSecrets.js
Normal file
52
server/utils/loadSecrets.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
//
|
||||
// Docker secrets support: if an environment variable named FOO_FILE is set,
|
||||
// read its contents from the file at that path and expose it as FOO.
|
||||
// This follows the standard *_FILE convention used by official Docker images.
|
||||
//
|
||||
// Supported secrets:
|
||||
// COOKIE_SECRET_FILE → COOKIE_SECRET
|
||||
// EMBY_API_KEY_FILE → EMBY_API_KEY
|
||||
// SABNZBD_API_KEY_FILE → SABNZBD_API_KEY (legacy single-instance)
|
||||
// SONARR_API_KEY_FILE → SONARR_API_KEY (legacy single-instance)
|
||||
// RADARR_API_KEY_FILE → RADARR_API_KEY (legacy single-instance)
|
||||
// QBITTORRENT_PASSWORD_FILE → QBITTORRENT_PASSWORD (legacy single-instance)
|
||||
//
|
||||
// For multi-instance JSON arrays the secret values must be embedded in the
|
||||
// JSON string itself; file-based loading is for the legacy single-key format.
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SECRET_MAPPINGS = [
|
||||
'COOKIE_SECRET',
|
||||
'EMBY_API_KEY',
|
||||
'SABNZBD_API_KEY',
|
||||
'SONARR_API_KEY',
|
||||
'RADARR_API_KEY',
|
||||
'QBITTORRENT_PASSWORD',
|
||||
];
|
||||
|
||||
function loadSecrets() {
|
||||
for (const key of SECRET_MAPPINGS) {
|
||||
const fileEnv = `${key}_FILE`;
|
||||
const filePath = process.env[fileEnv];
|
||||
if (!filePath) continue;
|
||||
if (process.env[key]) {
|
||||
console.warn(`[Secrets] Both ${key} and ${fileEnv} are set — ${fileEnv} takes precedence`);
|
||||
}
|
||||
try {
|
||||
const value = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (!value) {
|
||||
console.warn(`[Secrets] ${fileEnv} points to an empty file: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
console.log(`[Secrets] Loaded ${key} from ${fileEnv}`);
|
||||
} catch (err) {
|
||||
console.error(`[Secrets] Failed to read ${fileEnv} (${filePath}): ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loadSecrets;
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getTorrents } = require('./qbittorrent');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
// Query-param secrets (SABnzbd apikey, generic token/password params)
|
||||
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
|
||||
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
|
||||
@@ -7,13 +8,18 @@ const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|a
|
||||
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
||||
// Basic auth credentials in URLs (http://user:pass@host)
|
||||
const BASIC_AUTH_URL_PATTERN = /\/\/[^:@/\s]+:[^@/\s]+@/gi;
|
||||
// Redact only the host:port authority portion of URLs, preserving path/query so
|
||||
// other patterns (QUERY_SECRET_PATTERN etc.) can still act on them.
|
||||
// Negative lookahead skips URLs already handled by BASIC_AUTH_URL_PATTERN.
|
||||
const HOST_PATTERN = /(https?:\/\/)(?!\[REDACTED\]@)([^\s/?#]+)/gi;
|
||||
|
||||
function sanitizeError(err) {
|
||||
let msg = (err && err.message) ? err.message : String(err);
|
||||
msg = msg.replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
|
||||
msg = msg.replace(HEADER_PATTERN, (m) => m.split(/[\s:]/)[0] + ':[REDACTED]');
|
||||
msg = msg.replace(BEARER_PATTERN, 'bearer [REDACTED]');
|
||||
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@');
|
||||
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@'); // must run before HOST_PATTERN
|
||||
msg = msg.replace(HOST_PATTERN, '$1[HOST]');
|
||||
// Never leak stack traces to API responses
|
||||
return msg;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user