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

View File

@@ -49,6 +49,10 @@ vi.mock('nodemailer', () => ({
vi.mock('node-fetch', () => ({ default: fetchMock }));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
createPinnedAgent: vi.fn(() => ({})),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
@@ -18,7 +18,15 @@ vi.mock('../../../src/services/auditLog', () => ({
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
vi.mock('node-fetch', () => ({ default: vi.fn() }));
import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications';
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
createPinnedAgent: vi.fn(() => ({})),
}));
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications';
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { logError } from '../../../src/services/auditLog';
afterEach(() => {
vi.unstubAllEnvs();
@@ -193,3 +201,119 @@ describe('buildEmailHtml', () => {
expect(unknown).toContain('notifications enabled in TREK');
});
});
// ── SEC: XSS escaping in buildEmailHtml ──────────────────────────────────────
describe('buildEmailHtml XSS prevention (SEC-016)', () => {
it('escapes HTML special characters in subject', () => {
const html = buildEmailHtml('<script>alert(1)</script>', 'Body', 'en');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
it('escapes HTML special characters in body', () => {
const html = buildEmailHtml('Subject', '<img src=x onerror=alert(1)>', 'en');
expect(html).toContain('&lt;img');
expect(html).not.toContain('<img src=x');
});
it('escapes double quotes in subject to prevent attribute injection', () => {
const html = buildEmailHtml('He said "hello"', 'Body', 'en');
expect(html).toContain('&quot;');
expect(html).not.toContain('"hello"');
});
it('escapes ampersands in body', () => {
const html = buildEmailHtml('Subject', 'a & b', 'en');
expect(html).toContain('&amp;');
expect(html).not.toMatch(/>[^<]*a & b[^<]*</);
});
it('escapes user-controlled actor and preview in collab_message body', () => {
const { body } = getEventText('en', 'collab_message', {
trip: 'MyTrip',
actor: '<evil>',
preview: '<script>xss()</script>',
});
const html = buildEmailHtml('Subject', body, 'en');
expect(html).not.toContain('<evil>');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;evil&gt;');
expect(html).toContain('&lt;script&gt;');
});
});
// ── SEC: SSRF protection in sendWebhook ──────────────────────────────────────
describe('sendWebhook SSRF protection (SEC-017)', () => {
const payload = { event: 'test', title: 'T', body: 'B' };
beforeEach(() => {
vi.mocked(logError).mockClear();
});
it('allows a public URL and calls fetch', async () => {
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
const result = await sendWebhook('https://example.com/hook', payload);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalled();
});
it('blocks loopback address and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
error: 'Requests to loopback and link-local addresses are not allowed',
});
const result = await sendWebhook('http://localhost/secret', payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
});
it('blocks cloud metadata endpoint (169.254.169.254) and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '169.254.169.254',
error: 'Requests to loopback and link-local addresses are not allowed',
});
const result = await sendWebhook('http://169.254.169.254/latest/meta-data', payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
});
it('blocks private network addresses and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
error: 'Requests to private/internal network addresses are not allowed',
});
const result = await sendWebhook('http://192.168.1.1/hook', payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
});
it('blocks non-HTTP protocols', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: false,
error: 'Only HTTP and HTTPS URLs are allowed',
});
const result = await sendWebhook('file:///etc/passwd', payload);
expect(result).toBe(false);
});
it('does not call fetch when SSRF check blocks the URL', async () => {
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockClear();
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
error: 'blocked',
});
await sendWebhook('http://localhost/secret', payload);
expect(mockFetch).not.toHaveBeenCalled();
});
});