From c0dd93a1ab66b6a1661ff0318c550641ec3bdeac Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 19:40:07 +0100 Subject: [PATCH] feat: production hardening v1.2.0 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 --- .dockerignore | 7 + CHANGELOG.md | 80 +++++ SECURITY.md | 15 +- docker-compose.yaml | 21 +- package-lock.json | 504 +++++++++++++++++++++++++++++++- package.json | 5 +- public/.well-known/security.txt | 5 + server/index.js | 32 ++ server/utils/config.js | 36 ++- server/utils/loadSecrets.js | 52 ++++ server/utils/poller.js | 1 + server/utils/sanitizeError.js | 8 +- 12 files changed, 745 insertions(+), 21 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 public/.well-known/security.txt create mode 100644 server/utils/loadSecrets.js diff --git a/.dockerignore b/.dockerignore index 7a09ebc..a6ee69f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,7 +10,14 @@ node_modules/ client/ dist/ build/ +coverage/ +tests/ +vitest.config.js +.markdownlint.json README.md +CHANGELOG.md +SECURITY.md +LICENSE .dockerignore Dockerfile .gitea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c27dfa5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,80 @@ +# Changelog + +All notable changes to this project will be documented in this file. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [1.2.0] - 2025-05-17 + +### Security + +- **Docker secrets support** — all sensitive environment variables (`COOKIE_SECRET`, `EMBY_API_KEY`, `SABNZBD_API_KEY`, `SONARR_API_KEY`, `RADARR_API_KEY`, `QBITTORRENT_PASSWORD`) now support the standard `_FILE` variant for loading values from mounted secret files (e.g. `COOKIE_SECRET_FILE=/run/secrets/cookie_secret`). +- **Weak secret warning** — server now warns at startup if `COOKIE_SECRET` is shorter than 32 characters. +- **EMBY_URL validation** — validates the Emby URL scheme at startup and warns on misconfiguration. +- **Improved error sanitization** — `sanitizeError()` now also redacts hostnames from full request URLs that may appear in axios error messages. +- **Graceful shutdown** — `SIGTERM` and `SIGINT` handlers now stop the background poller and drain open HTTP connections before exiting. Prevents data loss and zombie processes on `docker stop`. + +### Compliance + +- **MIT LICENSE file** added to project root. +- **Copyright headers** added to key server source files (`index.js`, `poller.js`, `config.js`, `sanitizeError.js`, `loadSecrets.js`). +- **`security.txt`** (`/.well-known/security.txt`) added for responsible disclosure. + +### Configuration + +- **URL validation** added to `config.js` — all configured service instance URLs are validated for scheme (`http`/`https`) and well-formedness at startup; malformed URLs emit a warning instead of crashing. + +### Docker / Deployment + +- **`docker-compose.yaml`** updated with commented Option B (Docker secrets `_FILE` pattern) alongside the existing plain-env Option A. +- **`.dockerignore`** updated — `tests/`, `coverage/`, `vitest.config.js`, `CHANGELOG.md`, `SECURITY.md`, `LICENSE`, `.markdownlint.json` excluded from the production image. + +### CI + +- **`docs-check` workflow** added — separate Gitea Actions workflow that lints all Markdown files and validates Mermaid diagram syntax on every push that touches `.md` files. Both jobs use `continue-on-error: true` so documentation issues never block a release. +- **Mermaid diagrams** in `docs/ARCHITECTURE.md` fixed — replaced invalid `\n` in stateDiagram transition labels, Unicode arrows/dashes, and double-spaces in flowchart edge definitions. + +--- + +## [1.1.2] - 2025-05-15 + +### Changed + +- Server startup message now includes the current version (`sofarr v1.1.2`). + +--- + +## [1.1.1] - 2025-05-14 + +### Fixed + +- Docker/TrueNAS SCALE healthcheck: dynamic HTTP/HTTPS selection based on `TLS_ENABLED` environment variable. Prevents containers from being stuck in "starting" state when `TLS_ENABLED=false`. + +--- + +## [1.1.0] - 2025-05-13 + +### Added + +- **Episode display** — TV show download cards now show episode information (S01E01 format with title). Multi-episode packs show a "Multiple episodes" badge with a tooltip listing all episodes. +- **Episode tooltip** — solid background colour (theme-dependent) for readability. +- Sonarr queue and history API requests now include `includeEpisode=true`. + +--- + +## [1.0.0] - 2025-05-01 + +### Added + +- Initial release. +- SABnzbd queue and history integration. +- qBittorrent torrent integration. +- Sonarr and Radarr queue/history matching with user tag filtering. +- Emby/Jellyfin authentication. +- Server-Sent Events (SSE) real-time dashboard. +- Per-request CSP nonce, CSRF double-submit, HSTS, Permissions-Policy. +- Background polling with configurable interval and on-demand fallback. +- Docker multi-stage build, non-root user, read-only filesystem. +- TLS support with bundled snakeoil certificate. diff --git a/SECURITY.md b/SECURITY.md index 448ed35..5ef39fe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,9 +4,10 @@ | Version | Supported | |---------|-----------| -| 1.0.x | ✅ Yes | -| 0.2.x | ❌ No | -| 0.1.x | ❌ No | +| 1.2.x | ✅ Yes | +| 1.1.x | ✅ Yes | +| 1.0.x | ❌ No | +| < 1.0 | ❌ No | ## Reporting a Vulnerability @@ -79,10 +80,10 @@ services: - EMBY_API_KEY_FILE=/run/secrets/emby_api_key ``` -> Note: File-based secret loading requires application code support. -> Currently sofarr reads secrets from environment variables only. -> Mounting secrets as env vars (via `environment:` in compose) is the -> current supported approach. +> Since v1.2.0, sofarr natively supports the `_FILE` pattern. +> Set `COOKIE_SECRET_FILE=/run/secrets/cookie_secret` and sofarr will +> read the secret value from that file at startup. See `docker-compose.yaml` +> for a complete example. --- diff --git a/docker-compose.yaml b/docker-compose.yaml index 5bb2895..1448404 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,7 +21,8 @@ services: # 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 --- + # --- Secrets: use _FILE variants (Docker secrets) in production ------- + # Option A — plain environment variables (simple, less secure): - COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32 - EMBY_URL=https://emby.example.com - EMBY_API_KEY=your-emby-api-key @@ -29,6 +30,17 @@ services: - RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}] - 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"}] + # Option B — Docker secrets (_FILE pattern, recommended for production): + # Uncomment the lines below and comment out Option A above. + # Create secret files with: echo -n "value" > ./secrets/cookie_secret.txt + # - COOKIE_SECRET_FILE=/run/secrets/cookie_secret + # - EMBY_API_KEY_FILE=/run/secrets/emby_api_key + # - SONARR_API_KEY_FILE=/run/secrets/sonarr_api_key # legacy single-instance only + # - RADARR_API_KEY_FILE=/run/secrets/radarr_api_key # legacy single-instance only + # - SABNZBD_API_KEY_FILE=/run/secrets/sabnzbd_api_key # legacy single-instance only + # secrets: # uncomment when using Option B + # - cookie_secret + # - emby_api_key volumes: # Persistent volume for token store and log file - sofarr-data:/app/data @@ -57,3 +69,10 @@ services: volumes: sofarr-data: + +# Docker secrets definitions (uncomment and populate when using Option B above) +# secrets: +# cookie_secret: +# file: ./secrets/cookie_secret.txt +# emby_api_key: +# file: ./secrets/emby_api_key.txt diff --git a/package-lock.json b/package-lock.json index 65a10a0..44e94af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "0.1.5", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "0.1.5", + "version": "1.1.2", "license": "MIT", "dependencies": { "axios": "^1.6.0", @@ -14,7 +14,8 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.0.0", - "helmet": "^7.0.0" + "helmet": "^7.0.0", + "jsdom": "^29.1.1" }, "devDependencies": { "@vitest/coverage-v8": "^4.1.6", @@ -25,6 +26,53 @@ "vitest": "^4.1.6" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -95,6 +143,152 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -129,6 +323,23 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -198,7 +409,7 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -854,6 +1065,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1168,6 +1388,32 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -1194,6 +1440,12 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1291,6 +1543,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1713,6 +1977,18 @@ "node": ">=16.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -1873,6 +2149,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -1932,6 +2214,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -2207,6 +2529,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2254,6 +2585,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -2518,6 +2855,18 @@ "dev": true, "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2628,6 +2977,15 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2690,6 +3048,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -2760,6 +3127,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -2933,7 +3312,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3103,6 +3481,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3178,6 +3562,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3210,6 +3612,30 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3247,6 +3673,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3468,6 +3903,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3510,6 +3989,21 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 597127c..15f196b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.1.2", + "version": "1.2.0", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { @@ -21,7 +21,8 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.0.0", - "helmet": "^7.0.0" + "helmet": "^7.0.0", + "jsdom": "^29.1.1" }, "devDependencies": { "@vitest/coverage-v8": "^4.1.6", diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..f9165c6 --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,5 @@ +Contact: mailto:gordon@i3omb.com +Expires: 2026-12-31T23:59:00.000Z +Preferred-Languages: en +Canonical: https://git.i3omb.com/Gandalf/sofarr +Policy: https://git.i3omb.com/Gandalf/sofarr/src/branch/main/SECURITY.md diff --git a/server/index.js b/server/index.js index 982a851..0cada4e 100644 --- a/server/index.js +++ b/server/index.js @@ -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')); diff --git a/server/utils/config.js b/server/utils/config.js index d30bbb0..c71f8cd 100644 --- a/server/utils/config.js +++ b/server/utils/config.js @@ -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 }; diff --git a/server/utils/loadSecrets.js b/server/utils/loadSecrets.js new file mode 100644 index 0000000..e50058c --- /dev/null +++ b/server/utils/loadSecrets.js @@ -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; diff --git a/server/utils/poller.js b/server/utils/poller.js index d2ec021..9d267c3 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -1,3 +1,4 @@ +// Copyright (c) 2025 Gordon Bolton. MIT License. const axios = require('axios'); const cache = require('./cache'); const { getTorrents } = require('./qbittorrent'); diff --git a/server/utils/sanitizeError.js b/server/utils/sanitizeError.js index cd52112..2a9a60c 100644 --- a/server/utils/sanitizeError.js +++ b/server/utils/sanitizeError.js @@ -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; }