feat(webhooks): security hardening, tests, full documentation audit & polish (Phase 6)
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
Docs Check / Markdown lint (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 1m43s
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
Docs Check / Markdown lint (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 1m43s
This commit is contained in:
19
.env.sample
19
.env.sample
@@ -35,6 +35,20 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
||||
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||
SOFARR_BASE_URL=https://your-sofarr-url
|
||||
|
||||
# --- Webhook Polling Optimization (Phase 5) ---
|
||||
|
||||
# Minutes of silence after which the poller falls back to a full poll
|
||||
# even if webhooks were recently active. Default: 10 minutes.
|
||||
# Set lower (e.g. 2) for more aggressive fallback; higher (e.g. 30) to
|
||||
# reduce background polling on very stable setups.
|
||||
# WEBHOOK_FALLBACK_TIMEOUT=10
|
||||
|
||||
# When an instance has received a recent webhook event, the poller skips
|
||||
# its queue/history fetch entirely (saving API calls). If you still want
|
||||
# a periodic poll even with webhooks, set this to 1 to disable skipping.
|
||||
# Default behaviour: skip polling for instances with recent webhook activity.
|
||||
# WEBHOOK_POLL_INTERVAL_MULTIPLIER=3
|
||||
|
||||
# =============================================================================
|
||||
# TLS / HTTPS
|
||||
# =============================================================================
|
||||
@@ -152,4 +166,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# 4. For qBittorrent, ensure Web UI is enabled in settings
|
||||
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
|
||||
# 6. Background polling keeps data fresh; disable it for low-resource setups
|
||||
# 7. Webhooks (SOFARR_WEBHOOK_SECRET + SOFARR_BASE_URL) enable real-time
|
||||
# push updates from Sonarr/Radarr and automatically reduce polling load.
|
||||
# Use the Webhooks Configuration panel in the dashboard UI to enable them
|
||||
# with one click. The secret must match the header value in each *arr
|
||||
# notification connection (X-Sofarr-Webhook-Secret).
|
||||
# =============================================================================
|
||||
|
||||
233
ARCHITECTURE.md
Normal file
233
ARCHITECTURE.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# sofarr — Architecture Reference
|
||||
|
||||
> Concise top-level architecture guide. For the full deep-dive (API reference, matching pipeline, deployment) see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
sofarr is a **Node.js/Express** single-page application. It aggregates download activity from multiple media automation services, filters results by Emby user identity, and presents a real-time personalised dashboard.
|
||||
|
||||
Three pluggable layers form the core:
|
||||
|
||||
| Layer | Name | Location |
|
||||
|-------|------|----------|
|
||||
| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` |
|
||||
| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` |
|
||||
| Real-time push | **Webhook receiver** | `server/routes/webhook.js` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Request / Data Flow
|
||||
|
||||
```
|
||||
Browser (SPA)
|
||||
│ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie
|
||||
│ GET /api/dashboard/stream → SSE stream → poller cache → matched downloads
|
||||
│ POST /api/webhook/* ← Sonarr/Radarr push events
|
||||
│
|
||||
▼
|
||||
Express Server (:3001)
|
||||
├── Helmet (CSP nonce, HSTS, X-Frame-Options, …)
|
||||
├── express-rate-limit (300/15 min general; 60/1 min webhook)
|
||||
├── cookie-parser (HMAC-signed session cookie)
|
||||
├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook)
|
||||
│
|
||||
├── /api/auth → login, logout, me, csrf
|
||||
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
|
||||
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
|
||||
├── /api/dashboard → requireAuth → read cache → match downloads → SSE/JSON
|
||||
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
|
||||
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
|
||||
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
|
||||
|
||||
Background:
|
||||
Poller (setInterval POLL_INTERVAL ms)
|
||||
└── shouldSkipInstancePolling? ──yes──► extend cache TTL, increment pollsSkipped
|
||||
│ no (or fallback triggered)
|
||||
▼
|
||||
PDCA Registry.getDownloadsByClientType()
|
||||
PALDRA Registry.getQueuesByType() / getHistoryByType() / getTagsByType()
|
||||
│
|
||||
▼
|
||||
cache.set('poll:*', data, TTL)
|
||||
│
|
||||
▼
|
||||
notify pollSubscribers → SSE push to all connected browsers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Pluggable Download Client Architecture (PDCA)
|
||||
|
||||
All download clients extend `DownloadClient` (abstract base in `server/clients/DownloadClient.js`):
|
||||
|
||||
```
|
||||
DownloadClient (abstract)
|
||||
├── SABnzbdClient — REST API, API key auth
|
||||
├── QBittorrentClient — Sync API (incremental deltas), cookie auth, fallback to /torrents/info
|
||||
├── TransmissionClient — JSON-RPC, session-ID management
|
||||
└── RTorrentClient — XML-RPC, HTTP Basic Auth
|
||||
```
|
||||
|
||||
`DownloadClientRegistry` (`server/utils/downloadClients.js`) initialises all configured clients from `*_INSTANCES` env vars, fetches from all in parallel, and returns a `{ sabnzbd, qbittorrent, transmission, rtorrent }` map. Individual client failures are isolated.
|
||||
|
||||
**Adding a new client:** extend `DownloadClient`, implement `getActiveDownloads()` returning `NormalizedDownload[]`, register in the registry factory.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pluggable *Arr Retrieval Architecture (PALDRA)
|
||||
|
||||
`server/utils/arrRetrievers.js` provides `arrRetrieverRegistry` which:
|
||||
- Initialises one retriever per configured Sonarr/Radarr instance
|
||||
- Exposes `getQueuesByType()`, `getHistoryByType()`, `getTagsByType()` — returning results keyed by `sonarr` / `radarr`
|
||||
- Results carry `{ instance: instanceId, data: … }` so callers can look up instance credentials
|
||||
|
||||
The poller and webhook processor both use the same registry, ensuring consistency.
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook Flow (Phase 1–5.1)
|
||||
|
||||
```
|
||||
Sonarr/Radarr
|
||||
POST /api/webhook/sonarr (X-Sofarr-Webhook-Secret: <secret>)
|
||||
{
|
||||
"eventType": "Grab",
|
||||
"instanceName": "Main Sonarr",
|
||||
"date": "2026-05-19T10:00:00.000Z",
|
||||
…
|
||||
}
|
||||
│
|
||||
▼
|
||||
webhookLimiter (60 req/min/IP)
|
||||
│
|
||||
▼
|
||||
validateWebhookSecret() ──fail──► 401 Unauthorized
|
||||
│ ok
|
||||
▼
|
||||
validatePayload() ──fail──► 400 Bad Request
|
||||
│ ok
|
||||
▼
|
||||
isReplay() ──yes───► 200 { received: true, duplicate: true }
|
||||
│ no
|
||||
▼
|
||||
cache.updateWebhookMetrics(instance.url) ← activates smart polling skip
|
||||
│
|
||||
▼
|
||||
processWebhookEvent('sonarr', 'Grab') [fire-and-forget]
|
||||
├── classify: Grab → QUEUE_EVENT
|
||||
├── arrRetrieverRegistry.getQueuesByType()
|
||||
├── cache.set('poll:sonarr-queue', …, CACHE_TTL)
|
||||
└── pollAllServices() → pollSubscribers.forEach(cb) → SSE push
|
||||
│
|
||||
▼
|
||||
200 { received: true } (returned immediately, before fire-and-forget completes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Smart Polling Optimization (Phase 5)
|
||||
|
||||
```
|
||||
pollAllServices() called every POLL_INTERVAL ms:
|
||||
|
||||
globalMetrics = cache.getGlobalWebhookMetrics()
|
||||
fallbackTriggered = lastGlobalWebhookTimestamp > WEBHOOK_FALLBACK_TIMEOUT ago
|
||||
|
||||
for each service type (sonarr, radarr):
|
||||
shouldSkip = !fallbackTriggered
|
||||
&& all instances have metrics.eventsReceived > 0
|
||||
&& all instances have metrics.lastWebhookTimestamp within WEBHOOK_FALLBACK_TIMEOUT
|
||||
|
||||
if shouldSkip:
|
||||
extend TTL of existing cached data ← no API calls made
|
||||
increment metrics.pollsSkipped
|
||||
log "[Poller] Skipping sonarr polling for N instance(s) with active webhooks"
|
||||
else:
|
||||
fetch from *arr APIs → update cache
|
||||
```
|
||||
|
||||
**Result:** zero *arr API calls per poll cycle when webhooks are active and recent. Falls back automatically after `WEBHOOK_FALLBACK_TIMEOUT` minutes of silence (default: 10).
|
||||
|
||||
---
|
||||
|
||||
## 7. Cache Keys
|
||||
|
||||
| Key | Content | TTL |
|
||||
|-----|---------|-----|
|
||||
| `poll:sab-queue` | SABnzbd queue slots + status | `POLL_INTERVAL × 3` |
|
||||
| `poll:sab-history` | SABnzbd history slots | `POLL_INTERVAL × 3` |
|
||||
| `poll:sonarr-queue` | Sonarr queue records (with `_instanceUrl`) | `POLL_INTERVAL × 3` |
|
||||
| `poll:sonarr-history` | Sonarr history records | `POLL_INTERVAL × 3` |
|
||||
| `poll:sonarr-tags` | Sonarr tag list per instance | `POLL_INTERVAL × 3` |
|
||||
| `poll:radarr-queue` | Radarr queue records (with `_instanceUrl`) | `POLL_INTERVAL × 3` |
|
||||
| `poll:radarr-history` | Radarr history records | `POLL_INTERVAL × 3` |
|
||||
| `poll:radarr-tags` | Radarr tag list | `POLL_INTERVAL × 3` |
|
||||
| `poll:qbittorrent` | qBittorrent torrent list | `POLL_INTERVAL × 3` |
|
||||
| `history:sonarr` | Sonarr history (on-demand, `/api/history/recent`) | 5 min |
|
||||
| `history:radarr` | Radarr history (on-demand) | 5 min |
|
||||
| `emby:users` | Emby user list | 60 s |
|
||||
|
||||
When polling is disabled (`POLL_INTERVAL=0`), all `poll:*` TTLs fall back to 30 s.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Model
|
||||
|
||||
| Concern | Mechanism |
|
||||
|---------|-----------|
|
||||
| User authentication | Emby credentials → httpOnly HMAC-signed cookie |
|
||||
| Session validation | `requireAuth` middleware on all `/api/dashboard`, `/api/history`, proxy routes |
|
||||
| CSRF | Double-submit cookie (`X-CSRF-Token` header) on all state-changing routes |
|
||||
| Webhook auth | Shared secret on `X-Sofarr-Webhook-Secret` header (webhook routes are outside CSRF) |
|
||||
| Webhook input | `validatePayload()` allowlists event types; rejects invalid shapes |
|
||||
| Webhook replay | 5-minute nonce cache keyed on `(eventType, instanceName, date)` |
|
||||
| Rate limiting | 300 req/15 min (general), 10 fails/15 min (login), 60 req/1 min (webhook) |
|
||||
| Secret leakage | `sanitizeError()` redacts all secrets from error messages and logs |
|
||||
| Headers | Helmet v7: CSP nonce, HSTS, X-Frame-Options DENY, noSniff, Referrer-Policy |
|
||||
|
||||
---
|
||||
|
||||
## 9. Directory Structure (summary)
|
||||
|
||||
```
|
||||
sofarr/
|
||||
├── server/
|
||||
│ ├── app.js Express factory (imported by tests + index.js)
|
||||
│ ├── index.js Entry point: logging, listen, start poller
|
||||
│ ├── clients/ PDCA — one file per download client
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js Login / logout / csrf / me
|
||||
│ │ ├── dashboard.js SSE stream, downloads, status, cover-art
|
||||
│ │ ├── history.js Recently completed downloads
|
||||
│ │ ├── webhook.js Webhook receiver (Phase 1–6)
|
||||
│ │ ├── sonarr.js Sonarr API proxy + webhook management
|
||||
│ │ └── radarr.js Radarr API proxy + webhook management
|
||||
│ ├── middleware/
|
||||
│ │ ├── requireAuth.js Cookie auth enforcement
|
||||
│ │ └── verifyCsrf.js Double-submit CSRF check
|
||||
│ └── utils/
|
||||
│ ├── arrRetrievers.js PALDRA — Sonarr/Radarr fetch registry
|
||||
│ ├── cache.js MemoryCache + webhook metrics helpers
|
||||
│ ├── config.js Multi-instance config parser
|
||||
│ ├── downloadClients.js PDCA registry + factory
|
||||
│ ├── historyFetcher.js History fetch + event classification
|
||||
│ ├── poller.js Smart background polling engine
|
||||
│ ├── sanitizeError.js Secret redaction from errors
|
||||
│ └── tokenStore.js Emby token store (JSON file, atomic writes)
|
||||
├── public/ Static SPA (HTML + CSS + vanilla JS)
|
||||
├── tests/
|
||||
│ ├── setup.js Isolated DATA_DIR, SKIP_RATE_LIMIT
|
||||
│ ├── unit/ Pure unit tests
|
||||
│ └── integration/ Supertest + nock integration tests
|
||||
├── docs/ARCHITECTURE.md Full deep-dive architecture documentation
|
||||
├── ARCHITECTURE.md This file — concise reference
|
||||
├── SECURITY.md Threat model + hardening guide
|
||||
├── CHANGELOG.md Version history
|
||||
└── .env.sample Annotated configuration template
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*For complete API reference, data-flow diagrams, download matching pipeline, qBittorrent Sync API details, and deployment guidance see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).*
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -6,6 +6,54 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
#### Webhook Integration (Phases 1–5.1)
|
||||
|
||||
- **Webhook receiver endpoints** — `POST /api/webhook/sonarr` and `POST /api/webhook/radarr` accept push events from Sonarr and Radarr with shared-secret validation (`X-Sofarr-Webhook-Secret` header). Dashboard updates in < 1 second after any grab, import, or failure.
|
||||
- **Selective cache invalidation** — each incoming event is classified into *queue events* (`Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired`) or *history events* (`DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`). Only the affected cache key is refreshed via a lightweight re-poll of that instance, rather than a full poll cycle.
|
||||
- **SSE broadcast on webhook** — after refreshing the cache, `pollAllServices()` is called as a fire-and-forget, triggering an immediate SSE push to every connected browser.
|
||||
- **Notification management API proxy** — `GET /api/sonarr/api/v3/notification` and `POST /api/sonarr/api/v3/notification` (and Radarr equivalents) proxy the full *arr Notification API through sofarr with auth + CSRF enforcement.
|
||||
- **One-click webhook setup** — `POST /api/sonarr/webhook/enable` and `POST /api/radarr/webhook/enable` auto-configure the Sofarr webhook notification connection inside the respective *arr service. `POST /api/sonarr/webhook/test` and `/radarr/webhook/test` trigger a test event.
|
||||
- **Webhooks Configuration UI** — collapsible panel in the dashboard allowing users to enable, test, and view webhook status for each configured Sonarr/Radarr instance, with per-trigger type indicators showing which event types are active.
|
||||
- **`SOFARR_WEBHOOK_SECRET`** environment variable — required for webhook endpoints to accept requests. Generate with `openssl rand -hex 32`.
|
||||
- **`SOFARR_BASE_URL`** environment variable — public URL of sofarr, used by the one-click setup to tell Sonarr/Radarr where to POST events.
|
||||
|
||||
#### Smart Polling Optimization (Phase 5)
|
||||
|
||||
- **Webhook metrics tracking** — `cache.js` now maintains per-instance and global webhook metrics (`lastWebhookTimestamp`, `eventsReceived`, `pollsSkipped`) via `getWebhookMetrics()`, `updateWebhookMetrics()`, `incrementPollsSkipped()`, `getGlobalWebhookMetrics()`.
|
||||
- **Conditional poll skipping** — `poller.js` calls `shouldSkipInstancePolling()` before each Sonarr/Radarr fetch. If all instances of a type have received a webhook event within the fallback timeout window, their queue/history API calls are skipped entirely; existing cached data has its TTL extended instead.
|
||||
- **Webhook fallback** — if no webhook events have been received globally for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller forces a full poll regardless of per-instance state. Logged as `[Poller] Webhook fallback triggered`.
|
||||
- **Poll-skip logging** — logs `[Poller] Skipping sonarr/radarr polling for N instance(s) with active webhooks` when polling is skipped.
|
||||
- **`WEBHOOK_FALLBACK_TIMEOUT`** environment variable — minutes before fallback polling (default: `10`).
|
||||
- **`WEBHOOK_POLL_INTERVAL_MULTIPLIER`** environment variable — internal multiplier for TTL calculations when webhooks are active (default: `3`).
|
||||
- **Phase 5.1 metrics connection** — `webhook.js` calls `cache.updateWebhookMetrics(instance.url)` after every successfully validated event, activating the smart skip logic for that instance.
|
||||
|
||||
#### Security Hardening (Phase 6)
|
||||
|
||||
- **Dedicated webhook rate limiter** — 60 requests per minute per IP on `/api/webhook/*`, stricter than the global 300/15 min API limiter. Bypassed in tests via `SKIP_RATE_LIMIT=1`.
|
||||
- **Strict input validation** — `validatePayload()` rejects: non-object bodies, missing/non-string/overlong `eventType`, unrecognised event type values (allowlist of 18 known *arr event types), non-string `instanceName`. Returns `400` with a descriptive message.
|
||||
- **Replay protection** — `isReplay()` tracks recently-seen `(eventType, instanceName, date)` tuples in a `Map` with a 5-minute TTL. Duplicate events within the window return `200 { received: true, duplicate: true }` without triggering a cache refresh or SSE broadcast.
|
||||
- **35 new webhook integration tests** — cover secret validation (missing/wrong/unconfigured), payload validation (all invalid cases), replay protection, happy-path acceptance of all relevant event types, metrics increment, and secret-never-leaks assertions.
|
||||
|
||||
#### Documentation (Phase 6)
|
||||
|
||||
- **`README.md`** — updated architecture overview diagram, added Webhooks section with quick-setup guide, added PDCA/PALDRA/webhook architecture table, added Webhooks & Smart Polling env var section, added webhook API endpoints, updated test count.
|
||||
- **`CHANGELOG.md`** — this entry.
|
||||
- **`SECURITY.md`** — added webhook threat model rows, webhook-specific hardening checklist, and rate-limit table entry for webhook endpoints.
|
||||
- **`ARCHITECTURE.md`** (root) — new concise top-level architecture reference describing all pluggable layers and the full webhook + polling optimization flow.
|
||||
- **`.env.sample`** — added `WEBHOOK_FALLBACK_TIMEOUT` and `WEBHOOK_POLL_INTERVAL_MULTIPLIER` with explanatory comments; updated NOTES section.
|
||||
|
||||
### Changed
|
||||
|
||||
- `poller.js` — `pollAllServices()` now conditionally skips Sonarr/Radarr fetches when webhooks are active; extends TTL of existing cache entries instead of overwriting with empty data.
|
||||
- `cache.js` — exports four new webhook metrics helpers alongside the existing `MemoryCache` singleton.
|
||||
- `webhook.js` — imported `express-rate-limit`; added `validatePayload()`, `isReplay()`, `VALID_EVENT_TYPES` allowlist, `recentEvents` Map, and per-route `webhookLimiter` middleware.
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
87
README.md
87
README.md
@@ -4,6 +4,8 @@
|
||||
|
||||
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
|
||||
|
||||
Version 1.4.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
|
||||
|
||||
## What It Does
|
||||
|
||||
sofarr connects to your media stack and shows you a personalized view of:
|
||||
@@ -12,27 +14,59 @@ sofarr connects to your media stack and shows you a personalized view of:
|
||||
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
|
||||
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
|
||||
- **Multi-Instance Support** - Connect to multiple instances of each service
|
||||
- **Webhook Push Updates** - Sonarr/Radarr push events instantly to sofarr; dashboard updates in < 1s after a grab or import
|
||||
- **Smart Polling** - Background polling automatically reduces (or skips) API calls for instances with active webhooks, with configurable fallback
|
||||
|
||||
## How It Works
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
|
||||
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads) │
|
||||
│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │
|
||||
└─────────────┘ └──────────────┘ │ Transmission (Torrents) │
|
||||
│ │ rTorrent (Torrents) │
|
||||
│ │ Sonarr (TV management) │
|
||||
│ │ Radarr (Movie management) │
|
||||
│ │ Emby (User authentication) │
|
||||
▼ └─────────────────────────────┘
|
||||
┌──────────────┐
|
||||
│ Dashboard │
|
||||
│ Aggregator │
|
||||
└──────────────┘
|
||||
┌─────────────┐ ┌──────────────────────────────────────────────┐
|
||||
│ Browser │────▶│ sofarr Server │
|
||||
│ (User) │◀────│ Auth · Dashboard · History · Webhooks │
|
||||
└─────────────┘ │ │
|
||||
SSE push ◀───────│ Poller (smart: skips when webhooks active) │
|
||||
│ Cache · PDCA Download Registry · PALDRA │
|
||||
└───┬─────────────────────────┬────────────────┘
|
||||
│ polls (background) │ receives webhooks
|
||||
▼ │
|
||||
┌──────────────────────────┐ ┌─────────▼───────────────────┐
|
||||
│ Download Clients │ │ *arr Services │
|
||||
│ SABnzbd (Usenet) │ │ Sonarr (TV management) │
|
||||
│ qBittorrent (Torrent) │◀───│ Radarr (Movie management) │
|
||||
│ Transmission (Torrent) │ └─────────────────────────────┘
|
||||
│ rTorrent (Torrent) │
|
||||
└──────────────────────────┘
|
||||
│
|
||||
Emby / Jellyfin
|
||||
(User authentication)
|
||||
```
|
||||
|
||||
**Three pluggable layers power sofarr:**
|
||||
|
||||
| Layer | Name | What it does |
|
||||
|-------|------|--------------|
|
||||
| Download clients | **PDCA** (Pluggable Download Client Architecture) | Normalised interface for SABnzbd, qBittorrent, Transmission, rTorrent; easy to add new clients |
|
||||
| *arr data retrieval | **PALDRA** (Pluggable *Arr Library Data Retrieval Architecture) | Unified fetch layer for Sonarr/Radarr queue, history, and tags across multiple instances |
|
||||
| Real-time push | **Webhook receiver** | Sonarr/Radarr POST events to sofarr; cache updated immediately; SSE pushes to browser in < 1s |
|
||||
|
||||
### Webhooks
|
||||
|
||||
When webhooks are configured, sofarr receives instant push notifications from Sonarr and Radarr whenever a download is grabbed, imported, failed, or renamed. The dashboard updates in under a second — no polling delay.
|
||||
|
||||
**Quick setup:**
|
||||
1. Set `SOFARR_WEBHOOK_SECRET` and `SOFARR_BASE_URL` in your `.env`
|
||||
2. Open the sofarr dashboard → **Webhooks Configuration** panel
|
||||
3. Click **Enable** next to each Sonarr/Radarr instance
|
||||
4. sofarr auto-configures the notification connection inside each *arr service
|
||||
|
||||
**Smart polling fallback:** Once webhooks are active, the background poller automatically skips queue/history API calls for those instances. If no webhook events arrive for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller resumes a full poll automatically.
|
||||
|
||||
**Webhook endpoints** (no user authentication required — protected by `X-Sofarr-Webhook-Secret`):
|
||||
- `POST /api/webhook/sonarr` — receives Sonarr events
|
||||
- `POST /api/webhook/radarr` — receives Radarr events
|
||||
|
||||
### The Matching Process
|
||||
|
||||
1. **User Authentication**: Login via Emby credentials
|
||||
@@ -194,6 +228,17 @@ POLL_INTERVAL=5000 # Background polling interval in ms (default
|
||||
# Set to 0 or "off" to disable (on-demand mode)
|
||||
```
|
||||
|
||||
### Webhooks & Smart Polling
|
||||
```bash
|
||||
# Required for webhook endpoints to accept events
|
||||
SOFARR_WEBHOOK_SECRET=your-secret # Shared secret (generate: openssl rand -hex 32)
|
||||
SOFARR_BASE_URL=https://sofarr.example.com # Public URL used by one-click setup
|
||||
|
||||
# Optional tuning
|
||||
WEBHOOK_FALLBACK_TIMEOUT=10 # Minutes without a webhook before forcing a full poll (default: 10)
|
||||
WEBHOOK_POLL_INTERVAL_MULTIPLIER=3 # Internal multiplier used by the skip logic (default: 3)
|
||||
```
|
||||
|
||||
### Download Clients (PDCA)
|
||||
|
||||
sofarr uses a **Pluggable Download Client Architecture (PDCA)** that provides a unified interface for all download clients. This enables consistent data normalization, easy addition of new client types, and centralized configuration management.
|
||||
@@ -327,6 +372,20 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
### History
|
||||
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||
|
||||
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
||||
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
||||
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
||||
|
||||
### Webhook Management (requires auth + CSRF)
|
||||
- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections
|
||||
- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection
|
||||
- `GET /api/radarr/api/v3/notification` — list Radarr notification connections
|
||||
- `POST /api/radarr/api/v3/notification` — create/update Radarr notification connection
|
||||
- `POST /api/sonarr/webhook/enable` — one-click enable Sofarr webhook in Sonarr
|
||||
- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr
|
||||
- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event
|
||||
- `POST /api/radarr/webhook/test` — trigger a Radarr test event
|
||||
|
||||
### Service APIs (proxy to your services)
|
||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||
- `GET /api/sonarr/*` — Sonarr API proxy
|
||||
@@ -370,7 +429,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
||||
npm run test:ui # interactive Vitest UI
|
||||
```
|
||||
|
||||
145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
290 tests across 18 test files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, and webhook metrics integration. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
18
SECURITY.md
18
SECURITY.md
@@ -4,8 +4,10 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.4.x | ✅ Yes |
|
||||
| 1.3.x | ✅ Yes |
|
||||
| 1.2.x | ✅ Yes |
|
||||
| 1.1.x | ✅ Yes |
|
||||
| 1.1.x | ❌ No |
|
||||
| 1.0.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
@@ -35,6 +37,10 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
||||
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
|
||||
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
|
||||
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
|
||||
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
|
||||
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
|
||||
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
|
||||
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +55,15 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
||||
- [ ] HTTPS enforced by the reverse proxy with a valid certificate
|
||||
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
|
||||
|
||||
### Webhook-Specific (if using webhook integration)
|
||||
|
||||
- [ ] `SOFARR_WEBHOOK_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
|
||||
- [ ] `SOFARR_BASE_URL` set to the public HTTPS URL of sofarr (used by one-click setup)
|
||||
- [ ] Secret stored only in `.env` or Docker secret — never committed to source control
|
||||
- [ ] Rotate `SOFARR_WEBHOOK_SECRET` if you suspect it has been leaked; re-enable webhooks via the UI
|
||||
- [ ] Verify Sonarr/Radarr send the exact secret value in the `X-Sofarr-Webhook-Secret` header
|
||||
- [ ] Review webhook logs (`[Webhook] WARNING`) for repeated auth failures which may indicate probing
|
||||
|
||||
### Recommended
|
||||
|
||||
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
|
||||
@@ -145,6 +160,7 @@ server {
|
||||
|----------|-------|
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const cache = require('../utils/cache');
|
||||
@@ -8,6 +9,49 @@ const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/po
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Dedicated rate limiter for webhook endpoints — stricter than the global API limiter.
|
||||
// Sonarr/Radarr send at most one event per action; 60/min per IP is generous.
|
||||
// In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited.
|
||||
const webhookLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many webhook requests' }
|
||||
});
|
||||
|
||||
// Valid *arr eventType strings — used for strict input validation.
|
||||
const VALID_EVENT_TYPES = new Set([
|
||||
'Test',
|
||||
'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired',
|
||||
'DownloadFolderImported', 'ImportFailed',
|
||||
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
|
||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored'
|
||||
]);
|
||||
|
||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||
// *arr sends a `date` field on every event; we use it as the replay key component.
|
||||
// TTL = 5 minutes; an event replayed after that window is considered fresh.
|
||||
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
||||
const recentEvents = new Map();
|
||||
|
||||
function pruneReplayCache() {
|
||||
const cutoff = Date.now() - REPLAY_WINDOW_MS;
|
||||
for (const [key, ts] of recentEvents) {
|
||||
if (ts < cutoff) recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function isReplay(eventType, instanceName, eventDate) {
|
||||
if (!eventDate) return false;
|
||||
pruneReplayCache();
|
||||
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
||||
if (recentEvents.has(key)) return true;
|
||||
recentEvents.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand
|
||||
const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
|
||||
|
||||
@@ -150,21 +194,56 @@ async function processWebhookEvent(serviceType, eventType) {
|
||||
await pollAllServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize the incoming webhook payload.
|
||||
* Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }.
|
||||
*/
|
||||
function validatePayload(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { valid: false, reason: 'Payload must be a JSON object' };
|
||||
}
|
||||
const { eventType, instanceName } = body;
|
||||
if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) {
|
||||
return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' };
|
||||
}
|
||||
if (!VALID_EVENT_TYPES.has(eventType)) {
|
||||
return { valid: false, reason: `Unknown eventType: ${eventType}` };
|
||||
}
|
||||
if (instanceName !== undefined && typeof instanceName !== 'string') {
|
||||
return { valid: false, reason: 'instanceName must be a string if provided' };
|
||||
}
|
||||
const eventDate = body.date || null;
|
||||
return { valid: true, eventType, instanceName: instanceName || null, eventDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhook/sonarr
|
||||
* Receives webhook events from Sonarr instances.
|
||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
||||
*
|
||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
||||
* Phase 6: rate limiting, input validation, replay protection.
|
||||
*/
|
||||
router.post('/sonarr', (req, res) => {
|
||||
router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const validation = validatePayload(req.body);
|
||||
if (!validation.valid) {
|
||||
logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`);
|
||||
return res.status(400).json({ error: validation.reason });
|
||||
}
|
||||
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
if (isReplay(eventType, instanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const { eventType, instanceName } = req.body || {};
|
||||
logToFile(`[Webhook] Sonarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// Phase 5.1: update webhook metrics for polling optimization
|
||||
@@ -192,15 +271,28 @@ router.post('/sonarr', (req, res) => {
|
||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
||||
*
|
||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
||||
* Phase 6: rate limiting, input validation, replay protection.
|
||||
*/
|
||||
router.post('/radarr', (req, res) => {
|
||||
router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const validation = validatePayload(req.body);
|
||||
if (!validation.valid) {
|
||||
logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`);
|
||||
return res.status(400).json({ error: validation.reason });
|
||||
}
|
||||
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
if (isReplay(eventType, instanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const { eventType, instanceName } = req.body || {};
|
||||
logToFile(`[Webhook] Radarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// Phase 5.1: update webhook metrics for polling optimization
|
||||
|
||||
@@ -41,7 +41,10 @@ tests/
|
||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
└── integration/
|
||||
├── health.test.js # GET /health and /ready endpoints
|
||||
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
|
||||
└── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
|
||||
# replay protection, metrics, security assertions
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
@@ -60,6 +63,7 @@ The tested files meet these per-file minimums (enforced in CI):
|
||||
|---|---|---|
|
||||
| `server/app.js` | 85% | 65% |
|
||||
| `server/routes/auth.js` | 85% | 70% |
|
||||
| `server/routes/webhook.js` | 80% | 70% |
|
||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||
| `server/utils/sanitizeError.js` | 60% | — |
|
||||
| `server/utils/config.js` | 50% | 55% |
|
||||
|
||||
395
tests/integration/webhook.test.js
Normal file
395
tests/integration/webhook.test.js
Normal file
@@ -0,0 +1,395 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for webhook endpoints:
|
||||
* POST /api/webhook/sonarr
|
||||
* POST /api/webhook/radarr
|
||||
*
|
||||
* Uses supertest against createApp() (no real server).
|
||||
* processWebhookEvent() makes outbound *arr API calls — those are blocked by
|
||||
* nock so tests remain hermetic (fire-and-forget, not awaited by the handler).
|
||||
*
|
||||
* Covers:
|
||||
* - 401 when X-Sofarr-Webhook-Secret is missing or wrong
|
||||
* - 400 when payload is invalid (missing/unknown eventType, non-object body)
|
||||
* - 200 + { received: true } for valid events
|
||||
* - Replay protection: second identical event returns { duplicate: true }
|
||||
* - Test event (eventType=Test) is accepted and short-circuits the cache refresh
|
||||
* - cache.updateWebhookMetrics is called when a known instance name is provided
|
||||
* - cache.getGlobalWebhookMetrics reflects the recorded event
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { beforeEach, afterEach } from 'vitest';
|
||||
import { createRequire } from 'module';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cache = require('../../server/utils/cache.js');
|
||||
|
||||
const VALID_SECRET = 'test-webhook-secret-abc';
|
||||
|
||||
// Minimal valid Sonarr Grab payload
|
||||
const SONARR_GRAB = {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T10:00:00.000Z',
|
||||
series: { id: 1, title: 'Test Show' },
|
||||
episodes: [{ id: 10, episodeNumber: 1, seasonNumber: 1 }]
|
||||
};
|
||||
|
||||
// Minimal valid Radarr Grab payload
|
||||
const RADARR_GRAB = {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T10:00:01.000Z',
|
||||
movie: { id: 1, title: 'Test Movie' }
|
||||
};
|
||||
|
||||
// Minimal Test event (sent by *arr "Test" button in notifications settings)
|
||||
const SONARR_TEST = {
|
||||
eventType: 'Test',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T10:00:02.000Z'
|
||||
};
|
||||
|
||||
function makeApp() {
|
||||
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||
]);
|
||||
return createApp({ skipRateLimits: true });
|
||||
}
|
||||
|
||||
function postSonarr(app, payload, secret = VALID_SECRET) {
|
||||
const req = request(app).post('/api/webhook/sonarr').send(payload);
|
||||
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||
return req;
|
||||
}
|
||||
|
||||
function postRadarr(app, payload, secret = VALID_SECRET) {
|
||||
const req = request(app).post('/api/webhook/radarr').send(payload);
|
||||
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||
return req;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/webhook/sonarr — secret validation', () => {
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, SONARR_GRAB, null);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, SONARR_GRAB, 'wrong-secret');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/radarr — secret validation', () => {
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, RADARR_GRAB, null);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/webhook/sonarr — input validation', () => {
|
||||
it('returns 400 when body is not a JSON object (array)', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/webhook/sonarr')
|
||||
.set('X-Sofarr-Webhook-Secret', VALID_SECRET)
|
||||
.send([{ eventType: 'Grab' }]);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { instanceName: 'Main Sonarr' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/eventType/);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is an unknown value', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 'HackerThing', date: '2026-01-01T00:00:00Z' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Unknown eventType/);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is not a string', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 42 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType exceeds 64 characters', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 'G'.repeat(65) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when instanceName is not a string', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 'Grab', instanceName: 99 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/instanceName/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/radarr — input validation', () => {
|
||||
it('returns 400 when eventType is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, { instanceName: 'Main Radarr' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is unknown', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, { eventType: 'Injected', date: '2026-01-01T00:00:00Z' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Unknown eventType/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Happy path — valid events
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/webhook/sonarr — valid events', () => {
|
||||
it('returns 200 { received: true } for a valid Grab event', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { ...SONARR_GRAB, date: '2026-05-19T11:00:00.000Z' };
|
||||
const res = await postSonarr(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
expect(res.body.duplicate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 200 { received: true } for a Test event', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { ...SONARR_TEST, date: '2026-05-19T11:01:00.000Z' };
|
||||
const res = await postSonarr(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts DownloadFolderImported event', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'DownloadFolderImported',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T11:02:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts event without instanceName field', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
date: '2026-05-19T11:03:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/radarr — valid events', () => {
|
||||
it('returns 200 { received: true } for a valid Grab event', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { ...RADARR_GRAB, date: '2026-05-19T12:00:00.000Z' };
|
||||
const res = await postRadarr(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts Download event', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, {
|
||||
eventType: 'Download',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T12:01:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay protection
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Replay protection', () => {
|
||||
it('sonarr: second identical event (same date) returns duplicate:true', async () => {
|
||||
const app = makeApp();
|
||||
const payload = {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T13:00:00.000Z'
|
||||
};
|
||||
const first = await postSonarr(app, payload);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.duplicate).toBeUndefined();
|
||||
|
||||
const second = await postSonarr(app, payload);
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.duplicate).toBe(true);
|
||||
});
|
||||
|
||||
it('sonarr: event with different date is not considered a duplicate', async () => {
|
||||
const app = makeApp();
|
||||
const first = await postSonarr(app, {
|
||||
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:00:00.000Z'
|
||||
});
|
||||
expect(first.body.duplicate).toBeUndefined();
|
||||
|
||||
const second = await postSonarr(app, {
|
||||
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:01:00.000Z'
|
||||
});
|
||||
expect(second.body.duplicate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('radarr: second identical event returns duplicate:true', async () => {
|
||||
const app = makeApp();
|
||||
const payload = {
|
||||
eventType: 'Download',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T15:00:00.000Z'
|
||||
};
|
||||
await postRadarr(app, payload);
|
||||
const second = await postRadarr(app, payload);
|
||||
expect(second.body.duplicate).toBe(true);
|
||||
});
|
||||
|
||||
it('event without date field is never considered a duplicate', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { eventType: 'Grab', instanceName: 'Main Sonarr' };
|
||||
const first = await postSonarr(app, payload);
|
||||
const second = await postSonarr(app, payload);
|
||||
// Neither should be flagged as duplicate (no date = no replay key)
|
||||
expect(first.body.duplicate).toBeUndefined();
|
||||
expect(second.body.duplicate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook metrics (Phase 5.1 integration)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Webhook metrics — cache.updateWebhookMetrics integration', () => {
|
||||
it('sonarr: increments eventsReceived for a known instance', async () => {
|
||||
const app = makeApp();
|
||||
const instanceUrl = 'https://sonarr.test';
|
||||
const before = cache.getWebhookMetrics(instanceUrl);
|
||||
const countBefore = before ? before.eventsReceived : 0;
|
||||
|
||||
await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T16:00:00.000Z'
|
||||
});
|
||||
|
||||
const after = cache.getWebhookMetrics(instanceUrl);
|
||||
expect(after.eventsReceived).toBe(countBefore + 1);
|
||||
expect(after.lastWebhookTimestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('radarr: increments eventsReceived for a known instance', async () => {
|
||||
const app = makeApp();
|
||||
const instanceUrl = 'https://radarr.test';
|
||||
const before = cache.getWebhookMetrics(instanceUrl);
|
||||
const countBefore = before ? before.eventsReceived : 0;
|
||||
|
||||
await postRadarr(app, {
|
||||
eventType: 'Download',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T16:01:00.000Z'
|
||||
});
|
||||
|
||||
const after = cache.getWebhookMetrics(instanceUrl);
|
||||
expect(after.eventsReceived).toBe(countBefore + 1);
|
||||
});
|
||||
|
||||
it('does not crash when instanceName does not match a configured instance', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Unknown Instance',
|
||||
date: '2026-05-19T16:02:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('global metrics totalWebhookEventsReceived increments after valid event', async () => {
|
||||
const app = makeApp();
|
||||
const beforeGlobal = cache.getGlobalWebhookMetrics();
|
||||
const beforeCount = beforeGlobal.totalWebhookEventsReceived;
|
||||
|
||||
await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T17:00:00.000Z'
|
||||
});
|
||||
|
||||
const afterGlobal = cache.getGlobalWebhookMetrics();
|
||||
expect(afterGlobal.totalWebhookEventsReceived).toBe(beforeCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret not included in response
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Security — secret never leaks', () => {
|
||||
it('sonarr: SOFARR_WEBHOOK_SECRET is not present in any response body', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T18:00:00.000Z'
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
||||
});
|
||||
|
||||
it('radarr: SOFARR_WEBHOOK_SECRET is not present in 401 response body', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, RADARR_GRAB, 'wrong');
|
||||
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user