harden(messages): SSRF guard on mailbox host, strict sandbox + sanitizer, per-account TLS

Pre-upstream review hardening:
- /accounts + /accounts/test now reject private/internal IMAP/SMTP hosts via
  isPrivateIP(), matching /config + /incoming-config (SSRF).
- QueueDetail body iframe uses sandbox="" (script-less, no same-origin) like the
  inbound pane, instead of allow-same-origin.
- /send sanitizer drops the <style> tag + data: scheme to match the stricter
  inbound sanitizeBody allowlist.
- Per-account SMTP transport sets tls.rejectUnauthorized explicitly.
This commit is contained in:
Luca
2026-07-07 16:04:11 +02:00
parent 2c5c1d561b
commit c8cb4c88ca
3 changed files with 21 additions and 4 deletions
+17 -2
View File
@@ -338,6 +338,15 @@ router.post('/accounts', adminAuth, requirePermission('email.edit'), async (req,
try {
const b = req.body || {};
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
// SMTP host may point at a private/internal address.
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
const patch = {
label: b.label || null,
imap_host: b.imap_host || null,
@@ -380,6 +389,10 @@ router.post('/accounts', adminAuth, requirePermission('email.edit'), async (req,
router.post('/accounts/test', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const b = req.body || {};
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
let pass = b.imap_pass;
if ((!pass || pass === '********') && b.account_key) {
const stored = await db('mail_accounts').where({ account_key: b.account_key }).first();
@@ -716,14 +729,16 @@ router.post('/send', adminAuth, requirePermission('email.send'), async (req, res
if (!subject) return res.status(400).json({ error: 'A subject is required.' });
const sanitizeHtml = require('sanitize-html');
// Match the stricter inbound sanitizeBody allowlist: no <style> tag, no
// data: scheme — inline style/class attributes are enough for composed mail.
const html = sanitizeHtml(String(b.html || ''), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'style']),
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style', 'class'],
},
allowedSchemes: ['http', 'https', 'mailto', 'cid', 'data'],
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
});
const cc = b.cc ? String(b.cc).trim() : null;
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
+1
View File
@@ -798,6 +798,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
port: parseInt(acct.smtp_port, 10) || 587,
secure: acct.smtp_secure === true || acct.smtp_secure === 1,
auth: acct.smtp_user && acct.smtp_pass ? { user: acct.smtp_user, pass: acct.smtp_pass } : undefined,
tls: { rejectUnauthorized: true },
});
fromEmail = acct.from_email || acct.smtp_user;
fromName = acct.from_name || '';
@@ -480,8 +480,9 @@ const QueueDetail: React.FC<{ d: import('../../../services/email.service').Email
{d.renderedHtml ? (
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '52vh' }}>
{/* rendered_html is our own template output — sandboxed, scripts blocked */}
<iframe title="Email body" sandbox="allow-same-origin" srcDoc={d.renderedHtml} className="w-full h-full border-0" />
{/* Our own template output, but rendered with a strict script-less,
no-same-origin sandbox anyway matches the inbound-mail pane. */}
<iframe title="Email body" sandbox="" srcDoc={d.renderedHtml} className="w-full h-full border-0" />
</div>
) : (
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">