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:
@@ -716,6 +716,7 @@ export function updateAppSettings(
|
||||
}
|
||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
||||
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,27 @@ function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook',
|
||||
|
||||
// ── Preferences update ─────────────────────────────────────────────────────
|
||||
|
||||
// ── Shared helper for per-user channel preference upserts ─────────────────
|
||||
|
||||
function applyUserChannelPrefs(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
|
||||
upsert: ReturnType<typeof db.prepare>,
|
||||
del: ReturnType<typeof db.prepare>
|
||||
): void {
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (enabled) {
|
||||
// Remove explicit row — default is enabled
|
||||
del.run(userId, eventType, channel);
|
||||
} else {
|
||||
upsert.run(userId, eventType, channel, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update preferences from the matrix UI.
|
||||
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
|
||||
@@ -187,20 +208,7 @@ export function setPreferences(
|
||||
const del = db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (enabled) {
|
||||
// Remove explicit row — default is enabled
|
||||
del.run(userId, eventType, channel);
|
||||
} else {
|
||||
upsert.run(userId, eventType, channel, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
db.transaction(() => applyUserChannelPrefs(userId, prefs, upsert, del))();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,24 +227,33 @@ export function setAdminPreferences(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
|
||||
// Global setting — stored in app_settings
|
||||
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
|
||||
} else {
|
||||
// Per-user (inapp)
|
||||
if (enabled) {
|
||||
del.run(userId, eventType, channel);
|
||||
} else {
|
||||
upsert.run(userId, eventType, channel, 0);
|
||||
}
|
||||
}
|
||||
// Split global (email/webhook) from per-user (inapp) prefs
|
||||
const globalPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
|
||||
if (!globalPrefs[eventType]) globalPrefs[eventType] = {};
|
||||
globalPrefs[eventType]![channel] = enabled;
|
||||
} else {
|
||||
if (!userPrefs[eventType]) userPrefs[eventType] = {};
|
||||
userPrefs[eventType]![channel] = enabled;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Apply global prefs outside the transaction (they write to app_settings)
|
||||
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-user (inapp) prefs in a transaction
|
||||
db.transaction(() => applyUserChannelPrefs(userId, userPrefs, upsert, del))();
|
||||
}
|
||||
|
||||
// ── SMTP availability helper (for authService) ─────────────────────────────
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url']);
|
||||
|
||||
export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
|
||||
@@ -13,12 +16,17 @@ export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
return settings;
|
||||
}
|
||||
|
||||
function serializeValue(key: string, value: unknown): string {
|
||||
const raw = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
if (ENCRYPTED_SETTING_KEYS.has(key)) return maybe_encrypt_api_key(raw) ?? raw;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function upsertSetting(userId: number, key: string, value: unknown) {
|
||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
db.prepare(`
|
||||
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
|
||||
`).run(userId, key, serialized);
|
||||
`).run(userId, key, serializeValue(key, value));
|
||||
}
|
||||
|
||||
export function bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
|
||||
@@ -29,8 +37,7 @@ export function bulkUpsertSettings(userId: number, settings: Record<string, unkn
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
upsert.run(userId, key, serialized);
|
||||
upsert.run(userId, key, serializeValue(key, value));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user