|
@@ -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;
|
|
|
|
|
+}
|