webhook-dispatcher.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  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 { Readable } from 'node:stream';
  6. import { test } from 'node:test';
  7. import {
  8. claimWebhookDeliveries,
  9. createUser,
  10. createWebhook,
  11. enqueueWebhookDeliveries,
  12. initDatabase,
  13. listWebhookDeliveries
  14. } from '../src/db.js';
  15. import {
  16. assertSafeWebhookUrl,
  17. buildPinnedWebhookUrl,
  18. isBlockedIpAddress,
  19. processWebhookBatch,
  20. resolveSafeWebhookTarget,
  21. startWebhookWorker,
  22. stopWebhookWorker
  23. } from '../src/webhook-dispatcher.js';
  24. import { MAX_WEBHOOK_ATTEMPTS, signWebhookBody } from '../src/webhook-model.js';
  25. test('blocks private and loopback addresses', () => {
  26. assert.equal(isBlockedIpAddress('10.0.0.5'), true);
  27. assert.equal(isBlockedIpAddress('192.168.1.1'), true);
  28. assert.equal(isBlockedIpAddress('127.0.0.1'), true);
  29. assert.equal(isBlockedIpAddress('169.254.169.254'), true);
  30. assert.equal(isBlockedIpAddress('172.16.0.1'), true);
  31. assert.equal(isBlockedIpAddress('::1'), true);
  32. assert.equal(isBlockedIpAddress('fc00::1'), true);
  33. assert.equal(isBlockedIpAddress('fe80::1'), true);
  34. assert.equal(isBlockedIpAddress('::ffff:10.0.0.1'), true);
  35. assert.equal(isBlockedIpAddress('1.1.1.1'), false);
  36. assert.equal(isBlockedIpAddress('8.8.8.8'), false);
  37. });
  38. test('assertSafeWebhookUrl requires https and blocks private DNS results', async () => {
  39. await assert.rejects(
  40. () => assertSafeWebhookUrl('http://example.com/hook'),
  41. /https/i
  42. );
  43. await assert.rejects(
  44. () =>
  45. assertSafeWebhookUrl('https://hooks.example.com/hook', {
  46. dnsLookup: async () => [{ address: '10.1.2.3', family: 4 }]
  47. }),
  48. /blocked/i
  49. );
  50. const ok = await assertSafeWebhookUrl('https://hooks.example.com/hook', {
  51. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }]
  52. });
  53. assert.equal(ok.hostname, 'hooks.example.com');
  54. await assert.rejects(
  55. () => assertSafeWebhookUrl('http://127.0.0.1:9999/hook', { allowHttpLocal: false }),
  56. /https/i
  57. );
  58. const local = await assertSafeWebhookUrl('http://127.0.0.1:9999/hook', {
  59. allowHttpLocal: true
  60. });
  61. assert.equal(local.hostname, '127.0.0.1');
  62. });
  63. test('resolveSafeWebhookTarget returns pinned public address and rejects mixed private results', async () => {
  64. const target = await resolveSafeWebhookTarget('https://hooks.example.com/mail?x=1', {
  65. dnsLookup: async () => [
  66. { address: '1.1.1.1', family: 4 },
  67. { address: '8.8.8.8', family: 4 }
  68. ]
  69. });
  70. assert.equal(target.url.hostname, 'hooks.example.com');
  71. assert.deepEqual(target.addresses, ['1.1.1.1', '8.8.8.8']);
  72. assert.equal(target.pinnedAddress, '1.1.1.1');
  73. assert.equal(
  74. buildPinnedWebhookUrl(target.url, target.pinnedAddress).href,
  75. 'https://1.1.1.1/mail?x=1'
  76. );
  77. await assert.rejects(
  78. () =>
  79. resolveSafeWebhookTarget('https://hooks.example.com/hook', {
  80. dnsLookup: async () => [
  81. { address: '1.1.1.1', family: 4 },
  82. { address: '10.0.0.1', family: 4 }
  83. ]
  84. }),
  85. /blocked/i
  86. );
  87. });
  88. test('posts signed body to pinned IP with Host/SNI and marks success on 2xx', async () => {
  89. initDatabase(tempDataDir(), 'test-secret');
  90. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  91. const webhook = createWebhook(alice.id, {
  92. name: 'Primary',
  93. url: 'https://hooks.example.com/mail',
  94. events: ['sent']
  95. });
  96. enqueueWebhookDeliveries({
  97. id: 11,
  98. userId: alice.id,
  99. domainId: null,
  100. status: 'sent',
  101. sender: 'noreply@example.com',
  102. recipients: ['user@example.com'],
  103. subject: 'Hello',
  104. detail: 'ok',
  105. queueId: 'Q11',
  106. deliveredAt: '2026-07-09T12:00:01.000Z'
  107. });
  108. const [before] = listWebhookDeliveries(alice.id);
  109. const fetchCalls = [];
  110. const fixedSeconds = 1_700_000_000;
  111. const result = await processWebhookBatch({
  112. batchSize: 5,
  113. nowSeconds: () => fixedSeconds,
  114. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
  115. fetchImpl: async (url, options) => {
  116. fetchCalls.push({ url, options });
  117. return {
  118. status: 204,
  119. text: async () => ''
  120. };
  121. }
  122. });
  123. assert.equal(result.claimed, 1);
  124. assert.equal(fetchCalls.length, 1);
  125. // Connect by pinned IP (no second DNS); Host/SNI keep original hostname.
  126. assert.equal(fetchCalls[0].url, 'https://1.1.1.1/mail');
  127. assert.equal(fetchCalls[0].options.method, 'POST');
  128. assert.equal(fetchCalls[0].options.redirect, 'manual');
  129. assert.equal(fetchCalls[0].options.headers['Content-Type'], 'application/json');
  130. assert.equal(fetchCalls[0].options.headers['User-Agent'], 'MailHub-Webhook/1.0');
  131. assert.equal(fetchCalls[0].options.headers.Host, 'hooks.example.com');
  132. assert.equal(fetchCalls[0].options.servername, 'hooks.example.com');
  133. assert.equal(fetchCalls[0].options.pinnedAddress, '1.1.1.1');
  134. assert.equal(fetchCalls[0].options.headers['X-MailHub-Event'], 'email.sent');
  135. assert.equal(fetchCalls[0].options.headers['X-MailHub-Delivery'], `whd_${before.id}`);
  136. assert.equal(
  137. fetchCalls[0].options.headers['X-MailHub-Signature'],
  138. signWebhookBody(before.payloadJson, webhook.secret, fixedSeconds)
  139. );
  140. assert.equal(fetchCalls[0].options.body, before.payloadJson);
  141. const [after] = listWebhookDeliveries(alice.id);
  142. assert.equal(after.status, 'success');
  143. assert.equal(after.attemptCount, 1);
  144. assert.equal(after.responseStatus, 204);
  145. assert.equal(after.error, '');
  146. });
  147. test('schedules retry on 500', async () => {
  148. initDatabase(tempDataDir(), 'test-secret');
  149. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  150. createWebhook(alice.id, {
  151. name: 'Retry',
  152. url: 'https://hooks.example.com/retry',
  153. events: ['failed']
  154. });
  155. enqueueWebhookDeliveries({
  156. id: 12,
  157. userId: alice.id,
  158. domainId: null,
  159. status: 'failed',
  160. sender: 'noreply@example.com',
  161. recipients: ['user@example.com'],
  162. subject: 'Nope',
  163. detail: 'bounce',
  164. queueId: 'Q12'
  165. });
  166. await processWebhookBatch({
  167. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
  168. fetchImpl: async () => ({
  169. status: 500,
  170. text: async () => 'upstream error body'
  171. })
  172. });
  173. const [delivery] = listWebhookDeliveries(alice.id);
  174. assert.equal(delivery.status, 'pending');
  175. assert.equal(delivery.attemptCount, 1);
  176. assert.equal(delivery.responseStatus, 500);
  177. assert.match(delivery.error, /HTTP 500/);
  178. assert.match(delivery.responseBodyPreview, /upstream error/);
  179. assert.ok(Date.parse(delivery.nextAttemptAt) > Date.now());
  180. });
  181. test('marks dead after max attempts', async () => {
  182. initDatabase(tempDataDir(), 'test-secret');
  183. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  184. const webhook = createWebhook(alice.id, {
  185. name: 'Dead',
  186. url: 'https://hooks.example.com/dead',
  187. events: ['bounced']
  188. });
  189. enqueueWebhookDeliveries({
  190. id: 13,
  191. userId: alice.id,
  192. domainId: null,
  193. status: 'bounced',
  194. sender: 'noreply@example.com',
  195. recipients: ['user@example.com'],
  196. subject: 'Bounced',
  197. detail: '',
  198. queueId: 'Q13'
  199. });
  200. // Inject claim so retries are not blocked by future next_attempt_at backoff.
  201. for (let attempt = 0; attempt < MAX_WEBHOOK_ATTEMPTS; attempt += 1) {
  202. const delivery = listWebhookDeliveries(alice.id)[0];
  203. await processWebhookBatch({
  204. claim: () => [
  205. {
  206. delivery: { ...delivery, status: 'processing' },
  207. webhook: {
  208. id: webhook.id,
  209. url: webhook.url,
  210. secret: webhook.secret
  211. }
  212. }
  213. ],
  214. reap: () => 0,
  215. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
  216. fetchImpl: async () => ({
  217. status: 503,
  218. text: async () => 'down'
  219. })
  220. });
  221. }
  222. const dead = listWebhookDeliveries(alice.id)[0];
  223. assert.equal(dead.status, 'dead');
  224. assert.equal(dead.attemptCount, MAX_WEBHOOK_ATTEMPTS);
  225. });
  226. test('rejects private IP targets as permanent dead without calling fetch', async () => {
  227. initDatabase(tempDataDir(), 'test-secret');
  228. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  229. createWebhook(alice.id, {
  230. name: 'Internal',
  231. url: 'https://metadata.internal/hook',
  232. events: ['sent']
  233. });
  234. enqueueWebhookDeliveries({
  235. id: 14,
  236. userId: alice.id,
  237. domainId: null,
  238. status: 'sent',
  239. sender: 'noreply@example.com',
  240. recipients: ['user@example.com'],
  241. subject: 'SSRF',
  242. detail: '',
  243. queueId: 'Q14'
  244. });
  245. let fetchCalled = false;
  246. await processWebhookBatch({
  247. dnsLookup: async () => [{ address: '169.254.169.254', family: 4 }],
  248. fetchImpl: async () => {
  249. fetchCalled = true;
  250. return { status: 200, text: async () => 'ok' };
  251. }
  252. });
  253. assert.equal(fetchCalled, false);
  254. const [delivery] = listWebhookDeliveries(alice.id);
  255. assert.equal(delivery.status, 'dead');
  256. assert.equal(delivery.attemptCount, 1);
  257. assert.match(delivery.error, /blocked/i);
  258. assert.equal(claimWebhookDeliveries(5).length, 0);
  259. });
  260. test('missing webhook secret marks delivery permanently dead', async () => {
  261. initDatabase(tempDataDir(), 'test-secret');
  262. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  263. const webhook = createWebhook(alice.id, {
  264. name: 'NoSecret',
  265. url: 'https://hooks.example.com/no-secret',
  266. events: ['sent']
  267. });
  268. enqueueWebhookDeliveries({
  269. id: 15,
  270. userId: alice.id,
  271. domainId: null,
  272. status: 'sent',
  273. sender: 'noreply@example.com',
  274. recipients: ['user@example.com'],
  275. subject: 'Secret',
  276. detail: '',
  277. queueId: 'Q15'
  278. });
  279. const delivery = listWebhookDeliveries(alice.id)[0];
  280. await processWebhookBatch({
  281. claim: () => [
  282. {
  283. delivery: { ...delivery, status: 'processing' },
  284. webhook: {
  285. id: webhook.id,
  286. url: webhook.url,
  287. secret: ''
  288. }
  289. }
  290. ],
  291. reap: () => 0,
  292. fetchImpl: async () => {
  293. throw new Error('should not fetch');
  294. }
  295. });
  296. const [after] = listWebhookDeliveries(alice.id);
  297. assert.equal(after.status, 'dead');
  298. assert.equal(after.attemptCount, 1);
  299. assert.match(after.error, /missing url or secret/i);
  300. });
  301. test('bounds response body preview without consuming unbounded text()', async () => {
  302. initDatabase(tempDataDir(), 'test-secret');
  303. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  304. createWebhook(alice.id, {
  305. name: 'Body',
  306. url: 'https://hooks.example.com/body',
  307. events: ['sent']
  308. });
  309. enqueueWebhookDeliveries({
  310. id: 16,
  311. userId: alice.id,
  312. domainId: null,
  313. status: 'sent',
  314. sender: 'noreply@example.com',
  315. recipients: ['user@example.com'],
  316. subject: 'Body',
  317. detail: '',
  318. queueId: 'Q16'
  319. });
  320. const huge = 'x'.repeat(20_000);
  321. let textCalls = 0;
  322. await processWebhookBatch({
  323. dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
  324. fetchImpl: async () => ({
  325. status: 200,
  326. body: Readable.from([Buffer.from(huge)]),
  327. text: async () => {
  328. textCalls += 1;
  329. return huge;
  330. }
  331. })
  332. });
  333. assert.equal(textCalls, 0);
  334. const [delivery] = listWebhookDeliveries(alice.id);
  335. assert.equal(delivery.status, 'success');
  336. assert.ok(delivery.responseBodyPreview.length <= 2048);
  337. assert.ok(delivery.responseBodyPreview.length > 0);
  338. assert.match(delivery.responseBodyPreview, /^x+$/);
  339. });
  340. test('startWebhookWorker can be skipped and stopped', async () => {
  341. assert.equal(startWebhookWorker({ enabled: false }), null);
  342. const handle = startWebhookWorker({
  343. enabled: true,
  344. intervalMs: 60_000,
  345. fetchImpl: async () => ({ status: 200, text: async () => '' })
  346. });
  347. assert.ok(handle);
  348. assert.equal(typeof handle.stop, 'function');
  349. stopWebhookWorker();
  350. stopWebhookWorker();
  351. });
  352. function tempDataDir() {
  353. return mkdtempSync(path.join(tmpdir(), 'mailhub-webhook-dispatcher-'));
  354. }