plugins/helo.checks.js
'use strict';
// Check various bits of the HELO string
const tlds = require('haraka-tld');
const dns = require('dns');
const net_utils = require('haraka-net-utils');
const utils = require('haraka-utils');
const checks = [
'match_re', // List of regexps
'bare_ip', // HELO is bare IP (vs required Address Literal)
'dynamic', // HELO hostname looks dynamic (dsl|dialup|etc...)
'big_company', // Well known HELOs that must match rdns
'valid_hostname', // HELO hostname is a legal DNS name
'rdns_match', // HELO hostname matches rDNS
'forward_dns', // HELO hostname resolves to the connecting IP
'host_mismatch', // hostname differs between invocations
];
exports.register = function () {
this.load_helo_checks_ini();
if (this.cfg.check.proto_mismatch) {
// NOTE: these *must* run before init
this.register_hook('helo', 'proto_mismatch_smtp');
this.register_hook('ehlo', 'proto_mismatch_esmtp');
}
// Always run init
this.register_hook('helo', 'init');
this.register_hook('ehlo', 'init');
for (const c of checks) {
if (!this.cfg.check[c]) continue; // disabled in config
this.register_hook('helo', c);
this.register_hook('ehlo', c);
}
// Always emit a log entry
this.register_hook('helo', 'emit_log');
this.register_hook('ehlo', 'emit_log');
// IP literal that doesn't match remote IP
this.register_hook('helo', 'literal_mismatch');
this.register_hook('ehlo', 'literal_mismatch');
this.cfg.check.literal_mismatch = this.cfg.check.literal_mismatch ?? 2;
this.cfg.reject.literal_mismatch = this.cfg.reject.literal_mismatch ?? false;
if (this.cfg.check.match_re) {
const load_re_file = () => {
const regex_list = utils.valid_regexes(this.config.get('helo.checks.regexps', 'list', load_re_file));
// pre-compile the regexes
this.cfg.list_re = new RegExp(`^(${regex_list.join('|')})$`, 'i');
};
load_re_file();
}
}
exports.load_helo_checks_ini = function () {
const booleans = [
'+skip.private_ip',
'+skip.whitelist',
'+skip.relaying',
'+check.proto_mismatch',
'-reject.proto_mismatch',
];
checks.forEach(c => {
booleans.push(`+check.${c}`);
booleans.push(`-reject.${c}`);
});
this.cfg = this.config.get('helo.checks.ini', { booleans },
() => {
this.load_helo_checks_ini();
});
// backwards compatible with old config file
if (this.cfg.check_no_dot !== undefined) {
this.cfg.check.valid_hostname = !!this.cfg.check_no_dot;
}
if (this.cfg.check_dynamic !== undefined) {
this.cfg.check.dynamic = !!this.cfg.check_dynamic;
}
if (this.cfg.check_raw_ip !== undefined) {
this.cfg.check.bare_ip = !!this.cfg.check_raw_ip;
}
// non-default setting, so apply their localized setting
if (this.cfg.check.mismatch !== undefined && !this.cfg.check.mismatch) {
this.logerror('deprecated setting mismatch renamed to host_mismatch');
this.cfg.check.host_mismatch = this.cfg.check.mismatch;
}
if (this.cfg.reject.mismatch !== undefined && this.cfg.reject.mismatch) {
this.logerror('deprecated setting mismatch renamed to host_mismatch');
this.cfg.reject.host_mismatch = this.cfg.reject.mismatch;
}
}
exports.init = function (next, connection, helo) {
if (!connection.results.has('helo.checks', 'helo_host', helo)) {
connection.results.add(this, {helo_host: helo});
}
next();
}
exports.should_skip = function (connection, test_name) {
if (connection.results.has('helo.checks', '_skip_hooks', test_name)) {
this.logdebug(connection, `SKIPPING: ${test_name}`);
return true;
}
connection.results.push(this, {_skip_hooks: test_name});
if (this.cfg.skip.relaying && connection.relaying) {
connection.results.add(this, {skip: `${test_name}(relay)`});
return true;
}
if (this.cfg.skip.private_ip && connection.remote.is_private) {
connection.results.add(this, {skip: `${test_name}(private)`});
return true;
}
return false;
}
exports.host_mismatch = function (next, connection, helo) {
if (this.should_skip(connection, 'host_mismatch')) return next();
const prev_helo = connection.results.get('helo.checks').helo_host;
if (!prev_helo) {
connection.results.add(this, {skip: 'host_mismatch(1st)'});
connection.notes.prev_helo = helo;
return next();
}
if (prev_helo === helo) {
connection.results.add(this, {pass: 'host_mismatch'});
return next();
}
const msg = `host_mismatch(${prev_helo} / ${helo})`;
connection.results.add(this, {fail: msg});
if (!this.cfg.reject.host_mismatch) return next();
next(DENY, `HELO host ${msg}`);
}
exports.valid_hostname = function (next, connection, helo) {
if (this.should_skip(connection, 'valid_hostname')) return next();
if (net_utils.is_ip_literal(helo)) {
connection.results.add(this, {skip: 'valid_hostname(literal)'});
return next();
}
if (!/\./.test(helo)) {
connection.results.add(this, {fail: 'valid_hostname(no_dot)'});
if (this.cfg.reject.valid_hostname) {
return next(DENY, 'HELO host must be a FQDN or address literal (RFC 5321 2.3.5)');
}
return next();
}
// this will fail if TLD is invalid or hostname is a public suffix
if (!tlds.get_organizational_domain(helo)) {
// Check for any excluded TLDs
const excludes = this.config.get('helo.checks.allow', 'list');
const tld = (helo.split(/\./).reverse())[0].toLowerCase();
// Exclude .local, .lan and .corp
if (tld === 'local' || tld === 'lan' || tld === 'corp' || excludes.includes(`.${tld}`)) {
return next();
}
connection.results.add(this, {fail: 'valid_hostname'});
if (this.cfg.reject.valid_hostname) {
return next(DENY, "HELO host name invalid");
}
return next();
}
connection.results.add(this, {pass: 'valid_hostname'});
next();
}
exports.match_re = function (next, connection, helo) {
if (this.should_skip(connection, 'match_re')) return next();
if (this.cfg.list_re?.test(helo)) {
connection.results.add(this, {fail: 'match_re'});
if (this.cfg.reject.match_re) {
return next(DENY, "That HELO not allowed here");
}
return next();
}
connection.results.add(this, {pass: 'match_re'});
next();
}
exports.rdns_match = function (next, connection, helo) {
if (this.should_skip(connection, 'rdns_match')) return next();
if (!helo) {
connection.results.add(this, {fail: 'rdns_match(empty)'});
return next();
}
if (net_utils.is_ip_literal(helo)) {
connection.results.add(this, {fail: 'rdns_match(literal)'});
return next();
}
const r_host = connection.remote.host;
if (r_host && helo === r_host) {
connection.results.add(this, {pass: 'rdns_match'});
return next();
}
if (tlds.get_organizational_domain(r_host) ===
tlds.get_organizational_domain(helo)) {
connection.results.add(this, {pass: 'rdns_match(org_dom)'});
return next();
}
connection.results.add(this, {fail: 'rdns_match'});
if (this.cfg.reject.rdns_match) {
return next(DENY, 'HELO host does not match rDNS');
}
next();
}
exports.bare_ip = function (next, connection, helo) {
if (this.should_skip(connection, 'bare_ip')) return next();
// RFC 2821, 4.1.1.1 Address literals must be in brackets
// RAW IPs must be formatted: "[1.2.3.4]" not "1.2.3.4" in HELO
if (net_utils.get_ipany_re('^(?:IPv6:)?','$','').test(helo)) {
connection.results.add(this, {fail: 'bare_ip(invalid literal)'});
if (this.cfg.reject.bare_ip) {
return next(DENY, "Invalid address format in HELO");
}
return next();
}
connection.results.add(this, {pass: 'bare_ip'});
next();
}
exports.dynamic = function (next, connection, helo) {
if (this.should_skip(connection, 'dynamic')) return next();
// Skip if no dots or an IP literal or address
if (!/\./.test(helo)) {
connection.results.add(this, {skip: 'dynamic(no dots)'});
return next();
}
if (net_utils.get_ipany_re('^\\[?(?:IPv6:)?','\\]?$','').test(helo)) {
connection.results.add(this, {skip: 'dynamic(literal)'});
return next();
}
if (net_utils.is_ip_in_str(connection.remote.ip, helo)) {
connection.results.add(this, {fail: 'dynamic'});
if (this.cfg.reject.dynamic) {
return next(DENY, 'HELO is dynamic');
}
return next();
}
connection.results.add(this, {pass: 'dynamic'});
next();
}
exports.big_company = function (next, connection, helo) {
if (this.should_skip(connection, 'big_company')) return next();
if (net_utils.is_ip_literal(helo)) {
connection.results.add(this, {skip: 'big_co(literal)'});
return next();
}
if (!this.cfg.bigco) {
connection.results.add(this, {err: 'big_co(config missing)'});
return next();
}
if (!this.cfg.bigco[helo]) {
connection.results.add(this, {pass: 'big_co(not)'});
return next();
}
const rdns = connection.remote.host;
if (!rdns || rdns === 'Unknown' || rdns === 'DNSERROR') {
connection.results.add(this, {fail: 'big_co(rDNS)'});
if (this.cfg.reject.big_company) {
return next(DENY, "Big company w/o rDNS? Unlikely.");
}
return next();
}
const allowed_rdns = this.cfg.bigco[helo].split(/,/);
for (const allow of allowed_rdns) {
const re = new RegExp(`${allow.replace(/\./g, '\\.')}$`);
if (re.test(rdns)) {
connection.results.add(this, {pass: 'big_co'});
return next();
}
}
connection.results.add(this, {fail: 'big_co'});
if (this.cfg.reject.big_company) {
return next(DENY, "You are not who you say you are");
}
next();
}
exports.literal_mismatch = function (next, connection, helo) {
if (this.should_skip(connection, 'literal_mismatch')) return next();
const literal = net_utils.get_ipany_re('^\\[(?:IPv6:)?','\\]$','').exec(helo);
if (!literal) {
connection.results.add(this, {pass: 'literal_mismatch'});
return next();
}
const lmm_mode = parseInt(this.cfg.check.literal_mismatch, 10);
const helo_ip = literal[1];
if (lmm_mode > 2 && net_utils.is_private_ip(helo_ip)) {
connection.results.add(this, {pass: 'literal_mismatch(private)'});
return next();
}
if (lmm_mode > 1) {
if (net_utils.same_ipv4_network(connection.remote.ip, [helo_ip])) {
connection.results.add(this, {pass: 'literal_mismatch'});
return next();
}
connection.results.add(this, {fail: 'literal_mismatch'});
if (this.cfg.reject.literal_mismatch) {
return next(DENY, 'HELO IP literal not in the same /24 as your IP address');
}
return next();
}
if (helo_ip === connection.remote.ip) {
connection.results.add(this, {pass: 'literal_mismatch'});
return next();
}
connection.results.add(this, {fail: 'literal_mismatch'});
if (this.cfg.reject.literal_mismatch) {
return next(DENY, 'HELO IP literal does not match your IP address');
}
next();
}
exports.forward_dns = function (next, connection, helo) {
if (this.should_skip(connection, 'forward_dns')) return next();
if (!this.cfg.check.valid_hostname) {
connection.results.add(this, {err: 'forward_dns(valid_hostname disabled)'});
return next();
}
if (net_utils.is_ip_literal(helo)) {
connection.results.add(this, {skip: 'forward_dns(literal)'});
return next();
}
if (!connection.results.has('helo.checks', 'pass', /^valid_hostname/)) {
connection.results.add(this, {fail: 'forward_dns(invalid_hostname)'});
if (this.cfg.reject.forward_dns) {
return next(DENY, "Invalid HELO host cannot achieve forward DNS match");
}
return next();
}
this.get_a_records(helo)
.then(ips => {
if (!ips) {
connection.results.add(this, {err: 'forward_dns, no ips!'});
return next();
}
connection.results.add(this, {ips});
if (ips.includes(connection.remote.ip)) {
connection.results.add(this, {pass: 'forward_dns'});
return next();
}
// some valid hosts (facebook.com, hotmail.com) use a generic HELO
// hostname that resolves but doesn't contain the IP that is
// connecting. If their rDNS passed, and their HELO hostname is in
// the same domain, consider it close enough.
if (connection.results.has('helo.checks', 'pass', /^rdns_match/)) {
const helo_od = tlds.get_organizational_domain(helo);
const rdns_od = tlds.get_organizational_domain(connection.remote.host);
if (helo_od && helo_od === rdns_od) {
connection.results.add(this, {pass: 'forward_dns(domain)'});
return next();
}
connection.results.add(this, {msg: `od miss: ${helo_od}, ${rdns_od}`});
}
connection.results.add(this, {fail: 'forward_dns(no IP match)'});
if (this.cfg.reject.forward_dns) {
return next(DENY, "HELO host has no forward DNS match");
}
next();
})
.catch(err => {
if (err.code === dns.NOTFOUND || err.code === dns.NODATA || err.code === dns.SERVFAIL) {
connection.results.add(this, {fail: `forward_dns(${err.code})`});
return next();
}
if (err.code === dns.TIMEOUT && this.cfg.reject.forward_dns) {
connection.results.add(this, {fail: `forward_dns(${err.code})`});
return next(DENYSOFT, "DNS timeout resolving your HELO hostname");
}
connection.results.add(this, {err: `forward_dns(${err})`, emit_log_level: 'warn'});
next();
})
}
exports.proto_mismatch = function (next, connection, helo, proto) {
if (this.should_skip(connection, 'proto_mismatch')) return next();
const r = connection.results.get('helo.checks');
if (!r || (r && !r.helo_host)) return next();
if ((connection.esmtp && proto === 'smtp') ||
(!connection.esmtp && proto === 'esmtp')) {
connection.results.add(this, {fail: `proto_mismatch(${proto})`});
if (this.cfg.reject.proto_mismatch) {
return next(DENY, `${proto === 'smtp' ? 'HELO' : 'EHLO'} protocol mismatch`);
}
}
next();
}
exports.proto_mismatch_smtp = function (next, connection, helo) {
this.proto_mismatch(next, connection, helo, 'smtp');
}
exports.proto_mismatch_esmtp = function (next, connection, helo) {
this.proto_mismatch(next, connection, helo, 'esmtp');
}
exports.emit_log = function (next, connection, helo) {
// Spits out an INFO log entry. Default looks like this:
// [helo.checks] helo_host: [182.212.17.35], fail:big_co(rDNS) rdns_match(literal), pass:valid_hostname, match_re, bare_ip, literal_mismatch, mismatch, skip:dynamic(literal), valid_hostname(literal)
//
// Although sometimes useful, that's a bit verbose. I find that I'm rarely
// interested in the passes, the helo_host is already logged elsewhere,
// and so I set this in config/results.ini:
//
// [helo.checks]
// order=fail,pass,msg,err,skip
// hide=helo_host,multi,pass
//
// Thus set, my log entries look like this:
//
// [UUID] [helo.checks] fail:rdns_match
// [UUID] [helo.checks]
// [UUID] [helo.checks] fail:dynamic
connection.loginfo(this, connection.results.collate(this));
next();
}
exports.get_a_records = async function (host) {
if (!/\./.test(host)) {
// a single label is not a host name
const e = new Error("invalid hostname");
e.code = dns.NOTFOUND;
throw e;
}
// Set-up timer
let timed_out = false;
const timer = setTimeout(() => {
timed_out = true;
const err = new Error(`timeout resolving: ${host}`);
err.code = dns.TIMEOUT;
this.logerror(err);
throw err;
}, (this.cfg.main.dns_timeout || 30) * 1000);
// fully qualify, to ignore any search options in /etc/resolv.conf
if (!/\.$/.test(host)) host = `${host}.`;
// do the queries
let ips
let err = '';
try {
ips = await net_utils.get_ips_by_host(host)
}
catch (errs) {
for (const error of errs) {
switch (error.code) {
case dns.NODATA:
case dns.NOTFOUND:
case dns.SERVFAIL:
continue;
default:
err = `${err}, ${error.message}`;
}
}
}
// results is now equals to: {queryA: 1, queryAAAA: 2}
if (timed_out) return;
if (timer) clearTimeout(timer);
if (!ips.length && err) throw err
// this.logdebug(this, host + ' => ' + ips);
// return the DNS results
return ips;
};