feat(security): mask saved webhook URLs instead of returning encrypted values

Encrypted webhook URLs are no longer returned to the frontend. Both user
and admin webhook fields now show '••••••••' as a placeholder when a URL
is already saved, and the sentinel value is skipped on save/test so the
stored secret is never exposed or accidentally overwritten.
This commit is contained in:
jubnl
2026-04-05 06:08:30 +02:00
parent d8ee545002
commit 959015928f
7 changed files with 38 additions and 16 deletions

View File

@@ -1,7 +1,7 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp, testWebhook } from '../services/notifications';
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
import {
getNotifications,
getUnreadCount,
@@ -35,8 +35,14 @@ router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
});
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
const { url } = req.body;
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url is required' });
const authReq = req as AuthRequest;
let { url } = req.body;
if (!url || url === '••••••••') {
url = getUserWebhookUrl(authReq.user.id);
if (!url && authReq.user.role === 'admin') url = getAdminWebhookUrl();
if (!url) return res.status(400).json({ error: 'No webhook URL configured' });
}
if (typeof url !== 'string') return res.status(400).json({ error: 'url must be a string' });
try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
res.json(await testWebhook(url));
});

View File

@@ -14,6 +14,7 @@ router.put('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { key, value } = req.body;
if (!key) return res.status(400).json({ error: 'Key is required' });
if (value === '••••••••') return res.json({ success: true, key, unchanged: true });
settingsService.upsertSetting(authReq.user.id, key, value);
res.json({ success: true, key, value });
});

View File

@@ -678,7 +678,7 @@ export function getAppSettings(userId: number): { error?: string; status?: numbe
const result: Record<string, string> = {};
for (const key of ADMIN_SETTINGS_KEYS) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url') ? '••••••••' : row.value;
}
return { data: result };
}
@@ -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 === '••••••••') continue;
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);
}

View File

@@ -7,6 +7,10 @@ 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 }[];
const settings: Record<string, unknown> = {};
for (const row of rows) {
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
settings[row.key] = row.value ? '••••••••' : '';
continue;
}
try {
settings[row.key] = JSON.parse(row.value);
} catch {