| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- import assert from 'node:assert/strict';
- import { mkdtempSync } from 'node:fs';
- import { tmpdir } from 'node:os';
- import path from 'node:path';
- import { Readable } from 'node:stream';
- import { test } from 'node:test';
- import {
- claimWebhookDeliveries,
- createUser,
- createWebhook,
- enqueueWebhookDeliveries,
- initDatabase,
- listWebhookDeliveries
- } from '../src/db.js';
- import {
- assertSafeWebhookUrl,
- buildPinnedWebhookUrl,
- isBlockedIpAddress,
- processWebhookBatch,
- resolveSafeWebhookTarget,
- 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('resolveSafeWebhookTarget returns pinned public address and rejects mixed private results', async () => {
- const target = await resolveSafeWebhookTarget('https://hooks.example.com/mail?x=1', {
- dnsLookup: async () => [
- { address: '1.1.1.1', family: 4 },
- { address: '8.8.8.8', family: 4 }
- ]
- });
- assert.equal(target.url.hostname, 'hooks.example.com');
- assert.deepEqual(target.addresses, ['1.1.1.1', '8.8.8.8']);
- assert.equal(target.pinnedAddress, '1.1.1.1');
- assert.equal(
- buildPinnedWebhookUrl(target.url, target.pinnedAddress).href,
- 'https://1.1.1.1/mail?x=1'
- );
- await assert.rejects(
- () =>
- resolveSafeWebhookTarget('https://hooks.example.com/hook', {
- dnsLookup: async () => [
- { address: '1.1.1.1', family: 4 },
- { address: '10.0.0.1', family: 4 }
- ]
- }),
- /blocked/i
- );
- });
- test('posts signed body to pinned IP with Host/SNI 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);
- // Connect by pinned IP (no second DNS); Host/SNI keep original hostname.
- assert.equal(fetchCalls[0].url, 'https://1.1.1.1/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.Host, 'hooks.example.com');
- assert.equal(fetchCalls[0].options.servername, 'hooks.example.com');
- assert.equal(fetchCalls[0].options.pinnedAddress, '1.1.1.1');
- 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 as permanent dead 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, 'dead');
- assert.equal(delivery.attemptCount, 1);
- assert.match(delivery.error, /blocked/i);
- assert.equal(claimWebhookDeliveries(5).length, 0);
- });
- test('missing webhook secret marks delivery permanently dead', async () => {
- initDatabase(tempDataDir(), 'test-secret');
- const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
- const webhook = createWebhook(alice.id, {
- name: 'NoSecret',
- url: 'https://hooks.example.com/no-secret',
- events: ['sent']
- });
- enqueueWebhookDeliveries({
- id: 15,
- userId: alice.id,
- domainId: null,
- status: 'sent',
- sender: 'noreply@example.com',
- recipients: ['user@example.com'],
- subject: 'Secret',
- detail: '',
- queueId: 'Q15'
- });
- const delivery = listWebhookDeliveries(alice.id)[0];
- await processWebhookBatch({
- claim: () => [
- {
- delivery: { ...delivery, status: 'processing' },
- webhook: {
- id: webhook.id,
- url: webhook.url,
- secret: ''
- }
- }
- ],
- reap: () => 0,
- fetchImpl: async () => {
- throw new Error('should not fetch');
- }
- });
- const [after] = listWebhookDeliveries(alice.id);
- assert.equal(after.status, 'dead');
- assert.equal(after.attemptCount, 1);
- assert.match(after.error, /missing url or secret/i);
- });
- test('bounds response body preview without consuming unbounded text()', async () => {
- initDatabase(tempDataDir(), 'test-secret');
- const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
- createWebhook(alice.id, {
- name: 'Body',
- url: 'https://hooks.example.com/body',
- events: ['sent']
- });
- enqueueWebhookDeliveries({
- id: 16,
- userId: alice.id,
- domainId: null,
- status: 'sent',
- sender: 'noreply@example.com',
- recipients: ['user@example.com'],
- subject: 'Body',
- detail: '',
- queueId: 'Q16'
- });
- const huge = 'x'.repeat(20_000);
- let textCalls = 0;
- await processWebhookBatch({
- dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }],
- fetchImpl: async () => ({
- status: 200,
- body: Readable.from([Buffer.from(huge)]),
- text: async () => {
- textCalls += 1;
- return huge;
- }
- })
- });
- assert.equal(textCalls, 0);
- const [delivery] = listWebhookDeliveries(alice.id);
- assert.equal(delivery.status, 'success');
- assert.ok(delivery.responseBodyPreview.length <= 2048);
- assert.ok(delivery.responseBodyPreview.length > 0);
- assert.match(delivery.responseBodyPreview, /^x+$/);
- });
- 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-'));
- }
|