UNPKG

haraka-plugin-mail_from.is_resolvable

Version:

Haraka plugin that checks that the domain used in MAIL FROM is resolvable to an MX record

152 lines (133 loc) 4.24 kB
'use strict' // Check MAIL FROM domain is resolvable to an MX const net = require('node:net') const DSN = require('haraka-dsn') const net_utils = require('haraka-net-utils') exports.register = function () { this.load_ini() } exports.load_ini = function () { this.cfg = this.config.get( 'mail_from.is_resolvable.ini', { booleans: ['-main.allow_mx_ip'], }, () => { this.load_ini() }, ) this.re_bogus_ip = new RegExp( this.cfg.main.re_bogus_ip || '^(?:0\\.0\\.0\\.0|255\\.255\\.255\\.255|127\\.)', ) const modes = { no: 'no', false: 'defer', defer: 'defer' } this.reject_no_mx = modes[this.cfg.reject.no_mx] || 'deny' // Soft cap on how long we'll wait for DNS per call. this.dns_timeout_ms = parseInt(this.cfg.main.timeout_ms, 10) || 5000 } // Soft timeout: rejects the await after `ms` ms to bound transaction latency function with_timeout(promise, ms, label) { let timer const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { const err = new Error(`${label} timed out after ${ms}ms`) err.code = 'ETIMEOUT' reject(err) }, ms) }) return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer), ) } exports.hook_mail = async function (next, connection, params) { const mail_from = params[0] const { results } = connection.transaction // ignore MAIL FROM without an @ if (!mail_from.host) { results.add(this, { skip: 'null host' }) return next() } const domain = mail_from.host connection.logdebug(this, `resolving MX for domain ${domain}`) let exchanges try { exchanges = await with_timeout( net_utils.get_mx(domain), this.dns_timeout_ms, 'MX lookup', ) } catch (err) { results.add(this, { err: err.message }) return next(DENYSOFT, `Temp. resolver error (${err.code})`) } connection.logdebug(this, `${domain}: MX => ${JSON.stringify(exchanges)}`) if (!exchanges || !exchanges.length) { results.add(this, { fail: 'has_fwd_dns', emit: true }) if (this.reject_no_mx === 'no') return next() else return next( this.reject_no_mx === 'deny' ? DENY : DENYSOFT, 'No MX for your FROM address', ) } // Null MX (RFC 7505) — domain explicitly sends no mail if ( exchanges.length === 1 && exchanges[0].priority === 0 && exchanges[0].exchange === '' ) { results.add(this, { fail: 'null_mx', emit: true }) if (this.reject_no_mx === 'no') return next() return next( DENY, DSN.sec_null_mx_sender(`Null MX: ${domain} does not send mail`), ) } if (this.cfg.main.allow_mx_ip) { for (const mx of exchanges) { if ( (net.isIPv4(mx.exchange) && !this.re_bogus_ip.test(mx.exchange)) || (net.isIPv6(mx.exchange) && !net_utils.ipv6_bogus(mx.exchange)) ) { results.add(this, { pass: 'implicit_mx', emit: true }) return next() } } } // filter out the implicit MX and resolve the remaining MX hostnames const mx_hostnames = exchanges.filter( (a) => a.exchange && !net.isIP(a.exchange), ) if (mx_hostnames.length) { try { const resolved = await with_timeout( net_utils.resolve_mx_hosts(mx_hostnames), this.dns_timeout_ms, 'MX-host resolution', ) connection.logdebug(this, `resolved MX => ${JSON.stringify(resolved)}`) if (resolved.length) { for (const mx of resolved) { if ( (net.isIPv4(mx.exchange) && !this.re_bogus_ip.test(mx.exchange)) || (net.isIPv6(mx.exchange) && !net_utils.ipv6_bogus(mx.exchange)) ) { results.add(this, { pass: 'has_fwd_dns', emit: true }) return next() } } } } catch (err) { // resolve_mx_hosts ignores errors so this is unlikely to happen results.add(this, { err: err.message }) return next(DENYSOFT, `Temp. resolver error (${err.code})`) } } results.add(this, { fail: 'has_fwd_dns', emit: true }) if (this.reject_no_mx === 'no') return next() else return next( this.reject_no_mx === 'deny' ? DENY : DENYSOFT, 'No valid MX for your FROM address', ) }