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

This commit is contained in:
2026-05-19 17:11:45 +01:00
parent 8609f03c5a
commit 1bef14d590
8 changed files with 888 additions and 22 deletions

View File

@@ -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