fix(security): address notification system security audit findings

- SSRF: guard sendWebhook() with checkSsrf() + createPinnedAgent() to block
  requests to loopback, link-local, private network, and cloud metadata endpoints
- XSS: escape subject, body, and ctaHref in buildEmailHtml() via escapeHtml()
  to prevent HTML injection through user-controlled params (actor, preview, etc.)
- Encrypt webhook URLs at rest: apply maybe_encrypt_api_key on save
  (settingsService for user URLs, authService for admin URL) and decrypt_api_key
  on read in getUserWebhookUrl() / getAdminWebhookUrl()
- Log failed channel dispatches: inspect Promise.allSettled() results and log
  rejections via logError instead of silently dropping them
- Log admin webhook failures: replace fire-and-forget .catch(() => {}) with
  .catch(err => logError(...)) and await the call
- Migration 69: guard against missing notification_preferences table on fresh installs
- Migration 70: drop the now-unused notification_preferences table
- Refactor: extract applyUserChannelPrefs() helper to deduplicate
  setPreferences / setAdminPreferences logic
- Tests: add SEC-016 (XSS, 5 cases) and SEC-017 (SSRF, 6 cases) test suites;
  mock ssrfGuard in notificationService tests
This commit is contained in:
jubnl
2026-04-05 03:36:22 +02:00
parent 6df8b2555d
commit 7b37d337c1
8 changed files with 237 additions and 46 deletions
+10 -3
View File
@@ -1,5 +1,5 @@
import { db } from '../db/database';
import { logDebug } from './auditLog';
import { logDebug, logError } from './auditLog';
import {
getActiveChannels,
isEnabledForEvent,
@@ -264,7 +264,12 @@ export async function send(payload: NotificationPayload): Promise<void> {
}
}
await Promise.allSettled(promises);
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'rejected') {
logError(`notificationService.send channel dispatch failed event=${event} recipient=${recipientId}: ${result.reason}`);
}
}
}));
// ── Admin webhook (scope: admin) — global, respects global pref ──────
@@ -272,7 +277,9 @@ export async function send(payload: NotificationPayload): Promise<void> {
const adminWebhookUrl = getAdminWebhookUrl();
if (adminWebhookUrl) {
const { title, body } = getEventText('en', event, params);
sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch(() => {});
await sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch((err: unknown) => {
logError(`notificationService.send admin webhook failed event=${event}: ${err instanceof Error ? err.message : err}`);
});
}
}
}