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:
co-authored by
Paul Nothaft
parent
34a7b1c013
commit
90275f88e9
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* DNS-resolving SSRF guard (GHSA SSRF cluster: webhook / S3 / rsync / SMTP /
|
||||||
|
* IMAP). The literal isPrivateIP check can't see that a public-looking
|
||||||
|
* hostname resolves to an internal/metadata IP; isHostAllowed resolves the
|
||||||
|
* name and vets every A/AAAA record.
|
||||||
|
*/
|
||||||
|
jest.mock('dns', () => {
|
||||||
|
const actual = jest.requireActual('dns');
|
||||||
|
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
|
||||||
|
});
|
||||||
|
const dns = require('dns');
|
||||||
|
const {
|
||||||
|
isHostAllowed,
|
||||||
|
validateExternalUrlAsync,
|
||||||
|
classifyHost,
|
||||||
|
} = require('../../src/utils/networkValidation');
|
||||||
|
|
||||||
|
const lookup = dns.promises.lookup;
|
||||||
|
|
||||||
|
describe('classifyHost', () => {
|
||||||
|
beforeEach(() => lookup.mockReset());
|
||||||
|
|
||||||
|
it('distinguishes private, unresolved, ok, and invalid', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||||
|
expect(await classifyHost('evil.example')).toBe('private');
|
||||||
|
|
||||||
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||||
|
expect(await classifyHost('example.com')).toBe('ok');
|
||||||
|
|
||||||
|
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
|
||||||
|
expect(await classifyHost('blip.example')).toBe('unresolved');
|
||||||
|
|
||||||
|
lookup.mockResolvedValue([]);
|
||||||
|
expect(await classifyHost('empty.example')).toBe('unresolved');
|
||||||
|
|
||||||
|
expect(await classifyHost('')).toBe('invalid');
|
||||||
|
expect(await classifyHost('10.0.0.1')).toBe('private'); // literal, no lookup
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isHostAllowed', () => {
|
||||||
|
beforeEach(() => lookup.mockReset());
|
||||||
|
|
||||||
|
it('rejects a public hostname that resolves to a private IP', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||||
|
expect(await isHostAllowed('evil.example.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the hostname resolves to the cloud metadata IP', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||||
|
expect(await isHostAllowed('metadata-rebind.example')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when ANY resolved address is private (rebinding / mixed records)', async () => {
|
||||||
|
lookup.mockResolvedValue([
|
||||||
|
{ address: '93.184.216.34', family: 4 },
|
||||||
|
{ address: '169.254.169.254', family: 4 },
|
||||||
|
]);
|
||||||
|
expect(await isHostAllowed('rebind.example')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a hostname that resolves only to public IPs', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||||
|
expect(await isHostAllowed('example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when resolution errors', async () => {
|
||||||
|
lookup.mockRejectedValue(new Error('ENOTFOUND'));
|
||||||
|
expect(await isHostAllowed('nxdomain.invalid')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on an empty resolution', async () => {
|
||||||
|
lookup.mockResolvedValue([]);
|
||||||
|
expect(await isHostAllowed('empty.example')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects literal private IPs and blocked names without resolving', async () => {
|
||||||
|
expect(await isHostAllowed('127.0.0.1')).toBe(false);
|
||||||
|
expect(await isHostAllowed('10.0.0.1')).toBe(false);
|
||||||
|
expect(await isHostAllowed('localhost')).toBe(false);
|
||||||
|
expect(await isHostAllowed('metadata.google.internal')).toBe(false);
|
||||||
|
expect(await isHostAllowed('foo.internal')).toBe(false);
|
||||||
|
expect(lookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a public IP literal without resolving', async () => {
|
||||||
|
expect(await isHostAllowed('93.184.216.34')).toBe(true);
|
||||||
|
expect(lookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty / non-string input', async () => {
|
||||||
|
expect(await isHostAllowed('')).toBe(false);
|
||||||
|
expect(await isHostAllowed(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateExternalUrlAsync', () => {
|
||||||
|
beforeEach(() => lookup.mockReset());
|
||||||
|
|
||||||
|
it('rejects a URL whose host resolves to a private address', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]);
|
||||||
|
const r = await validateExternalUrlAsync('https://evil.example/hook');
|
||||||
|
expect(r.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a URL whose host resolves public', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||||
|
expect((await validateExternalUrlAsync('https://example.com/hook')).valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed URL', async () => {
|
||||||
|
expect((await validateExternalUrlAsync('not a url')).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports reason=unresolved for a transient lookup failure (retryable)', async () => {
|
||||||
|
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
|
||||||
|
const r = await validateExternalUrlAsync('https://blip.example/hook');
|
||||||
|
expect(r.valid).toBe(false);
|
||||||
|
expect(r.reason).toBe('unresolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports reason=private for a resolved-private host (permanent)', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||||
|
const r = await validateExternalUrlAsync('https://rebind.example/hook');
|
||||||
|
expect(r.valid).toBe(false);
|
||||||
|
expect(r.reason).toBe('private');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,14 +57,33 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 's3':
|
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) {
|
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
|
||||||
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
||||||
}
|
}
|
||||||
break;
|
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
|
// Update settings
|
||||||
for (const [key, value] of Object.entries(updates)) {
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
if (key.startsWith('backup_')) {
|
if (key.startsWith('backup_')) {
|
||||||
@@ -356,9 +375,11 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection: block connections to private/internal addresses
|
// SSRF protection: resolve the host and block any private/internal
|
||||||
const { isPrivateIP } = require('../utils/networkValidation');
|
// address. ssh does its own DNS at connect time, so a literal-only
|
||||||
if (isPrivateIP(host)) {
|
// 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' });
|
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,11 @@ router.post('/config', [
|
|||||||
tls_reject_unauthorized
|
tls_reject_unauthorized
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validate SMTP host is not a private/internal address (SSRF protection)
|
// Validate SMTP host is not a private/internal address (SSRF protection).
|
||||||
const { isPrivateIP } = require('../utils/networkValidation');
|
// Resolves DNS so a public-looking hostname pointing at an internal IP
|
||||||
if (isPrivateIP(smtp_host)) {
|
// 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' });
|
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);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
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 { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
|
||||||
const { isPrivateIP } = require('../utils/networkValidation');
|
const { isHostAllowed } = require('../utils/networkValidation');
|
||||||
if (isPrivateIP(imap_host)) {
|
if (!(await isHostAllowed(imap_host))) {
|
||||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||||
}
|
}
|
||||||
const existing = await db('email_configs').first();
|
const existing = await db('email_configs').first();
|
||||||
@@ -183,8 +185,8 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
|
|||||||
try {
|
try {
|
||||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {};
|
const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {};
|
||||||
if (imap_host) {
|
if (imap_host) {
|
||||||
const { isPrivateIP } = require('../utils/networkValidation');
|
const { isHostAllowed } = require('../utils/networkValidation');
|
||||||
if (isPrivateIP(imap_host)) {
|
if (!(await isHostAllowed(imap_host))) {
|
||||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
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 {
|
try {
|
||||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
|
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
|
||||||
if (imap_host) {
|
if (imap_host) {
|
||||||
const { isPrivateIP } = require('../utils/networkValidation');
|
const { isHostAllowed } = require('../utils/networkValidation');
|
||||||
if (isPrivateIP(imap_host)) {
|
if (!(await isHostAllowed(imap_host))) {
|
||||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
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' });
|
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
|
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
|
||||||
// SMTP host may point at a private/internal address.
|
// SMTP host may point at a private/internal address.
|
||||||
const { isPrivateIP } = require('../utils/networkValidation');
|
const { isHostAllowed } = require('../utils/networkValidation');
|
||||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
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' });
|
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' });
|
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||||
}
|
}
|
||||||
const patch = {
|
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) => {
|
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const b = req.body || {};
|
const b = req.body || {};
|
||||||
const { isPrivateIP } = require('../utils/networkValidation');
|
const { isHostAllowed } = require('../utils/networkValidation');
|
||||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
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' });
|
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||||
}
|
}
|
||||||
let pass = b.imap_pass;
|
let pass = b.imap_pass;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const { body, query, validationResult } = require('express-validator');
|
|||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { validateExternalUrl } = require('../utils/networkValidation');
|
const { validateExternalUrlAsync } = require('../utils/networkValidation');
|
||||||
const webhookService = require('../services/webhookService');
|
const webhookService = require('../services/webhookService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
@@ -78,9 +78,9 @@ router.post(
|
|||||||
requirePermission('settings.edit'),
|
requirePermission('settings.edit'),
|
||||||
[
|
[
|
||||||
body('name').isString().trim().isLength({ min: 1, max: 100 }),
|
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;
|
if (ALLOW_PRIVATE_URLS) return true;
|
||||||
const check = validateExternalUrl(url);
|
const check = await validateExternalUrlAsync(url);
|
||||||
if (!check.valid) throw new Error(check.error);
|
if (!check.valid) throw new Error(check.error);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
@@ -160,9 +160,9 @@ router.put(
|
|||||||
requirePermission('settings.edit'),
|
requirePermission('settings.edit'),
|
||||||
[
|
[
|
||||||
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
|
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;
|
if (ALLOW_PRIVATE_URLS) return true;
|
||||||
const check = validateExternalUrl(url);
|
const check = await validateExternalUrlAsync(url);
|
||||||
if (!check.valid) throw new Error(check.error);
|
if (!check.valid) throw new Error(check.error);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -845,6 +845,14 @@ function parseRsyncStats(output) {
|
|||||||
|
|
||||||
async function performRsyncBackup(config, files) {
|
async function performRsyncBackup(config, files) {
|
||||||
const { spawnAsync } = require('../utils/safeExec');
|
const { spawnAsync } = require('../utils/safeExec');
|
||||||
|
// SSRF: the /test-connection route validates the host, but a scheduled or
|
||||||
|
// manual /run reaches here directly with the stored host. Resolve-and-vet
|
||||||
|
// it right before ssh/rsync does its own DNS at connect time, so a host
|
||||||
|
// that resolves to an internal address can't be reached (GHSA-4jh8).
|
||||||
|
const { isHostAllowed } = require('../utils/networkValidation');
|
||||||
|
if (!(await isHostAllowed(config.backup_rsync_host))) {
|
||||||
|
throw new Error('rsync host resolves to a private or internal network address');
|
||||||
|
}
|
||||||
// Anchored excludes for the de-selected What-to-Backup paths; rsync
|
// Anchored excludes for the de-selected What-to-Backup paths; rsync
|
||||||
// otherwise transfers the whole storage root regardless of the walker's
|
// otherwise transfers the whole storage root regardless of the walker's
|
||||||
// file list (which only feeds manifests and file state).
|
// file list (which only feeds manifests and file state).
|
||||||
|
|||||||
@@ -131,6 +131,19 @@ class S3StorageAdapter extends stream.EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async testConnection() {
|
async testConnection() {
|
||||||
try {
|
try {
|
||||||
|
// Resolve-and-vet the custom endpoint before the network round-trip
|
||||||
|
// (the constructor's literal check can't catch a public-looking
|
||||||
|
// hostname that resolves to an internal IP). Prod-only, matching the
|
||||||
|
// constructor gate — dev points at localhost MinIO deliberately.
|
||||||
|
if (process.env.NODE_ENV === 'production' && this.config.endpoint) {
|
||||||
|
const { isHostAllowed } = require('../../utils/networkValidation');
|
||||||
|
const { hostname } = new URL(
|
||||||
|
/^https?:\/\//.test(this.config.endpoint) ? this.config.endpoint : `https://${this.config.endpoint}`
|
||||||
|
);
|
||||||
|
if (!(await isHostAllowed(hostname))) {
|
||||||
|
throw new Error('S3 endpoint resolves to a private or internal network address');
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||||
logger.info(`Successfully connected to S3 bucket: ${this.bucket}`);
|
logger.info(`Successfully connected to S3 bucket: ${this.bucket}`);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const axios = require('axios');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { signPayload, renderTemplate } = require('./webhookService');
|
const { signPayload, renderTemplate } = require('./webhookService');
|
||||||
const { validateExternalUrl } = require('../utils/networkValidation');
|
const { validateExternalUrlAsync } = require('../utils/networkValidation');
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10);
|
const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10);
|
||||||
const CONCURRENCY = parseInt(process.env.WEBHOOK_DELIVERY_CONCURRENCY || '5', 10);
|
const CONCURRENCY = parseInt(process.env.WEBHOOK_DELIVERY_CONCURRENCY || '5', 10);
|
||||||
@@ -91,12 +91,24 @@ async function deliverOne(row) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-validate URL per delivery — DNS-rebinding mitigation. Admin can opt
|
// Re-validate URL per delivery — DNS-rebinding mitigation. Resolves the
|
||||||
// out via WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
|
// host and vets every A/AAAA record (a public-looking name that now
|
||||||
|
// resolves to an internal IP is rejected). Admin can opt out via
|
||||||
|
// WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
|
||||||
if (!allowPrivateUrls) {
|
if (!allowPrivateUrls) {
|
||||||
const urlCheck = validateExternalUrl(webhook.url);
|
const urlCheck = await validateExternalUrlAsync(webhook.url);
|
||||||
if (!urlCheck.valid) {
|
if (!urlCheck.valid) {
|
||||||
await markFailedFinal(row, `URL rejected: ${urlCheck.error}`);
|
// A transient lookup failure ('unresolved' — EAI_AGAIN, resolver
|
||||||
|
// briefly down) must NOT connect: falling through to axios would let
|
||||||
|
// an attacker SERVFAIL this preflight and answer axios's own lookup
|
||||||
|
// with a private/metadata IP, defeating the guard. Schedule the
|
||||||
|
// normal retry/backoff instead — no request is made. A confirmed
|
||||||
|
// policy rejection (resolves-to-private / malformed) is permanent.
|
||||||
|
if (urlCheck.reason === 'unresolved') {
|
||||||
|
await scheduleTransientRetry(row, webhook, 'URL host did not resolve — retrying');
|
||||||
|
} else {
|
||||||
|
await markFailedFinal(row, `URL rejected: ${urlCheck.error}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +229,36 @@ async function markFailedFinal(row, reason) {
|
|||||||
await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date() });
|
await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule the normal retry/backoff for a transient failure that must not
|
||||||
|
// make a network request (e.g. the SSRF preflight lookup failed). Mirrors
|
||||||
|
// the failure branch of the main delivery path: retry until MAX_ATTEMPTS,
|
||||||
|
// then give up. No response fields — nothing was sent.
|
||||||
|
async function scheduleTransientRetry(row, webhook, errorMsg) {
|
||||||
|
const newAttempt = row.attempt_count + 1;
|
||||||
|
if (newAttempt >= MAX_ATTEMPTS) {
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
last_error: errorMsg,
|
||||||
|
attempt_count: newAttempt,
|
||||||
|
completed_at: new Date(),
|
||||||
|
next_retry_at: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const backoff = BACKOFF_MS[Math.min(newAttempt - 1, BACKOFF_MS.length - 1)];
|
||||||
|
await db('webhook_deliveries')
|
||||||
|
.where({ id: row.id })
|
||||||
|
.update({
|
||||||
|
status: 'pending',
|
||||||
|
last_error: errorMsg,
|
||||||
|
attempt_count: newAttempt,
|
||||||
|
next_retry_at: new Date(Date.now() + backoff),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyBody(data) {
|
function stringifyBody(data) {
|
||||||
if (data == null) return null;
|
if (data == null) return null;
|
||||||
if (typeof data === 'string') return data;
|
if (typeof data === 'string') return data;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { URL } = require('url');
|
const { URL } = require('url');
|
||||||
const net = require('net');
|
const net = require('net');
|
||||||
|
const dns = require('dns').promises;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a hostname or IP resolves to a private/internal network address.
|
* Check if a hostname or IP resolves to a private/internal network address.
|
||||||
@@ -162,6 +163,11 @@ function isPrivateIPv6(ip) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a URL string, rejecting private/internal targets.
|
* Validate a URL string, rejecting private/internal targets.
|
||||||
|
*
|
||||||
|
* NOTE: literal-only. For a hostname (not an IP), this checks the string but
|
||||||
|
* NOT what it resolves to — `evil.example` with an A record of 10.0.0.5
|
||||||
|
* passes. Prefer isHostAllowed / validateExternalUrlAsync at any call site
|
||||||
|
* that then actually connects; kept for synchronous callers and fast checks.
|
||||||
* @param {string} urlString - URL to validate
|
* @param {string} urlString - URL to validate
|
||||||
* @returns {{ valid: boolean, error?: string }}
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
*/
|
*/
|
||||||
@@ -177,4 +183,65 @@ function validateExternalUrl(urlString) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { isPrivateIP, validateExternalUrl };
|
/**
|
||||||
|
* Resolve a hostname and reject if it (or ANY of its A/AAAA records) points
|
||||||
|
* at a private/internal address. Closes the SSRF hole where a public-looking
|
||||||
|
* hostname resolves to an internal IP or the cloud metadata endpoint — the
|
||||||
|
* literal isPrivateIP check alone can't see that. Fails closed on resolution
|
||||||
|
* failure. IP literals are decided by isPrivateIP without a lookup.
|
||||||
|
*
|
||||||
|
* Residual: a determined attacker who controls DNS can still rebind between
|
||||||
|
* this check and the client's own resolution (TOCTOU). Fully closing that
|
||||||
|
* needs pinning the connection to the vetted IP, which the underlying
|
||||||
|
* clients (nodemailer/imap/ssh/aws-sdk) don't cleanly support; these actions
|
||||||
|
* are admin-only, so resolve-and-vet is the proportionate mitigation.
|
||||||
|
*
|
||||||
|
* @param {string} hostname
|
||||||
|
* @returns {Promise<boolean>} true when safe to connect
|
||||||
|
*/
|
||||||
|
async function classifyHost(hostname) {
|
||||||
|
if (!hostname || typeof hostname !== 'string') return 'invalid';
|
||||||
|
// Literal check first: IP literals, blocked names, .internal/.local/.localhost.
|
||||||
|
if (isPrivateIP(hostname)) return 'private';
|
||||||
|
// An IP literal is fully decided above — no name to resolve.
|
||||||
|
const bare = hostname.replace(/^\[|\]$/g, '');
|
||||||
|
if (net.isIP(bare)) return 'ok';
|
||||||
|
let addresses;
|
||||||
|
try {
|
||||||
|
addresses = await dns.lookup(hostname, { all: true });
|
||||||
|
} catch {
|
||||||
|
return 'unresolved'; // transient/NXDOMAIN — caller decides retry vs reject
|
||||||
|
}
|
||||||
|
if (!addresses.length) return 'unresolved';
|
||||||
|
return addresses.every((a) => !isPrivateIP(a.address)) ? 'ok' : 'private';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isHostAllowed(hostname) {
|
||||||
|
// Fail-closed boolean for save/test call sites: anything not clearly 'ok'
|
||||||
|
// (including a transient lookup failure) is rejected.
|
||||||
|
return (await classifyHost(hostname)) === 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async, DNS-resolving counterpart to validateExternalUrl. Returns a `reason`
|
||||||
|
* so callers with retry semantics (e.g. the webhook worker) can distinguish a
|
||||||
|
* policy rejection ('private'/'invalid') from a transient lookup failure
|
||||||
|
* ('unresolved') that should be retried rather than permanently failed.
|
||||||
|
* @param {string} urlString
|
||||||
|
* @returns {Promise<{ valid: boolean, error?: string, reason: string }>}
|
||||||
|
*/
|
||||||
|
async function validateExternalUrlAsync(urlString) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(urlString);
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'Invalid URL format', reason: 'invalid' };
|
||||||
|
}
|
||||||
|
const reason = await classifyHost(parsed.hostname);
|
||||||
|
if (reason !== 'ok') {
|
||||||
|
return { valid: false, error: 'URL points to a private or internal network address', reason };
|
||||||
|
}
|
||||||
|
return { valid: true, reason: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isPrivateIP, validateExternalUrl, isHostAllowed, validateExternalUrlAsync, classifyHost };
|
||||||
|
|||||||
Reference in New Issue
Block a user