fix(security): resolve DNS before vetting external hostnames (SSRF cluster) (stable) (#942)

* fix(security): resolve DNS before vetting external hostnames (SSRF cluster)

* fix(security): harden SSRF fix per review (rsync backup path, S3 config-save, webhook transient-DNS retry)

* fix(security): S3 endpoint validation on any endpoint update + no-connect on unresolved webhook host (codex r2)

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-01 17:36:51 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 34a7b1c013
commit 90275f88e9
8 changed files with 311 additions and 30 deletions
+26 -5
View File
@@ -57,14 +57,33 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
}
break;
case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
}
break;
}
}
// SSRF: validate an S3 endpoint whenever one is supplied — NOT only when
// the payload also flips backup_destination_type to 's3'. The PUT
// persists every backup_* field independently, so with S3 already
// selected a caller could PATCH just backup_s3_endpoint to a
// private-resolving host; the management ops (manifest, bucket/file
// browse, cleanup, test-upload) then connect without going through
// testConnection. Prod-only; dev points at localhost MinIO deliberately.
if (process.env.NODE_ENV === 'production'
&& updates.backup_s3_endpoint && updates.backup_s3_endpoint !== '••••••••') {
const rawEndpoint = updates.backup_s3_endpoint;
const withProto = /^https?:\/\//.test(rawEndpoint) ? rawEndpoint : `https://${rawEndpoint}`;
let epHost = null;
try { epHost = new URL(withProto).hostname; } catch { epHost = null; }
const { isHostAllowed } = require('../utils/networkValidation');
if (!epHost || !(await isHostAllowed(epHost))) {
return res.status(400).json({ error: 'S3 endpoint resolves to a private or internal network address' });
}
}
// Update settings
for (const [key, value] of Object.entries(updates)) {
if (key.startsWith('backup_')) {
@@ -356,9 +375,11 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
break;
}
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
// SSRF protection: resolve the host and block any private/internal
// address. ssh does its own DNS at connect time, so a literal-only
// check let a hostname resolving to an internal IP through (#GHSA-4jh8).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(host))) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
+16 -14
View File
@@ -66,9 +66,11 @@ router.post('/config', [
tls_reject_unauthorized
} = req.body;
// Validate SMTP host is not a private/internal address (SSRF protection)
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(smtp_host)) {
// Validate SMTP host is not a private/internal address (SSRF protection).
// Resolves DNS so a public-looking hostname pointing at an internal IP
// is caught, not just literal private addresses (#GHSA-ch64).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(smtp_host))) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
@@ -152,8 +154,8 @@ router.post('/incoming-config', [
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
const existing = await db('email_configs').first();
@@ -183,8 +185,8 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
try {
const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {};
if (imap_host) {
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
}
@@ -205,8 +207,8 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
try {
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
if (imap_host) {
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
}
@@ -385,11 +387,11 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
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)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (b.imap_host && !(await isHostAllowed(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)) {
if (b.smtp_host && !(await isHostAllowed(b.smtp_host))) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
const patch = {
@@ -434,8 +436,8 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const b = req.body || {};
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (b.imap_host && !(await isHostAllowed(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;
+5 -5
View File
@@ -20,7 +20,7 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { validateExternalUrl } = require('../utils/networkValidation');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const webhookService = require('../services/webhookService');
const logger = require('../utils/logger');
@@ -78,9 +78,9 @@ router.post(
requirePermission('settings.edit'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('url').isString().isLength({ max: 2048 }).custom((url) => {
body('url').isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
@@ -160,9 +160,9 @@ router.put(
requirePermission('settings.edit'),
[
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
body('url').optional().isString().isLength({ max: 2048 }).custom((url) => {
body('url').optional().isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),