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:
@@ -331,7 +331,7 @@ export const notificationsApi = {
|
|||||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||||
testWebhook: (url: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inAppNotificationsApi = {
|
export const inAppNotificationsApi = {
|
||||||
|
|||||||
@@ -36,13 +36,20 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
|
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [webhookUrl, setWebhookUrl] = useState('')
|
const [webhookUrl, setWebhookUrl] = useState('')
|
||||||
|
const [webhookIsSet, setWebhookIsSet] = useState(false)
|
||||||
const [webhookSaving, setWebhookSaving] = useState(false)
|
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||||
const [webhookTesting, setWebhookTesting] = useState(false)
|
const [webhookTesting, setWebhookTesting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
||||||
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
|
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
|
||||||
setWebhookUrl((data.settings?.webhook_url as string) || '')
|
const val = (data.settings?.webhook_url as string) || ''
|
||||||
|
if (val === '••••••••') {
|
||||||
|
setWebhookIsSet(true)
|
||||||
|
setWebhookUrl('')
|
||||||
|
} else {
|
||||||
|
setWebhookUrl(val)
|
||||||
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -75,6 +82,8 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
setWebhookSaving(true)
|
setWebhookSaving(true)
|
||||||
try {
|
try {
|
||||||
await settingsApi.set('webhook_url', webhookUrl)
|
await settingsApi.set('webhook_url', webhookUrl)
|
||||||
|
if (webhookUrl) setWebhookIsSet(true)
|
||||||
|
else setWebhookIsSet(false)
|
||||||
toast.success(t('settings.webhookUrl.saved'))
|
toast.success(t('settings.webhookUrl.saved'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
@@ -84,10 +93,10 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const testWebhookUrl = async () => {
|
const testWebhookUrl = async () => {
|
||||||
if (!webhookUrl) return
|
if (!webhookUrl && !webhookIsSet) return
|
||||||
setWebhookTesting(true)
|
setWebhookTesting(true)
|
||||||
try {
|
try {
|
||||||
const result = await notificationsApi.testWebhook(webhookUrl)
|
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
|
||||||
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
|
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
|
||||||
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
|
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -122,7 +131,7 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
type="text"
|
type="text"
|
||||||
value={webhookUrl}
|
value={webhookUrl}
|
||||||
onChange={e => setWebhookUrl(e.target.value)}
|
onChange={e => setWebhookUrl(e.target.value)}
|
||||||
placeholder={t('settings.webhookUrl.placeholder')}
|
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
|
||||||
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -134,8 +143,8 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={testWebhookUrl}
|
onClick={testWebhookUrl}
|
||||||
disabled={!webhookUrl || webhookTesting}
|
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
|
||||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!webhookUrl || webhookTesting) ? 'not-allowed' : 'pointer', opacity: (!webhookUrl || webhookTesting) ? 0.5 : 1 }}
|
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
|
||||||
>
|
>
|
||||||
{t('settings.webhookUrl.test')}
|
{t('settings.webhookUrl.test')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1258,9 +1258,9 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminWebhookPanel.title')}</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminWebhookPanel.title')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={smtpValues.admin_webhook_url || ''}
|
value={smtpValues.admin_webhook_url === '••••••••' ? '' : smtpValues.admin_webhook_url || ''}
|
||||||
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
|
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder={smtpValues.admin_webhook_url === '••••••••' ? '••••••••' : 'https://discord.com/api/webhooks/...'}
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1279,10 +1279,11 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!smtpValues.admin_webhook_url) return
|
const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url
|
||||||
|
if (!url && smtpValues.admin_webhook_url !== '••••••••') return
|
||||||
try {
|
try {
|
||||||
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url }).catch(() => {})
|
if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {})
|
||||||
const result = await notificationsApi.testWebhook(smtpValues.admin_webhook_url)
|
const result = await notificationsApi.testWebhook(url)
|
||||||
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
|
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
|
||||||
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
|
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
|
||||||
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
|
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { testSmtp, testWebhook } from '../services/notifications';
|
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
|
||||||
import {
|
import {
|
||||||
getNotifications,
|
getNotifications,
|
||||||
getUnreadCount,
|
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) => {
|
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
||||||
const { url } = req.body;
|
const authReq = req as AuthRequest;
|
||||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url is required' });
|
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' }); }
|
try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||||
res.json(await testWebhook(url));
|
res.json(await testWebhook(url));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ router.put('/', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { key, value } = req.body;
|
const { key, value } = req.body;
|
||||||
if (!key) return res.status(400).json({ error: 'Key is required' });
|
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);
|
settingsService.upsertSetting(authReq.user.id, key, value);
|
||||||
res.json({ success: true, key, value });
|
res.json({ success: true, key, value });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -678,7 +678,7 @@ export function getAppSettings(userId: number): { error?: string; status?: numbe
|
|||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
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 };
|
return { data: result };
|
||||||
}
|
}
|
||||||
@@ -716,6 +716,7 @@ export function updateAppSettings(
|
|||||||
}
|
}
|
||||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||||
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
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;
|
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);
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
|
||||||
const settings: Record<string, unknown> = {};
|
const settings: Record<string, unknown> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
|
||||||
|
settings[row.key] = row.value ? '••••••••' : '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
settings[row.key] = JSON.parse(row.value);
|
settings[row.key] = JSON.parse(row.value);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user