webhook-dispatcher.test.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import { tmpdir } from 'node:os';
  4. import path from 'node:path';
  5. import { test } from 'node:test';
  6. import {
  7. claimWebhookDeliveries,
  8. createUser,
  9. createWebhook,
  10. enqueueWebhookDeliveries,
  11. initDatabase,
  12. listWebhookDeliveries
  13. } from '../src/db.js';
  14. import {
  15. assertSafeWebhookUrl,
  16. isBlockedIpAddress,
  17. processWebhookBatch,
  18. startWebhookWorker,
  19. stopWebhookWorker
  20. } from '../src/webhook-dispatcher.js';
  21. import { MAX_WEBHOOK_ATTEMPTS, signWebhookBody } from '../src/webhook-model.js';
  22. test('blocks private and loopback addresses', () => {
  23. assert.equal(isBlockedIpAddress('10.0.0.5'), true);
  24. assert.equal(isBlockedIpAddress('192.168.1.1'), true);
  25. assert.equal(isBlockedIpAddress('127.0.0.1'), true);
  26. assert.equal(isBlockedIpAddress('169.254.169.254'), true);
  27. assert.equal(isBlockedIpAddress('172.16.0.1'), true);
  28. assert.equal(isBlockedIpAddress('::1'), true);
  29. assert.equal(isBlockedIpAddress('fc00::1'), true);
  30. assert.equal(isBlockedIpAddress('fe80::1'), true);
  31. assert.equal(isBlockedIpAddress('::ffff:10.0.0.1'), true);
  32. assert.equal(isBlockedIpAddress('1.1.1.1'), false);
  33. assert.equal(isBlockedIpAddress('8.8.8.8'), false);
  34. });
  35. test('assertSafeWebhookUrl requires https and blocks private DNS results', async () => {
  36. await assert.rejects(
  37. () => assertSafeWebhookUrl('http://example.com/hook'),
  38. /https/i
  39. );
  40. await assert.rejects(
  41. () =>
  42. assertSafeWebhookUrl('https://hooks.example.com/hook', {
  43. dnsLookup: async () => [{ address: '10.1.2.3', family: 4 }]
  44. }),
  45. /blocked/i
  46. );
  47. const ok = await assertSafeWebhookUrl('https://hooks.example.com/hook', {
  48. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }]
  49. });
  50. assert.equal(ok.hostname, 'hooks.example.com');
  51. await assert.rejects(
  52. () => assertSafeWebhookUrl('http://127.0.0.1:9999/hook', { allowHttpLocal: false }),
  53. /https/i
  54. );
  55. const local = await assertSafeWebhookUrl('http://127.0.0.1:9999/hook', {
  56. allowHttpLocal: true
  57. });
  58. assert.equal(local.hostname, '127.0.0.1');
  59. });
  60. test('posts signed body and marks success on 2xx', async () => {
  61. initDatabase(tempDataDir(), 'test-secret');
  62. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  63. const webhook = createWebhook(alice.id, {
  64. name: 'Primary',
  65. url: 'https://hooks.example.com/mail',
  66. events: ['sent']
  67. });
  68. enqueueWebhookDeliveries({
  69. id: 11,
  70. userId: alice.id,
  71. domainId: null,
  72. status: 'sent',
  73. sender: 'noreply@example.com',
  74. recipients: ['user@example.com'],
  75. subject: 'Hello',
  76. detail: 'ok',
  77. queueId: 'Q11',
  78. deliveredAt: '2026-07-09T12:00:01.000Z'
  79. });
  80. const [before] = listWebhookDeliveries(alice.id);
  81. const fetchCalls = [];
  82. const fixedSeconds = 1_700_000_000;
  83. const result = await processWebhookBatch({
  84. batchSize: 5,
  85. nowSeconds: () => fixedSeconds,
  86. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
  87. fetchImpl: async (url, options) => {
  88. fetchCalls.push({ url, options });
  89. return {
  90. status: 204,
  91. text: async () => ''
  92. };
  93. }
  94. });
  95. assert.equal(result.claimed, 1);
  96. assert.equal(fetchCalls.length, 1);
  97. assert.equal(fetchCalls[0].url, 'https://hooks.example.com/mail');
  98. assert.equal(fetchCalls[0].options.method, 'POST');
  99. assert.equal(fetchCalls[0].options.redirect, 'manual');
  100. assert.equal(fetchCalls[0].options.headers['Content-Type'], 'application/json');
  101. assert.equal(fetchCalls[0].options.headers['User-Agent'], 'MailHub-Webhook/1.0');
  102. assert.equal(fetchCalls[0].options.headers['X-MailHub-Event'], 'email.sent');
  103. assert.equal(fetchCalls[0].options.headers['X-MailHub-Delivery'], `whd_${before.id}`);
  104. assert.equal(
  105. fetchCalls[0].options.headers['X-MailHub-Signature'],
  106. signWebhookBody(before.payloadJson, webhook.secret, fixedSeconds)
  107. );
  108. assert.equal(fetchCalls[0].options.body, before.payloadJson);
  109. const [after] = listWebhookDeliveries(alice.id);
  110. assert.equal(after.status, 'success');
  111. assert.equal(after.attemptCount, 1);
  112. assert.equal(after.responseStatus, 204);
  113. assert.equal(after.error, '');
  114. });
  115. test('schedules retry on 500', async () => {
  116. initDatabase(tempDataDir(), 'test-secret');
  117. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  118. createWebhook(alice.id, {
  119. name: 'Retry',
  120. url: 'https://hooks.example.com/retry',
  121. events: ['failed']
  122. });
  123. enqueueWebhookDeliveries({
  124. id: 12,
  125. userId: alice.id,
  126. domainId: null,
  127. status: 'failed',
  128. sender: 'noreply@example.com',
  129. recipients: ['user@example.com'],
  130. subject: 'Nope',
  131. detail: 'bounce',
  132. queueId: 'Q12'
  133. });
  134. await processWebhookBatch({
  135. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
  136. fetchImpl: async () => ({
  137. status: 500,
  138. text: async () => 'upstream error body'
  139. })
  140. });
  141. const [delivery] = listWebhookDeliveries(alice.id);
  142. assert.equal(delivery.status, 'pending');
  143. assert.equal(delivery.attemptCount, 1);
  144. assert.equal(delivery.responseStatus, 500);
  145. assert.match(delivery.error, /HTTP 500/);
  146. assert.match(delivery.responseBodyPreview, /upstream error/);
  147. assert.ok(Date.parse(delivery.nextAttemptAt) > Date.now());
  148. });
  149. test('marks dead after max attempts', async () => {
  150. initDatabase(tempDataDir(), 'test-secret');
  151. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  152. const webhook = createWebhook(alice.id, {
  153. name: 'Dead',
  154. url: 'https://hooks.example.com/dead',
  155. events: ['bounced']
  156. });
  157. enqueueWebhookDeliveries({
  158. id: 13,
  159. userId: alice.id,
  160. domainId: null,
  161. status: 'bounced',
  162. sender: 'noreply@example.com',
  163. recipients: ['user@example.com'],
  164. subject: 'Bounced',
  165. detail: '',
  166. queueId: 'Q13'
  167. });
  168. // Inject claim so retries are not blocked by future next_attempt_at backoff.
  169. for (let attempt = 0; attempt < MAX_WEBHOOK_ATTEMPTS; attempt += 1) {
  170. const delivery = listWebhookDeliveries(alice.id)[0];
  171. await processWebhookBatch({
  172. claim: () => [
  173. {
  174. delivery: { ...delivery, status: 'processing' },
  175. webhook: {
  176. id: webhook.id,
  177. url: webhook.url,
  178. secret: webhook.secret
  179. }
  180. }
  181. ],
  182. reap: () => 0,
  183. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
  184. fetchImpl: async () => ({
  185. status: 503,
  186. text: async () => 'down'
  187. })
  188. });
  189. }
  190. const dead = listWebhookDeliveries(alice.id)[0];
  191. assert.equal(dead.status, 'dead');
  192. assert.equal(dead.attemptCount, MAX_WEBHOOK_ATTEMPTS);
  193. });
  194. test('rejects private IP targets without calling fetch', async () => {
  195. initDatabase(tempDataDir(), 'test-secret');
  196. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  197. createWebhook(alice.id, {
  198. name: 'Internal',
  199. url: 'https://metadata.internal/hook',
  200. events: ['sent']
  201. });
  202. enqueueWebhookDeliveries({
  203. id: 14,
  204. userId: alice.id,
  205. domainId: null,
  206. status: 'sent',
  207. sender: 'noreply@example.com',
  208. recipients: ['user@example.com'],
  209. subject: 'SSRF',
  210. detail: '',
  211. queueId: 'Q14'
  212. });
  213. let fetchCalled = false;
  214. await processWebhookBatch({
  215. dnsLookup: async () => [{ address: '169.254.169.254', family: 4 }],
  216. fetchImpl: async () => {
  217. fetchCalled = true;
  218. return { status: 200, text: async () => 'ok' };
  219. }
  220. });
  221. assert.equal(fetchCalled, false);
  222. const [delivery] = listWebhookDeliveries(alice.id);
  223. assert.equal(delivery.status, 'pending');
  224. assert.equal(delivery.attemptCount, 1);
  225. assert.match(delivery.error, /blocked/i);
  226. assert.equal(claimWebhookDeliveries(5).length, 0);
  227. });
  228. test('startWebhookWorker can be skipped and stopped', async () => {
  229. assert.equal(startWebhookWorker({ enabled: false }), null);
  230. const handle = startWebhookWorker({
  231. enabled: true,
  232. intervalMs: 60_000,
  233. fetchImpl: async () => ({ status: 200, text: async () => '' })
  234. });
  235. assert.ok(handle);
  236. assert.equal(typeof handle.stop, 'function');
  237. stopWebhookWorker();
  238. stopWebhookWorker();
  239. });
  240. function tempDataDir() {
  241. return mkdtempSync(path.join(tmpdir(), 'mailhub-webhook-dispatcher-'));
  242. }