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

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:
2026-05-17 19:40:07 +01:00
parent 3c4c24d0e4
commit c0dd93a1ab
12 changed files with 745 additions and 21 deletions

View File

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

80
CHANGELOG.md Normal file
View File

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

View File

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

View File

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

504
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

@@ -1,3 +1,4 @@
// Copyright (c) 2025 Gordon Bolton. MIT License.
const axios = require('axios');
const cache = require('./cache');
const { getTorrents } = require('./qbittorrent');

View File

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