diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 203d4ac..dddf28f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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: + Headers: X-Sofarr-Webhook-Secret: OR URL parameter: ?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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 46bdc1f..5370463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 754386d..244f3cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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/*` | diff --git a/package-lock.json b/package-lock.json index 5c13046..6fc79c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b556453..02a4735 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server/openapi.yaml b/server/openapi.yaml index 20063d8..5499028 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -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: diff --git a/server/routes/ombi.js b/server/routes/ombi.js index cf0a88d..b2539f9 100644 --- a/server/routes/ombi.js +++ b/server/routes/ombi.js @@ -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'); diff --git a/server/routes/webhook.js b/server/routes/webhook.js index 3ceb170..fe8e523 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -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: diff --git a/tests/integration/ombi.test.js b/tests/integration/ombi.test.js index bb9dcb8..6c93992 100644 --- a/tests/integration/ombi.test.js +++ b/tests/integration/ombi.test.js @@ -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 }); diff --git a/tests/integration/webhook.test.js b/tests/integration/webhook.test.js index 74bc154..6baee0f 100644 --- a/tests/integration/webhook.test.js +++ b/tests/integration/webhook.test.js @@ -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 });