fix: secure webhook config endpoint and validate config on Ombi enable/test
Build and Push Docker Image / build (push) Successful in 1m4s
Docs Check / Markdown lint (push) Successful in 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m22s
CI / Security audit (push) Successful in 2m44s
CI / Swagger Validation & Coverage (push) Successful in 2m59s
Docs Check / Mermaid diagram parse check (push) Successful in 3m11s
CI / Tests & coverage (push) Successful in 3m27s
Build and Push Docker Image / build (push) Successful in 1m4s
Docs Check / Markdown lint (push) Successful in 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m22s
CI / Security audit (push) Successful in 2m44s
CI / Swagger Validation & Coverage (push) Successful in 2m59s
Docs Check / Mermaid diagram parse check (push) Successful in 3m11s
CI / Tests & coverage (push) Successful in 3m27s
- Add requireAuth to GET /api/webhook/config to enforce authentication - Add SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET validation to POST /api/ombi/webhook/enable and /test - Return 400 with descriptive errors when webhook config is missing on Ombi routes - Clean up test environment in webhook.test.js afterEach - Add regression tests for all new validation logic - Update CHANGELOG.md with security fixes
This commit is contained in:
+143
-10
@@ -405,17 +405,90 @@ describe('GET /api/ombi/webhook/status', () => {
|
||||
expect(res.body.error).toBe('Not authenticated');
|
||||
});
|
||||
|
||||
it('returns disabled status when SOFARR_BASE_URL is missing', async () => {
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/webhook/status')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.enabled).toBe(false);
|
||||
expect(res.body.webhookUrl).toBeNull();
|
||||
expect(res.body.applicationToken).toBeNull();
|
||||
expect(res.body.triggers).toEqual({
|
||||
requestAvailable: false,
|
||||
requestApproved: false,
|
||||
requestDeclined: false,
|
||||
requestPending: false,
|
||||
requestProcessing: false
|
||||
});
|
||||
expect(res.body.stats).toBeNull();
|
||||
});
|
||||
|
||||
it('returns disabled status when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/webhook/status')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.enabled).toBe(false);
|
||||
expect(res.body.webhookUrl).toBeNull();
|
||||
expect(res.body.applicationToken).toBeNull();
|
||||
expect(res.body.triggers).toEqual({
|
||||
requestAvailable: false,
|
||||
requestApproved: false,
|
||||
requestDeclined: false,
|
||||
requestPending: false,
|
||||
requestProcessing: false
|
||||
});
|
||||
expect(res.body.stats).toBeNull();
|
||||
});
|
||||
|
||||
it('returns disabled status when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are missing', async () => {
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/webhook/status')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.enabled).toBe(false);
|
||||
expect(res.body.webhookUrl).toBeNull();
|
||||
expect(res.body.applicationToken).toBeNull();
|
||||
expect(res.body.triggers).toEqual({
|
||||
requestAvailable: false,
|
||||
requestApproved: false,
|
||||
requestDeclined: false,
|
||||
requestPending: false,
|
||||
requestProcessing: false
|
||||
});
|
||||
expect(res.body.stats).toBeNull();
|
||||
});
|
||||
|
||||
it('returns disabled status when Ombi not configured', async () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/webhook/status')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
|
||||
expect(res.body.enabled).toBe(false);
|
||||
expect(res.body.webhookUrl).toBeNull();
|
||||
expect(res.body.applicationToken).toBeNull();
|
||||
@@ -559,18 +632,48 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
expect(res.body.error).toBe('CSRF token missing');
|
||||
});
|
||||
|
||||
it('returns 400 when Ombi not configured', async () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
||||
it('returns 400 when SOFARR_BASE_URL is missing', async () => {
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/enable')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(400);
|
||||
|
||||
|
||||
expect(res.body.error).toBe('SOFARR_BASE_URL not configured');
|
||||
});
|
||||
|
||||
it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/enable')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured');
|
||||
});
|
||||
|
||||
it('returns 400 when Ombi not configured', async () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/enable')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toBe('Ombi not configured');
|
||||
});
|
||||
|
||||
@@ -627,18 +730,48 @@ describe('POST /api/ombi/webhook/test', () => {
|
||||
expect(res.body.error).toBe('CSRF token missing');
|
||||
});
|
||||
|
||||
it('returns 400 when Ombi not configured', async () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
||||
it('returns 400 when SOFARR_BASE_URL is missing', async () => {
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/test')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(400);
|
||||
|
||||
|
||||
expect(res.body.error).toBe('SOFARR_BASE_URL not configured');
|
||||
});
|
||||
|
||||
it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/test')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured');
|
||||
});
|
||||
|
||||
it('returns 400 when Ombi not configured', async () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/test')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toBe('Ombi not configured');
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,18 @@ const require = createRequire(import.meta.url);
|
||||
const cache = require('../../server/utils/cache.js');
|
||||
|
||||
const VALID_SECRET = 'test-webhook-secret-abc';
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
const EMBY_AUTH_BODY = {
|
||||
AccessToken: 'test-emby-token-abc123',
|
||||
User: { Id: 'user-id-001', Name: 'TestUser' }
|
||||
};
|
||||
|
||||
const EMBY_USER_BODY = {
|
||||
Id: 'user-id-001',
|
||||
Name: 'TestUser',
|
||||
Policy: { IsAdministrator: false }
|
||||
};
|
||||
|
||||
// Minimal valid Sonarr Grab payload
|
||||
const SONARR_GRAB = {
|
||||
@@ -53,7 +65,31 @@ const SONARR_TEST = {
|
||||
date: '2026-05-19T10:00:02.000Z'
|
||||
};
|
||||
|
||||
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, EMBY_AUTH_BODY);
|
||||
nock(EMBY_BASE)
|
||||
.get(/\/Users\//)
|
||||
.reply(200, userBody);
|
||||
}
|
||||
|
||||
async function authenticateUser(app, username = 'TestUser', isAdmin = false) {
|
||||
const userBody = isAdmin ? { ...EMBY_USER_BODY, Policy: { IsAdministrator: true } } : EMBY_USER_BODY;
|
||||
interceptSuccessfulLogin(userBody);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username, password: 'password' });
|
||||
|
||||
const cookies = res.headers['set-cookie'];
|
||||
const csrfToken = res.body.csrfToken;
|
||||
|
||||
return { cookies, csrfToken };
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
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' }
|
||||
@@ -84,7 +120,11 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -393,3 +433,88 @@ describe('Security — secret never leaks', () => {
|
||||
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/webhook/config
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('GET /api/webhook/config', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
const app = makeApp();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/webhook/config')
|
||||
.expect(401);
|
||||
|
||||
expect(res.body.error).toBe('Not authenticated');
|
||||
});
|
||||
|
||||
it('returns valid: true when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured', async () => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
||||
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/webhook/config')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns valid: false when SOFARR_BASE_URL is missing', async () => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/webhook/config')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.missing).toEqual(['SOFARR_BASE_URL']);
|
||||
});
|
||||
|
||||
it('returns valid: false when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/webhook/config')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.missing).toEqual(['SOFARR_WEBHOOK_SECRET']);
|
||||
});
|
||||
|
||||
it('returns valid: false when both are missing', async () => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/webhook/config')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.missing).toContain('SOFARR_BASE_URL');
|
||||
expect(res.body.missing).toContain('SOFARR_WEBHOOK_SECRET');
|
||||
expect(res.body.missing).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user