Sfoglia il codice sorgente

feat(webhooks): add delivery worker with claim and SSRF guards

AI-Co-Authored-By: Grok
chendeben 1 mese fa
parent
commit
0e248aa34b
3 ha cambiato i file con 640 aggiunte e 0 eliminazioni
  1. 12 0
      src/server.js
  2. 366 0
      src/webhook-dispatcher.js
  3. 262 0
      test/webhook-dispatcher.test.js

+ 12 - 0
src/server.js

@@ -66,6 +66,7 @@ import {
 import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
 import { startDnsAutoChecker } from './dns-auto-checker.js';
 import { startPostfixDeliveryTracker } from './delivery-tracker.js';
+import { startWebhookWorker } from './webhook-dispatcher.js';
 import { buildDnsGuide, buildSystemDnsChecks } from './dns-guide.js';
 import { createDkimKeyPair } from './dkim.js';
 import {
@@ -109,6 +110,11 @@ const envConfig = {
   dnsAutoCheckEnabled: String(process.env.DNS_AUTO_CHECK_ENABLED || 'true').toLowerCase() !== 'false',
   dnsAutoCheckIntervalMs: Number(process.env.DNS_AUTO_CHECK_INTERVAL_MS || 60000),
   dnsAutoCheckLimit: Number(process.env.DNS_AUTO_CHECK_LIMIT || 25),
+  webhookWorkerEnabled:
+    String(process.env.WEBHOOK_WORKER_ENABLED || 'true').toLowerCase() !== 'false' &&
+    String(process.env.WEBHOOK_WORKER_ENABLED || '') !== '0',
+  webhookWorkerIntervalMs: Number(process.env.WEBHOOK_WORKER_INTERVAL_MS || 10000),
+  webhookWorkerBatchSize: Number(process.env.WEBHOOK_WORKER_BATCH_SIZE || 3),
   submissionEnabled: String(process.env.SUBMISSION_ENABLED || 'true').toLowerCase() !== 'false',
   submissionHost: process.env.SUBMISSION_HOST || process.env.APP_BASE_URL?.replace(/^https?:\/\//, '') || 'localhost',
   submissionListeners: parseSubmissionListeners(process.env.SUBMISSION_PORTS),
@@ -157,6 +163,12 @@ startDnsAutoChecker({
   limit: envConfig.dnsAutoCheckLimit
 });
 
+startWebhookWorker({
+  enabled: envConfig.webhookWorkerEnabled,
+  intervalMs: envConfig.webhookWorkerIntervalMs,
+  batchSize: envConfig.webhookWorkerBatchSize
+});
+
 const server = http.createServer(async (req, res) => {
   try {
     setSecurityHeaders(res);

+ 366 - 0
src/webhook-dispatcher.js

@@ -0,0 +1,366 @@
+import dns from 'node:dns';
+import net from 'node:net';
+import {
+  claimWebhookDeliveries,
+  completeWebhookDeliveryFailure,
+  completeWebhookDeliverySuccess,
+  reapExpiredWebhookProcessing
+} from './db.js';
+import { eventTypeForStatus, signWebhookBody } from './webhook-model.js';
+
+const DEFAULT_INTERVAL_MS = 10_000;
+const DEFAULT_BATCH_SIZE = 3;
+const FETCH_TIMEOUT_MS = 10_000;
+const USER_AGENT = 'MailHub-Webhook/1.0';
+
+const blockedAddresses = new net.BlockList();
+// IPv4 special-use / private / metadata-adjacent
+blockedAddresses.addSubnet('0.0.0.0', 8, 'ipv4');
+blockedAddresses.addSubnet('10.0.0.0', 8, 'ipv4');
+blockedAddresses.addSubnet('100.64.0.0', 10, 'ipv4');
+blockedAddresses.addSubnet('127.0.0.0', 8, 'ipv4');
+blockedAddresses.addSubnet('169.254.0.0', 16, 'ipv4');
+blockedAddresses.addSubnet('172.16.0.0', 12, 'ipv4');
+blockedAddresses.addSubnet('192.0.0.0', 24, 'ipv4');
+blockedAddresses.addSubnet('192.0.2.0', 24, 'ipv4');
+blockedAddresses.addSubnet('192.168.0.0', 16, 'ipv4');
+blockedAddresses.addSubnet('198.18.0.0', 15, 'ipv4');
+blockedAddresses.addSubnet('198.51.100.0', 24, 'ipv4');
+blockedAddresses.addSubnet('203.0.113.0', 24, 'ipv4');
+blockedAddresses.addSubnet('224.0.0.0', 4, 'ipv4');
+blockedAddresses.addSubnet('240.0.0.0', 4, 'ipv4');
+// IPv6 special-use (IPv4-mapped ::ffff:* handled in isBlockedIpAddress, not here —
+// BlockList treats IPv4 as matching ::ffff:0:0/96 and would false-positive public IPs)
+blockedAddresses.addAddress('::', 'ipv6');
+blockedAddresses.addAddress('::1', 'ipv6');
+blockedAddresses.addSubnet('64:ff9b::', 96, 'ipv6');
+blockedAddresses.addSubnet('100::', 64, 'ipv6');
+blockedAddresses.addSubnet('2001:db8::', 32, 'ipv6');
+blockedAddresses.addSubnet('fc00::', 7, 'ipv6');
+blockedAddresses.addSubnet('fe80::', 10, 'ipv6');
+blockedAddresses.addSubnet('ff00::', 8, 'ipv6');
+
+let workerHandle = null;
+
+export function isBlockedIpAddress(address) {
+  const value = String(address || '').trim().toLowerCase();
+  if (!value) return true;
+
+  const mapped = value.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
+  if (mapped) return isBlockedIpAddress(mapped[1]);
+
+  const mappedHex = value.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
+  if (mappedHex) {
+    const hi = Number.parseInt(mappedHex[1], 16);
+    const lo = Number.parseInt(mappedHex[2], 16);
+    const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
+    return isBlockedIpAddress(ipv4);
+  }
+
+  if (net.isIPv4(value)) {
+    return blockedAddresses.check(value, 'ipv4');
+  }
+  if (net.isIPv6(value)) {
+    return blockedAddresses.check(value, 'ipv6');
+  }
+  return true;
+}
+
+export function isLoopbackHostname(hostname) {
+  const host = String(hostname || '')
+    .trim()
+    .toLowerCase()
+    .replace(/^\[|\]$/g, '');
+  return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0:0:0:0:0:0:0:1';
+}
+
+export function isLoopbackIpAddress(address) {
+  const value = String(address || '').trim().toLowerCase();
+  if (net.isIPv4(value)) return value.startsWith('127.');
+  if (net.isIPv6(value)) {
+    return value === '::1' || value === '0:0:0:0:0:0:0:1';
+  }
+  return false;
+}
+
+/**
+ * Validate webhook URL scheme and resolved addresses (fail closed).
+ * @returns {Promise<URL>}
+ */
+export async function assertSafeWebhookUrl(
+  rawUrl,
+  {
+    allowHttpLocal = String(process.env.WEBHOOK_ALLOW_HTTP_LOCAL || '') === '1',
+    dnsLookup = defaultDnsLookup
+  } = {}
+) {
+  let parsed;
+  try {
+    parsed = new URL(String(rawUrl || ''));
+  } catch {
+    throw new Error('Invalid webhook URL');
+  }
+
+  const protocol = parsed.protocol.toLowerCase();
+  const loopbackHost = isLoopbackHostname(parsed.hostname);
+  const allowLocalHttp = allowHttpLocal && protocol === 'http:' && loopbackHost;
+
+  if (protocol === 'https:') {
+    // ok
+  } else if (allowLocalHttp) {
+    // ok
+  } else if (protocol === 'http:') {
+    throw new Error('Webhook URL must use https (set WEBHOOK_ALLOW_HTTP_LOCAL=1 for loopback http)');
+  } else {
+    throw new Error('Webhook URL must use https');
+  }
+
+  if (parsed.username || parsed.password) {
+    throw new Error('Webhook URL must not include credentials');
+  }
+
+  const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
+  if (!hostname) throw new Error('Invalid webhook URL host');
+
+  // Literal IP hosts: check before DNS.
+  if (net.isIP(hostname)) {
+    const allowLoopback = allowHttpLocal && isLoopbackIpAddress(hostname);
+    if (isBlockedIpAddress(hostname) && !allowLoopback) {
+      throw new Error(`Webhook URL resolves to a blocked address (${hostname})`);
+    }
+    return parsed;
+  }
+
+  let records;
+  try {
+    records = await dnsLookup(hostname, { all: true, verbatim: true });
+  } catch (error) {
+    throw new Error(`Webhook DNS lookup failed: ${error.code || error.message}`);
+  }
+
+  const list = Array.isArray(records) ? records : records ? [records] : [];
+  if (list.length === 0) {
+    throw new Error('Webhook DNS lookup returned no addresses');
+  }
+
+  for (const record of list) {
+    const address = typeof record === 'string' ? record : record.address;
+    const allowLoopback = allowHttpLocal && loopbackHost && isLoopbackIpAddress(address);
+    if (isBlockedIpAddress(address) && !allowLoopback) {
+      throw new Error(`Webhook URL resolves to a blocked address (${address})`);
+    }
+  }
+
+  return parsed;
+}
+
+export async function deliverOne(item, {
+  fetchImpl = globalThis.fetch.bind(globalThis),
+  completeSuccess = completeWebhookDeliverySuccess,
+  completeFailure = completeWebhookDeliveryFailure,
+  dnsLookup = defaultDnsLookup,
+  allowHttpLocal = String(process.env.WEBHOOK_ALLOW_HTTP_LOCAL || '') === '1',
+  nowSeconds = () => Math.floor(Date.now() / 1000),
+  logger = console
+} = {}) {
+  const delivery = item?.delivery;
+  const webhook = item?.webhook;
+  const deliveryId = delivery?.id;
+
+  if (!deliveryId) {
+    logger.warn?.('webhook deliverOne missing delivery id');
+    return null;
+  }
+
+  const url = webhook?.url;
+  const secret = webhook?.secret;
+  if (!url || !secret) {
+    return completeFailure(deliveryId, {
+      error: 'Webhook target missing url or secret'
+    });
+  }
+
+  try {
+    await assertSafeWebhookUrl(url, { allowHttpLocal, dnsLookup });
+  } catch (error) {
+    return completeFailure(deliveryId, {
+      error: error.message || 'Webhook URL blocked'
+    });
+  }
+
+  const rawBody = delivery.payloadJson || '{}';
+  let eventHeader = 'email.sent';
+  try {
+    const payload = JSON.parse(rawBody);
+    eventHeader = payload.type || eventTypeForStatus(delivery.eventType) || eventHeader;
+  } catch {
+    eventHeader = eventTypeForStatus(delivery.eventType) || eventHeader;
+  }
+
+  const signature = signWebhookBody(rawBody, secret, nowSeconds());
+  const headers = {
+    'Content-Type': 'application/json',
+    'User-Agent': USER_AGENT,
+    'X-MailHub-Signature': signature,
+    'X-MailHub-Event': eventHeader,
+    'X-MailHub-Delivery': `whd_${deliveryId}`
+  };
+
+  try {
+    const response = await fetchImpl(url, {
+      method: 'POST',
+      headers,
+      body: rawBody,
+      redirect: 'manual',
+      signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
+    });
+    const status = Number(response?.status) || 0;
+    const bodyPreview = await readBodyPreview(response);
+    if (status >= 200 && status < 300) {
+      return completeSuccess(deliveryId, {
+        responseStatus: status,
+        bodyPreview
+      });
+    }
+    return completeFailure(deliveryId, {
+      responseStatus: status || null,
+      bodyPreview,
+      error: `HTTP ${status || 'error'}`
+    });
+  } catch (error) {
+    return completeFailure(deliveryId, {
+      error: error.name === 'TimeoutError' || error.name === 'AbortError'
+        ? 'Webhook request timed out'
+        : error.message || 'Webhook request failed'
+    });
+  }
+}
+
+export async function processWebhookBatch({
+  fetchImpl = globalThis.fetch.bind(globalThis),
+  batchSize = DEFAULT_BATCH_SIZE,
+  claim = claimWebhookDeliveries,
+  completeSuccess = completeWebhookDeliverySuccess,
+  completeFailure = completeWebhookDeliveryFailure,
+  reap = reapExpiredWebhookProcessing,
+  dnsLookup = defaultDnsLookup,
+  allowHttpLocal = String(process.env.WEBHOOK_ALLOW_HTTP_LOCAL || '') === '1',
+  nowSeconds = () => Math.floor(Date.now() / 1000),
+  logger = console
+} = {}) {
+  try {
+    reap();
+  } catch (error) {
+    logger.warn?.(`webhook reap failed: ${error.message}`);
+  }
+
+  let claimed = [];
+  try {
+    claimed = claim(batchSize) || [];
+  } catch (error) {
+    logger.warn?.(`webhook claim failed: ${error.message}`);
+    return { claimed: 0, processed: 0 };
+  }
+
+  let processed = 0;
+  for (const item of claimed) {
+    try {
+      await deliverOne(item, {
+        fetchImpl,
+        completeSuccess,
+        completeFailure,
+        dnsLookup,
+        allowHttpLocal,
+        nowSeconds,
+        logger
+      });
+      processed += 1;
+    } catch (error) {
+      logger.warn?.(`webhook deliver failed: ${error.message}`);
+      try {
+        if (item?.delivery?.id) {
+          completeFailure(item.delivery.id, {
+            error: error.message || 'Webhook deliver failed'
+          });
+        }
+      } catch {
+        // ignore secondary failures
+      }
+    }
+  }
+
+  return { claimed: claimed.length, processed };
+}
+
+export function startWebhookWorker({
+  enabled = true,
+  intervalMs = DEFAULT_INTERVAL_MS,
+  batchSize = DEFAULT_BATCH_SIZE,
+  fetchImpl = globalThis.fetch.bind(globalThis),
+  logger = console
+} = {}) {
+  if (!enabled) return null;
+  if (workerHandle) return workerHandle;
+
+  const state = {
+    stopped: false,
+    running: false
+  };
+  const pollIntervalMs = safePositiveInt(intervalMs, DEFAULT_INTERVAL_MS);
+  const size = safePositiveInt(batchSize, DEFAULT_BATCH_SIZE);
+
+  async function poll() {
+    if (state.stopped || state.running) return;
+    state.running = true;
+    try {
+      await processWebhookBatch({
+        fetchImpl,
+        batchSize: size,
+        logger
+      });
+    } catch (error) {
+      logger.warn?.(`webhook worker poll failed: ${error.message}`);
+    } finally {
+      state.running = false;
+    }
+  }
+
+  const timer = setInterval(poll, pollIntervalMs);
+  timer.unref?.();
+  setTimeout(poll, 2000).unref?.();
+
+  workerHandle = {
+    stop() {
+      state.stopped = true;
+      clearInterval(timer);
+      if (workerHandle === this) workerHandle = null;
+    },
+    poll
+  };
+  return workerHandle;
+}
+
+export function stopWebhookWorker() {
+  if (!workerHandle) return;
+  workerHandle.stop();
+  workerHandle = null;
+}
+
+async function defaultDnsLookup(hostname, options) {
+  return dns.promises.lookup(hostname, options);
+}
+
+async function readBodyPreview(response) {
+  if (!response || typeof response.text !== 'function') return '';
+  try {
+    const text = await response.text();
+    return String(text || '').slice(0, 2048);
+  } catch {
+    return '';
+  }
+}
+
+function safePositiveInt(value, fallback) {
+  const number = Number(value);
+  return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
+}

+ 262 - 0
test/webhook-dispatcher.test.js

@@ -0,0 +1,262 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  claimWebhookDeliveries,
+  createUser,
+  createWebhook,
+  enqueueWebhookDeliveries,
+  initDatabase,
+  listWebhookDeliveries
+} from '../src/db.js';
+import {
+  assertSafeWebhookUrl,
+  isBlockedIpAddress,
+  processWebhookBatch,
+  startWebhookWorker,
+  stopWebhookWorker
+} from '../src/webhook-dispatcher.js';
+import { MAX_WEBHOOK_ATTEMPTS, signWebhookBody } from '../src/webhook-model.js';
+
+test('blocks private and loopback addresses', () => {
+  assert.equal(isBlockedIpAddress('10.0.0.5'), true);
+  assert.equal(isBlockedIpAddress('192.168.1.1'), true);
+  assert.equal(isBlockedIpAddress('127.0.0.1'), true);
+  assert.equal(isBlockedIpAddress('169.254.169.254'), true);
+  assert.equal(isBlockedIpAddress('172.16.0.1'), true);
+  assert.equal(isBlockedIpAddress('::1'), true);
+  assert.equal(isBlockedIpAddress('fc00::1'), true);
+  assert.equal(isBlockedIpAddress('fe80::1'), true);
+  assert.equal(isBlockedIpAddress('::ffff:10.0.0.1'), true);
+  assert.equal(isBlockedIpAddress('1.1.1.1'), false);
+  assert.equal(isBlockedIpAddress('8.8.8.8'), false);
+});
+
+test('assertSafeWebhookUrl requires https and blocks private DNS results', async () => {
+  await assert.rejects(
+    () => assertSafeWebhookUrl('http://example.com/hook'),
+    /https/i
+  );
+  await assert.rejects(
+    () =>
+      assertSafeWebhookUrl('https://hooks.example.com/hook', {
+        dnsLookup: async () => [{ address: '10.1.2.3', family: 4 }]
+      }),
+    /blocked/i
+  );
+  const ok = await assertSafeWebhookUrl('https://hooks.example.com/hook', {
+    dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }]
+  });
+  assert.equal(ok.hostname, 'hooks.example.com');
+
+  await assert.rejects(
+    () => assertSafeWebhookUrl('http://127.0.0.1:9999/hook', { allowHttpLocal: false }),
+    /https/i
+  );
+  const local = await assertSafeWebhookUrl('http://127.0.0.1:9999/hook', {
+    allowHttpLocal: true
+  });
+  assert.equal(local.hostname, '127.0.0.1');
+});
+
+test('posts signed body and marks success on 2xx', async () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const webhook = createWebhook(alice.id, {
+    name: 'Primary',
+    url: 'https://hooks.example.com/mail',
+    events: ['sent']
+  });
+  enqueueWebhookDeliveries({
+    id: 11,
+    userId: alice.id,
+    domainId: null,
+    status: 'sent',
+    sender: 'noreply@example.com',
+    recipients: ['user@example.com'],
+    subject: 'Hello',
+    detail: 'ok',
+    queueId: 'Q11',
+    deliveredAt: '2026-07-09T12:00:01.000Z'
+  });
+
+  const [before] = listWebhookDeliveries(alice.id);
+  const fetchCalls = [];
+  const fixedSeconds = 1_700_000_000;
+
+  const result = await processWebhookBatch({
+    batchSize: 5,
+    nowSeconds: () => fixedSeconds,
+    dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
+    fetchImpl: async (url, options) => {
+      fetchCalls.push({ url, options });
+      return {
+        status: 204,
+        text: async () => ''
+      };
+    }
+  });
+
+  assert.equal(result.claimed, 1);
+  assert.equal(fetchCalls.length, 1);
+  assert.equal(fetchCalls[0].url, 'https://hooks.example.com/mail');
+  assert.equal(fetchCalls[0].options.method, 'POST');
+  assert.equal(fetchCalls[0].options.redirect, 'manual');
+  assert.equal(fetchCalls[0].options.headers['Content-Type'], 'application/json');
+  assert.equal(fetchCalls[0].options.headers['User-Agent'], 'MailHub-Webhook/1.0');
+  assert.equal(fetchCalls[0].options.headers['X-MailHub-Event'], 'email.sent');
+  assert.equal(fetchCalls[0].options.headers['X-MailHub-Delivery'], `whd_${before.id}`);
+  assert.equal(
+    fetchCalls[0].options.headers['X-MailHub-Signature'],
+    signWebhookBody(before.payloadJson, webhook.secret, fixedSeconds)
+  );
+  assert.equal(fetchCalls[0].options.body, before.payloadJson);
+
+  const [after] = listWebhookDeliveries(alice.id);
+  assert.equal(after.status, 'success');
+  assert.equal(after.attemptCount, 1);
+  assert.equal(after.responseStatus, 204);
+  assert.equal(after.error, '');
+});
+
+test('schedules retry on 500', async () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  createWebhook(alice.id, {
+    name: 'Retry',
+    url: 'https://hooks.example.com/retry',
+    events: ['failed']
+  });
+  enqueueWebhookDeliveries({
+    id: 12,
+    userId: alice.id,
+    domainId: null,
+    status: 'failed',
+    sender: 'noreply@example.com',
+    recipients: ['user@example.com'],
+    subject: 'Nope',
+    detail: 'bounce',
+    queueId: 'Q12'
+  });
+
+  await processWebhookBatch({
+    dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
+    fetchImpl: async () => ({
+      status: 500,
+      text: async () => 'upstream error body'
+    })
+  });
+
+  const [delivery] = listWebhookDeliveries(alice.id);
+  assert.equal(delivery.status, 'pending');
+  assert.equal(delivery.attemptCount, 1);
+  assert.equal(delivery.responseStatus, 500);
+  assert.match(delivery.error, /HTTP 500/);
+  assert.match(delivery.responseBodyPreview, /upstream error/);
+  assert.ok(Date.parse(delivery.nextAttemptAt) > Date.now());
+});
+
+test('marks dead after max attempts', async () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const webhook = createWebhook(alice.id, {
+    name: 'Dead',
+    url: 'https://hooks.example.com/dead',
+    events: ['bounced']
+  });
+  enqueueWebhookDeliveries({
+    id: 13,
+    userId: alice.id,
+    domainId: null,
+    status: 'bounced',
+    sender: 'noreply@example.com',
+    recipients: ['user@example.com'],
+    subject: 'Bounced',
+    detail: '',
+    queueId: 'Q13'
+  });
+
+  // Inject claim so retries are not blocked by future next_attempt_at backoff.
+  for (let attempt = 0; attempt < MAX_WEBHOOK_ATTEMPTS; attempt += 1) {
+    const delivery = listWebhookDeliveries(alice.id)[0];
+    await processWebhookBatch({
+      claim: () => [
+        {
+          delivery: { ...delivery, status: 'processing' },
+          webhook: {
+            id: webhook.id,
+            url: webhook.url,
+            secret: webhook.secret
+          }
+        }
+      ],
+      reap: () => 0,
+      dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
+      fetchImpl: async () => ({
+        status: 503,
+        text: async () => 'down'
+      })
+    });
+  }
+
+  const dead = listWebhookDeliveries(alice.id)[0];
+  assert.equal(dead.status, 'dead');
+  assert.equal(dead.attemptCount, MAX_WEBHOOK_ATTEMPTS);
+});
+
+test('rejects private IP targets without calling fetch', async () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  createWebhook(alice.id, {
+    name: 'Internal',
+    url: 'https://metadata.internal/hook',
+    events: ['sent']
+  });
+  enqueueWebhookDeliveries({
+    id: 14,
+    userId: alice.id,
+    domainId: null,
+    status: 'sent',
+    sender: 'noreply@example.com',
+    recipients: ['user@example.com'],
+    subject: 'SSRF',
+    detail: '',
+    queueId: 'Q14'
+  });
+
+  let fetchCalled = false;
+  await processWebhookBatch({
+    dnsLookup: async () => [{ address: '169.254.169.254', family: 4 }],
+    fetchImpl: async () => {
+      fetchCalled = true;
+      return { status: 200, text: async () => 'ok' };
+    }
+  });
+
+  assert.equal(fetchCalled, false);
+  const [delivery] = listWebhookDeliveries(alice.id);
+  assert.equal(delivery.status, 'pending');
+  assert.equal(delivery.attemptCount, 1);
+  assert.match(delivery.error, /blocked/i);
+  assert.equal(claimWebhookDeliveries(5).length, 0);
+});
+
+test('startWebhookWorker can be skipped and stopped', async () => {
+  assert.equal(startWebhookWorker({ enabled: false }), null);
+  const handle = startWebhookWorker({
+    enabled: true,
+    intervalMs: 60_000,
+    fetchImpl: async () => ({ status: 200, text: async () => '' })
+  });
+  assert.ok(handle);
+  assert.equal(typeof handle.stop, 'function');
+  stopWebhookWorker();
+  stopWebhookWorker();
+});
+
+function tempDataDir() {
+  return mkdtempSync(path.join(tmpdir(), 'mailhub-webhook-dispatcher-'));
+}