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
+29 -5
View File
@@ -3,6 +3,7 @@ import fetch from 'node-fetch';
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
import { logInfo, logDebug, logError } from './auditLog';
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -17,6 +18,17 @@ interface SmtpConfig {
secure: boolean;
}
// ── HTML escaping ──────────────────────────────────────────────────────────
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── Settings helpers ───────────────────────────────────────────────────────
function getAppSetting(key: string): string | null {
@@ -54,11 +66,13 @@ export function getUserLanguage(userId: number): string {
}
export function getUserWebhookUrl(userId: number): string | null {
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null;
const value = (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null;
return value ? decrypt_api_key(value) : null;
}
export function getAdminWebhookUrl(): string | null {
return getAppSetting('admin_webhook_url') || null;
const value = getAppSetting('admin_webhook_url') || null;
return value ? decrypt_api_key(value) : null;
}
// ── Email i18n strings ─────────────────────────────────────────────────────
@@ -226,7 +240,9 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
const s = I18N[lang] || I18N.en;
const appUrl = getAppUrl();
const ctaHref = navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || '');
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
const safeSubject = escapeHtml(subject);
const safeBody = escapeHtml(body);
return `<!DOCTYPE html>
<html>
@@ -243,9 +259,9 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
</td></tr>
<!-- Content -->
<tr><td style="padding: 32px 32px 16px;">
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${subject}</h1>
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${safeSubject}</h1>
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${body}</p>
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
</td></tr>
<!-- CTA -->
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
@@ -328,12 +344,20 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise<boolean> {
if (!url) return false;
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) {
logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
return false;
}
try {
const agent = createPinnedAgent(ssrf.resolvedIp!, new URL(url).protocol);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: buildWebhookBody(url, payload),
signal: AbortSignal.timeout(10000),
agent,
});
if (!res.ok) {