Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e |
+10
-9
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
|
|||||||
Both endpoints share identical processing logic:
|
Both endpoints share identical processing logic:
|
||||||
|
|
||||||
```
|
```
|
||||||
Sonarr/Radarr
|
Sonarr/Radarr/Ombi
|
||||||
POST /api/webhook/sonarr
|
POST /api/webhook/sonarr
|
||||||
Headers: X-Sofarr-Webhook-Secret: <secret>
|
Headers: X-Sofarr-Webhook-Secret: <secret> OR URL parameter: ?secret=<secret>
|
||||||
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
|
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
|
||||||
"date": "2026-05-19T10:00:00.000Z", … }
|
"date": "2026-05-19T10:00:00.000Z", … }
|
||||||
│
|
│
|
||||||
@@ -404,6 +404,7 @@ Sonarr/Radarr
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
validateWebhookSecret() ──fail──► 401 Unauthorized
|
validateWebhookSecret() ──fail──► 401 Unauthorized
|
||||||
|
(Checks header or query param)
|
||||||
│ ok
|
│ ok
|
||||||
▼
|
▼
|
||||||
validatePayload() ──fail──► 400 Bad Request
|
validatePayload() ──fail──► 400 Bad Request
|
||||||
@@ -1002,19 +1003,19 @@ sofarr provides a togglable, real-time log capturing and streaming engine allowi
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph Browser (SPA)
|
subgraph Browser ["Browser (SPA)"]
|
||||||
console["console.log/warn/error"] --> queue["logQueue (batched)"]
|
console["console.log/warn/error"] --> queue["logQueue (batched)"]
|
||||||
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
|
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Node.js (Server)
|
subgraph Server ["Node.js (Server)"]
|
||||||
stdout["process.stdout.write"] --> capture["processStreamData()"]
|
stdout["process.stdout.write"] --> capture["processStreamData()"]
|
||||||
stderr["process.stderr.write"] --> capture
|
stderr["process.stderr.write"] --> capture
|
||||||
capture --> |stripAnsi()| serverBuffer["logBuffer (rolling 1000 lines)"]
|
capture --> |stripAnsi| serverBuffer["logBuffer (rolling 1000 lines)"]
|
||||||
capture --> |emit('server-log')| serverSse["GET /api/debug/server-logs (SSE)"]
|
capture --> |emit server-log| serverSse["GET /api/debug/server-logs (SSE)"]
|
||||||
|
|
||||||
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
|
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
|
||||||
ingestionRoute --> |emit('client-log')| clientSse["GET /api/debug/client-logs (SSE)"]
|
ingestionRoute --> |emit client-log| clientSse["GET /api/debug/client-logs (SSE)"]
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1224,7 +1225,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| Concern | Mechanism |
|
| Concern | Mechanism |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
||||||
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
|
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). Bypassed in testing/dev via `SKIP_RATE_LIMIT=1`. |
|
||||||
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
||||||
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
||||||
|
|
||||||
@@ -1232,7 +1233,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
|
|
||||||
| Concern | Mechanism |
|
| Concern | Mechanism |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
|
| **Rate limiting** | General API limiter (300 req/15 min on `/api/*` prefix) exempts `/api/dashboard/cover-art` requests; Login limiter (10 attempts/15 min) employs `skipSuccessfulRequests: true` to count failed attempts only; Webhook limiter runs 60 req/1 min on `/api/webhook/*` endpoints; Root `/health` and `/ready` probes are entirely exempt. All limiters bypassable in testing via `SKIP_RATE_LIMIT=1` or `createApp({ skipRateLimits: true })`. |
|
||||||
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
||||||
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
||||||
| **Body size** | `express.json` body limit: 64 KB. |
|
| **Body size** | `express.json` body limit: 64 KB. |
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
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).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.15] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Webhook Authentication Failures** — Resolved a critical issue where Ombi webhook notifications failed to authenticate because Ombi's built-in notification agent does not support custom HTTP headers (such as `X-Sofarr-Webhook-Secret`). Added a query parameter authentication fallback (`?secret=`) to all `/api/webhook/*` endpoints (Sonarr, Radarr, and Ombi) and configured Ombi webhook registration to automatically append this secret query parameter. Resolves Gitea Issue [#47](https://git.i3omb.com/Gandalf/sofarr/issues/47).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.14] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.13] - 2026-05-24
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
|
||||||
|
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
|
||||||
|
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.12] - 2026-05-24
|
## [1.7.12] - 2026-05-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+8
-7
@@ -40,7 +40,7 @@ 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header or `secret` query parameter; 401 on mismatch |
|
||||||
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
|
| 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 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/*` |
|
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
||||||
@@ -162,12 +162,13 @@ server {
|
|||||||
|
|
||||||
## Rate Limits
|
## Rate Limits
|
||||||
|
|
||||||
| Endpoint | Limit |
|
| Endpoint | Limit | Details & Exemptions |
|
||||||
|----------|-------|
|
|----------|-------|----------------------|
|
||||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. |
|
||||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. |
|
||||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. |
|
||||||
| `GET /api/swagger` | No rate limit (public documentation) |
|
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
|
||||||
|
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.12",
|
"version": "1.7.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.12",
|
"version": "1.7.15",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.12",
|
"version": "1.7.15",
|
||||||
"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",
|
"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",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+6
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
|||||||
const swaggerJsdoc = require('swagger-jsdoc');
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
const sonarrRoutes = require('./routes/sonarr');
|
||||||
@@ -128,13 +129,17 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* type: number
|
* type: number
|
||||||
* description: Server uptime in seconds
|
* description: Server uptime in seconds
|
||||||
* example: 3600.5
|
* example: 3600.5
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* description: sofarr version
|
||||||
|
* example: "1.7.14"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
* source: curl http://localhost:3001/health
|
* source: curl http://localhost:3001/health
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime() });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.6.0"
|
* example: "1.7.14"
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
|
|||||||
+32
-7
@@ -12,13 +12,17 @@ info:
|
|||||||
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
- General API: 300 requests per 15 minutes per IP
|
To protect the system from resource exhaustion, rate limiters are enforced at different levels:
|
||||||
- Login: 10 failed attempts per 15 minutes per IP
|
- **General API Limiter**: Enforces a limit of **300 requests per 15 minutes** per IP across all `/api/*` endpoints.
|
||||||
- Webhooks: 60 requests per minute per IP
|
- *Exemption:* Requests starting with `/api/dashboard/cover-art` are completely exempted from this limit to avoid normal dashboard image browsing triggering blocks.
|
||||||
|
- **Login Rate Limiter**: Enforces a strict limit of **10 attempts per 15 minutes** per IP on `POST /api/auth/login`.
|
||||||
|
- *Exemption:* This limiter only tracks and counts *failed* login attempts (`skipSuccessfulRequests: true`). Successful logins do not count towards the lockout threshold.
|
||||||
|
- **Webhook Limiter**: Enforces a limit of **60 requests per minute** per IP on stateful webhook receiver endpoints (`/api/webhook/*`).
|
||||||
|
- **Health and Readiness Probes**: The public `/health` and `/ready` endpoints are mounted at the root directory level rather than under `/api/*` and are completely exempt from both rate limiting and authentication controls.
|
||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.6.0
|
version: 1.7.15
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
@@ -791,8 +795,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Sonarr webhook
|
summary: Sonarr webhook
|
||||||
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header.
|
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -828,8 +839,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Radarr webhook
|
summary: Radarr webhook
|
||||||
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header.
|
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -865,8 +883,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Ombi webhook
|
summary: Ombi webhook
|
||||||
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header.
|
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||||
|
|
||||||
// Call Ombi API to register webhook
|
// Call Ombi API to register webhook
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|||||||
+31
-11
@@ -144,13 +144,13 @@ const OMBI_EVENTS = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
* Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
|
||||||
* @param {Object} req - Express request object
|
* @param {Object} req - Express request object
|
||||||
* @returns {boolean} True if secret is valid, false otherwise
|
* @returns {boolean} True if secret is valid, false otherwise
|
||||||
*/
|
*/
|
||||||
function validateWebhookSecret(req) {
|
function validateWebhookSecret(req) {
|
||||||
const expectedSecret = getWebhookSecret();
|
const expectedSecret = getWebhookSecret();
|
||||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
|
||||||
|
|
||||||
if (!expectedSecret) {
|
if (!expectedSecret) {
|
||||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||||
@@ -158,7 +158,7 @@ function validateWebhookSecret(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!providedSecret) {
|
if (!providedSecret) {
|
||||||
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
|
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,13 +309,13 @@ function validatePayload(body) {
|
|||||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||||
*
|
*
|
||||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* No cookie authentication required (webhooks come from Sonarr, not browsers).
|
* No cookie authentication required (webhooks come from Sonarr, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **Validation:**
|
||||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -342,6 +342,13 @@ function validatePayload(body) {
|
|||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -456,13 +463,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||||
*
|
*
|
||||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* No cookie authentication required (webhooks come from Radarr, not browsers).
|
* No cookie authentication required (webhooks come from Radarr, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **Validation:**
|
||||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -489,6 +496,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -603,13 +617,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||||
*
|
*
|
||||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **Validation:**
|
||||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||||
* - Payload validation (must be JSON object with notificationType, requestId)
|
* - Payload validation (must be JSON object with notificationType, requestId)
|
||||||
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -627,11 +641,17 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
||||||
*
|
*
|
||||||
* **x-integration-notes:** Configure Ombi webhook:
|
* **x-integration-notes:** Configure Ombi webhook:
|
||||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi`
|
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Method: POST
|
* - Method: POST
|
||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
|
||||||
* - Application Token: OMBI_API_KEY
|
* - Application Token: OMBI_API_KEY
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
getRadarrInstances,
|
getRadarrInstances,
|
||||||
getOmbiInstances
|
getOmbiInstances
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||||
|
|||||||
@@ -856,7 +856,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.post('/api/v1/Settings/notifications/webhook', {
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
id: 42,
|
id: 42,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
applicationToken: 'test-ombi-key'
|
applicationToken: 'test-ombi-key'
|
||||||
})
|
})
|
||||||
.reply(200, { success: true });
|
.reply(200, { success: true });
|
||||||
@@ -870,7 +870,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi`);
|
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`);
|
||||||
expect(res.body.applicationToken).toBe('test-ombi-key');
|
expect(res.body.applicationToken).toBe('test-ombi-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -882,7 +882,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.post('/api/v1/Settings/notifications/webhook', {
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
id: 0,
|
id: 0,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
applicationToken: 'test-ombi-key'
|
applicationToken: 'test-ombi-key'
|
||||||
})
|
})
|
||||||
.reply(200, { success: true });
|
.reply(200, { success: true });
|
||||||
|
|||||||
@@ -196,6 +196,18 @@ describe('Swagger Coverage', () => {
|
|||||||
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have Debug logging endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/debug/status']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/status'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/server-logs']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/server-logs'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs'].post).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 200 for Swagger UI endpoint', async () => {
|
it('should return 200 for Swagger UI endpoint', async () => {
|
||||||
const response = await request(app).get('/api/swagger').redirects(1);
|
const response = await request(app).get('/api/swagger').redirects(1);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|||||||
@@ -156,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
|
|||||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/webhook/sonarr?secret=${VALID_SECRET}`)
|
||||||
|
.send(SONARR_GRAB);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/webhook/sonarr?secret=wrong-query-secret')
|
||||||
|
.send(SONARR_GRAB);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/webhook/radarr — secret validation', () => {
|
describe('POST /api/webhook/radarr — secret validation', () => {
|
||||||
@@ -171,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
|
|||||||
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/webhook/radarr?secret=${VALID_SECRET}`)
|
||||||
|
.send(RADARR_GRAB);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/webhook/radarr?secret=wrong-query-secret')
|
||||||
|
.send(RADARR_GRAB);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -548,6 +583,40 @@ describe('POST /api/webhook/ombi', () => {
|
|||||||
expect(res.body.error).toBe('Unauthorized');
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/webhook/ombi?secret=${VALID_SECRET}`)
|
||||||
|
.send({
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 127,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'Query Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:40:00.000Z'
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/webhook/ombi?secret=wrong-query-secret')
|
||||||
|
.send({ notificationType: 'NewRequest', requestId: 1 });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 400 when notificationType is missing or invalid', async () => {
|
it('returns 400 when notificationType is missing or invalid', async () => {
|
||||||
const app = makeApp();
|
const app = makeApp();
|
||||||
const res = await postOmbi(app, { requestId: 1 });
|
const res = await postOmbi(app, { requestId: 1 });
|
||||||
|
|||||||
Reference in New Issue
Block a user