mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user