merge branch 'develop' into 'main' - Release v1.7.15
Build and Push Docker Image / build (push) Successful in 1m43s
Create Release / release (push) Successful in 39s
CI / Security audit (push) Successful in 1m49s
CI / Swagger Validation & Coverage (push) Successful in 2m0s
CI / Tests & coverage (push) Successful in 2m51s
Build and Push Docker Image / build (push) Successful in 1m43s
Create Release / release (push) Successful in 39s
CI / Security audit (push) Successful in 1m49s
CI / Swagger Validation & Coverage (push) Successful in 2m0s
CI / Tests & coverage (push) Successful in 2m51s
This commit is contained in:
+3
-2
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
|
||||
Both endpoints share identical processing logic:
|
||||
|
||||
```
|
||||
Sonarr/Radarr
|
||||
Sonarr/Radarr/Ombi
|
||||
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",
|
||||
"date": "2026-05-19T10:00:00.000Z", … }
|
||||
│
|
||||
@@ -404,6 +404,7 @@ Sonarr/Radarr
|
||||
│
|
||||
▼
|
||||
validateWebhookSecret() ──fail──► 401 Unauthorized
|
||||
(Checks header or query param)
|
||||
│ ok
|
||||
▼
|
||||
validatePayload() ──fail──► 400 Bad Request
|
||||
|
||||
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.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
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
| 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 |
|
||||
| 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 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/*` |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.14",
|
||||
"version": "1.7.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.14",
|
||||
"version": "1.7.15",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.14",
|
||||
"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",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+25
-4
@@ -22,7 +22,7 @@ info:
|
||||
|
||||
## SSE Streaming
|
||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||
version: 1.7.14
|
||||
version: 1.7.15
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
@@ -795,8 +795,15 @@ paths:
|
||||
post:
|
||||
tags: [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: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -832,8 +839,15 @@ paths:
|
||||
post:
|
||||
tags: [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: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -869,8 +883,15 @@ paths:
|
||||
post:
|
||||
tags: [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: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
||||
@@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||
|
||||
// Call Ombi API to register webhook
|
||||
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
|
||||
* @returns {boolean} True if secret is valid, false otherwise
|
||||
*/
|
||||
function validateWebhookSecret(req) {
|
||||
const expectedSecret = getWebhookSecret();
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
|
||||
|
||||
if (!expectedSecret) {
|
||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||
@@ -158,7 +158,7 @@ function validateWebhookSecret(req) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -309,13 +309,13 @@ function validatePayload(body) {
|
||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||
* 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).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **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)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
@@ -342,6 +342,13 @@ function validatePayload(body) {
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -456,13 +463,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||
* 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).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **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)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - 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}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -603,13 +617,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
||||
* 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).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **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)
|
||||
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
||||
* - 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
|
||||
*
|
||||
* **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
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Application Token: OMBI_API_KEY
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
|
||||
@@ -856,7 +856,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.post('/api/v1/Settings/notifications/webhook', {
|
||||
id: 42,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
@@ -870,7 +870,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.expect(200);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -882,7 +882,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.post('/api/v1/Settings/notifications/webhook', {
|
||||
id: 0,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
|
||||
@@ -156,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
|
||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||
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', () => {
|
||||
@@ -171,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
|
||||
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const app = makeApp();
|
||||
const res = await postOmbi(app, { requestId: 1 });
|
||||
|
||||
Reference in New Issue
Block a user