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
JavaScript
'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',
)
}