fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs (#1255)

* fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs

Make NotificationService.dispatchAlert never reject so the many
fire-and-forget callers (monitors, event streams, policy and image-update
paths) cannot trigger an unhandledRejection on an unhealthy database. The
whole dispatch body now sits inside a guard covering node resolution, the
history insert, channel-table reads, and the WebSocket broadcast; a
failure logs and drops the notification instead of crashing the process.
An inner guard still splits the write-success and write-failure metrics.

Also:
- Redact webhook URLs in diagnostic logs via a new maskWebhookUrl helper;
  Discord/Slack/custom webhook URLs embed their token in the path, so only
  the origin is safe to emit.
- Add error logging to four notification-history route handlers that
  previously swallowed database errors silently before returning 500.
- Snapshot the subscriber set before broadcasting so a close/error handler
  firing mid-send cannot mutate the set during iteration.
- Sanitize the admin-supplied route name in dispatch log lines.

Adds tests for dispatch crash-safety (write failure, post-write routing
failure, broadcast send failure), the success-path write metric, webhook
URL masking, and history-route error logging.

* fix(notifications): sanitize route name and patterns in create logs

Apply sanitizeForLog to the admin-supplied route name and stack patterns
in the route-creation log lines, matching the dispatch-site sanitization
and closing the remaining log-injection path on this feature.

Also extend tests: userinfo-stripping in maskWebhookUrl, subscriber-set
snapshot behavior under an unsubscribe-during-send, and error logging on
the mark-read, delete-one, and clear-all notification-history handlers.
This commit is contained in:
Anso
2026-05-29 21:09:57 -04:00
committed by GitHub
parent 69edb0dcbb
commit 7d4e61625f
6 changed files with 340 additions and 72 deletions
+13 -7
View File
@@ -9,9 +9,11 @@ import {
NOTIFICATION_CHANNEL_TYPES,
validateHttpsUrl,
cleanStackPatterns,
maskWebhookUrl,
} from '../helpers/notificationChannels';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { parseIntParam } from '../utils/parseIntParam';
const VALID_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(ALL_NOTIFICATION_CATEGORIES);
@@ -56,7 +58,8 @@ notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response)
const category = typeof req.query.category === 'string' ? req.query.category : undefined;
const history = DatabaseService.getInstance().getNotificationHistory(nodeId, 50, category);
res.json(history);
} catch {
} catch (error) {
console.error('Failed to fetch notifications:', error);
res.status(500).json({ error: 'Failed to fetch notifications' });
}
});
@@ -66,7 +69,8 @@ notificationsRouter.post('/read', authMiddleware, async (req: Request, res: Resp
const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().markAllNotificationsRead(nodeId);
res.json({ success: true });
} catch {
} catch (error) {
console.error('Failed to mark notifications read:', error);
res.status(500).json({ error: 'Failed to mark notifications read' });
}
});
@@ -78,7 +82,8 @@ notificationsRouter.delete('/:id', authMiddleware, async (req: Request, res: Res
const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().deleteNotification(nodeId, id);
res.json({ success: true });
} catch {
} catch (error) {
console.error('Failed to delete notification:', error);
res.status(500).json({ error: 'Failed to delete notification' });
}
});
@@ -88,7 +93,8 @@ notificationsRouter.delete('/', authMiddleware, async (req: Request, res: Respon
const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().deleteAllNotifications(nodeId);
res.json({ success: true });
} catch {
} catch (error) {
console.error('Failed to clear notifications:', error);
res.status(500).json({ error: 'Failed to clear notifications' });
}
});
@@ -172,8 +178,8 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
created_at: now,
updated_at: now,
});
console.log(`[Routes] Route "${route.name}" created (id=${route.id})`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${route.name}" created with patterns=[${cleanedPatterns}], channel=${channel_type}`);
console.log(`[Routes] Route "${sanitizeForLog(route.name)}" created (id=${route.id})`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog(cleanedPatterns.join(', '))}], channel=${channel_type}`);
res.status(201).json(route);
} catch (error) {
console.error('Failed to create notification route:', error);
@@ -284,7 +290,7 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request,
const route = DatabaseService.getInstance().getNotificationRoute(id);
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${route.channel_url})`);
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${maskWebhookUrl(route.channel_url)})`);
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
res.json({ success: true });
} catch (error) {