| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- 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-'));
- }
|