diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 035a400..4c1bf2a 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -5,3 +5,6 @@ env: es2022: true extends: ['@haraka'] + +rules: + no-unused-vars: 1 diff --git a/README.md b/README.md index d63c81e..b9c1f87 100644 --- a/README.md +++ b/README.md @@ -3,71 +3,153 @@ [![NPM][npm-img]][npm-url] -# haraka-plugin-template +# haraka-plugin-helo.checks -Clone me, to create a new Haraka plugin! +This plugin performs a number of checks on the HELO string. -## Template Instructions +HELO strings are very often forged or dubious in spam and so this can be a +highly effective and false-positive free anti-spam measure. -These instructions will not self-destruct after use. Use and destroy. +## Usage -See also, [How to Write a Plugin](https://github.com/haraka/Haraka/wiki/Write-a-Plugin) and [Plugins.md](https://github.com/haraka/Haraka/blob/master/docs/Plugins.md) for additional plugin writing information. +helo.checks results can be accessed by subsequent plugins: -## Create a new repo for your plugin +```js +const h = connection.results.get('helo.checks'); +if (h.pass && h.pass.length > 5) { + // nice job, you passed 6+ tests +} +if (h.fail && h.fail.length > 3) { + // yikes, you failed 4+ tests! +} +if (connection.results.has('helo.checks','pass', /^forward_dns/) { + // the HELO hostname is valid +} +``` -Haraka plugins are named like `haraka-plugin-something`. All the namespace after `haraka-plugin-` is yours for the taking. Please check the [Plugins](https://github.com/haraka/Haraka/blob/master/Plugins.md) page and a Google search to see what plugins already exist. +## Configuration -Once you've settled on a name, create the GitHub repo. On the [template repo's main page](https://github.com/haraka/haraka-plugin-template), click the _Use this template_ button and create your new repository. Then paste that URL into a local ENV variable with a command like this: +- helo.checks.regexps -```sh -export MY_GITHUB_ORG=haraka -export MY_PLUGIN_NAME=haraka-plugin-SOMETHING -``` + List of regular expressions to match against the HELO string. The regular + expressions are automatically wrapped in `^` and `$` so they always match + the entire string. -Clone and rename the template repo: +- helo.checks.ini ```sh -git clone git@github.com:haraka/$MY_PLUGIN_NAME.git -cd $MY_PLUGIN_NAME +cp node_modules/haraka-plugin-helo.checks/config/helo.checks.ini config/helo.checks.ini +$EDITOR config/helo.checks.ini ``` -Now you'll have a local git repo to begin authoring your plugin +INI file which controls enabling of certain checks: -## rename boilerplate +- dns_timeout=30 -Replaces all uses of the word `template` with your plugin's name. +How many seconds to wait for DNS queries to timeout. -./redress.sh [something] +### [check] -You'll then be prompted to update package.json and then force push this repo onto the GitHub repo you've created earlier. +- valid_hostname=true -# Add your content here + Checks that the HELO has at least one '.' in it and the organizational + name is possible (ie, a host within a Public Suffix). -## INSTALL +- bare_ip=true -```sh -cd /path/to/local/haraka -npm install haraka-plugin-template -echo "template" >> config/plugins -service haraka restart -``` + Checks for HELO where the IP is not surrounded by square brackets. + This is an RFC violation so should always be enabled. -### Configuration +- dynamic=true -If the default configuration is not sufficient, copy the config file from the distribution into your haraka config dir and then modify it: + Checks to see if all or part the connecting IP address appears within + the HELO argument to indicate that the client has a dynamic IP address. -```sh -cp node_modules/haraka-plugin-template/config/template.ini config/template.ini -$EDITOR config/template.ini +- literal_mismatch=1|2|3 + + Checks to see if the IP literal used matches the connecting IP address. + If set to 1, the full IP must match. If set to 2, the /24 must match. + If set to 3, the /24 may match, or the IP can be private (RFC 1918). + +- match_re=true + + See above. This is merely an on/off toggle. + +- big_company=true + + See below. This is merely an on/off toggle. + +- forward_dns=true + + Perform a DNS lookup of the HELO hostname and validate that the IP of + the remote is included in the IP(s) of the HELO hostname. + + This test requires that the valid_hostname check is also enabled. + +- rdns_match=true + + Sees if the HELO hostname (or at least the domain) match the rDNS + hostname(s). + +- host_mismatch=true + + If HELO is called multiple times, checks if the hostname differs between + EHLO invocations. + +- proto_mismatch=true + + If EHLO was sent and the host later tries to then send HELO or vice-versa. + +### [reject] + +For all of the checks included above, a matching key in the reject section +controls whether messages that fail the test are rejected. + +Defaults shown: + +```ini +[reject] +host_mismatch=false +literal_mismatch=false +proto_mismatch=false +rdns_match=false +dynamic=false +bare_ip=false +valid_hostname=false +forward_dns=false +big_company=false ``` -## USAGE +### [skip] + +- private_ip=true + + Bypasses checks for clients within RFC1918, Loopback or APIPA IP address ranges. + +- relaying + + Bypass checks for clients who have relaying privileges (whitelisted IP, + SMTP-AUTH, etc). + +### [bigco] + +A list of =[,...] to match against. If the HELO matches +what's on the left hand side, the reverse-DNS must match one of the +entries on the right hand side or the mail is blocked. + +Example: + +```ini +yahoo.com=yahoo.com,yahoo.co.jp +aol.com=aol.com +gmail.com=google.com +``` -[ci-img]: https://github.com/haraka/haraka-plugin-template/actions/workflows/ci.yml/badge.svg -[ci-url]: https://github.com/haraka/haraka-plugin-template/actions/workflows/ci.yml -[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-template/badges/gpa.svg -[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-template -[npm-img]: https://nodei.co/npm/haraka-plugin-template.png -[npm-url]: https://www.npmjs.com/package/haraka-plugin-template +[ci-img]: https://github.com/haraka/haraka-plugin-helo.checks/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-helo.checks/actions/workflows/ci.yml +[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-helo.checks/badges/gpa.svg +[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-helo.checks +[npm-img]: https://nodei.co/npm/haraka-plugin-helo.checks.png +[npm-url]: https://www.npmjs.com/package/haraka-plugin-helo.checks diff --git a/config/helo.checks.ini b/config/helo.checks.ini new file mode 100644 index 0000000..7da5080 --- /dev/null +++ b/config/helo.checks.ini @@ -0,0 +1,52 @@ +; disable checks or reject for each test if you are worried about strictness + +;dns_timeout=28 + +[check] +; match_re=true +; bare_ip=true +; dynamic=true +; big_company=true +; literal_mismatch: 1 = exact IP match, 2 = IP/24 match, 3 = /24 or RFC1918 +; literal_mismatch=2 +; valid_hostname=true +; forward_dns=true +; rdns_match=true +; host_mismatch: hostname differs between EHLO invocations +; host_mismatch=true +; proto_mismatch: host sent EHLO but then tries to sent HELO or vice-versa +; proto_mismatch=true + +[reject] +; host_mismatch=true +; proto_mismatch=false +; rdns_match=false +; dynamic=false +; bare_ip=false +; literal_mismatch=false +; valid_hostname=false +; forward_dns=false +; big_company=true + +[skip] +; private_ip=true +; relaying=true +; whitelist=true ; TODO + +[bigco] +msn.com=msn.com +hotmail.com=hotmail.com +yahoo.com=yahoo.com,yahoo.co.jp +yahoo.co.jp=yahoo.com,yahoo.co.jp +yahoo.co.uk=yahoo.co.uk +excite.com=excite.com,excitenetwork.com +mailexcite.com=excite.com,excitenetwork.com +yahoo.co.jp=yahoo.com,yahoo.co.jp +mailexcite.com=excite.com,excitenetwork.com +aol.com=aol.com +compuserve.com=compuserve.com,adelphia.net +nortelnetworks.com=nortelnetworks.com,nortel.com +earthlink.net=earthlink.net +earthling.net=earthling.net +google.com=google.com +gmail.com=google.com,gmail.com diff --git a/config/template.ini b/config/template.ini deleted file mode 100644 index 2a92888..0000000 --- a/config/template.ini +++ /dev/null @@ -1,2 +0,0 @@ - -[main] diff --git a/index.js b/index.js index e4bd2f5..fb284fc 100644 --- a/index.js +++ b/index.js @@ -1,24 +1,560 @@ 'use strict' +// Check various bits of the HELO string +const dns = require('node:dns') + +const tlds = require('haraka-tld') +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 + // literal_mismatch +] exports.register = function () { - this.load_template_ini() + 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) + } + + // IP literal that doesn't match remote IP + this.register_hook('helo', 'literal_mismatch') + this.register_hook('ehlo', 'literal_mismatch') - // register hooks here. More info at https://haraka.github.io/core/Plugins/ - // this.register_hook('data_post', 'do_stuff_with_message') + // Always emit a log entry + this.register_hook('helo', 'emit_log') + this.register_hook('ehlo', 'emit_log') + + 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_template_ini = function () { - this.cfg = this.config.get( - 'template.ini', - { - booleans: [ - '+enabled', // this.cfg.main.enabled=true - '-disabled', // this.cfg.main.disabled=false - '+feature_section.yes', // this.cfg.feature_section.yes=true - ], - }, +exports.load_helo_checks_ini = function () { + const booleans = [ + '+skip.private_ip', + '+skip.whitelist', + '+skip.relaying', + + '-reject.literal_mismatch', + '+check.proto_mismatch', + '-reject.proto_mismatch', + ] + + for (const c of checks) { + booleans.push(`+check.${c}`) + booleans.push(`-reject.${c}`) + } + + this.cfg = this.config.get('helo.checks.ini', { booleans }, () => { + this.load_helo_checks_ini() + }) + + this.cfg.check.literal_mismatch = this.cfg.check.literal_mismatch ?? 2 + + // 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 === undefined + ) { + 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.hello.host && + 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 === true) { + 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 === true) { + 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( () => { - this.load_template_ini() + timed_out = true + const err = new Error(`timeout resolving: ${host}`) + err.code = dns.TIMEOUT + this.logerror(err) + throw err }, + (this.cfg.main.dns_timeout || 28) * 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 } diff --git a/package.json b/package.json index 6cf83d7..2ffdbff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "haraka-plugin-template", - "version": "1.0.7", - "description": "Haraka plugin that...CHANGE THIS", + "name": "haraka-plugin-helo.checks", + "version": "1.0.0", + "description": "Haraka plugin checks the HELO string.", "main": "index.js", "files": [ "CHANGELOG.md", @@ -19,20 +19,25 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/haraka/haraka-plugin-template.git" + "url": "git+https://github.com/haraka/haraka-plugin-helo.checks.git" }, "keywords": [ "haraka-plugin", - "template" + "helo.checks" ], - "author": "Welcome Member ", + "author": "Haraka Team ", "license": "MIT", "bugs": { - "url": "https://github.com/haraka/haraka-plugin-template/issues" + "url": "https://github.com/haraka/haraka-plugin-helo.checks/issues" }, - "homepage": "https://github.com/haraka/haraka-plugin-template#readme", + "homepage": "https://github.com/haraka/haraka-plugin-helo.checks#readme", "devDependencies": { "@haraka/eslint-config": "1.1.3", "haraka-test-fixtures": "1.3.5" + }, + "dependencies": { + "haraka-net-utils": "^1.7.0", + "haraka-tld": "^1.2.1", + "haraka-utils": "^1.1.3" } } diff --git a/redress.sh b/redress.sh deleted file mode 100755 index df370f2..0000000 --- a/redress.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh - -if [ -z "$1" ]; then - echo "$0 something" - exit -fi - -sed -i '' \ - -e "s/template/${1}/g" \ - README.md - -sed -i '' \ - -e "s/template/${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - test/index.js - -sed -i '' \ - -e "s/template/${1}/g" \ - package.json - -sed -i '' \ - -e "s/_template/_${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - index.js - -tee Changes.md < { + this.plugin = new fixtures.plugin('helo.checks') + this.plugin.config.root_path = path.resolve('test', 'config') -beforeEach(() => { - this.plugin = new fixtures.plugin('template') -}) + this.connection = fixtures.connection.createConnection() + this.connection.remote.ip = '208.75.199.19' + + this.plugin.register() + + done() +} + +describe('helo.checks', () => { + beforeEach(_set_up) -describe('template', () => { - it('loads', () => { - assert.ok(this.plugin) + it('init is always run', () => { + assert.equal(this.plugin.register_hook.args[2][0], 'helo') + assert.equal(this.plugin.register_hook.args[2][1], 'init') + assert.equal(this.plugin.register_hook.args[3][0], 'ehlo') + assert.equal(this.plugin.register_hook.args[3][1], 'init') }) -}) -describe('load_template_ini', () => { - it('loads template.ini from config/template.ini', () => { - this.plugin.load_template_ini() - assert.ok(this.plugin.cfg) + it('hooks are registered', () => { + assert.equal(this.plugin.register_hook.args.length, 24) }) - it('initializes enabled boolean', () => { - this.plugin.load_template_ini() - assert.equal(this.plugin.cfg.main.enabled, true, this.plugin.cfg) + it('default config is loaded', () => { + assert.deepEqual(this.plugin.cfg, { + main: {}, + skip: { private_ip: true, whitelist: true, relaying: true }, + reject: { + proto_mismatch: false, + match_re: false, + bare_ip: false, + dynamic: false, + big_company: false, + valid_hostname: false, + rdns_match: false, + forward_dns: false, + host_mismatch: false, + literal_mismatch: false, + }, + bigco: { + 'msn.com': 'msn.com', + 'hotmail.com': 'hotmail.com', + 'yahoo.com': 'yahoo.com,yahoo.co.jp', + 'yahoo.co.jp': 'yahoo.com,yahoo.co.jp', + 'yahoo.co.uk': 'yahoo.co.uk', + 'excite.com': 'excite.com,excitenetwork.com', + 'mailexcite.com': 'excite.com,excitenetwork.com', + 'aol.com': 'aol.com', + 'compuserve.com': 'compuserve.com,adelphia.net', + 'nortelnetworks.com': 'nortelnetworks.com,nortel.com', + 'earthlink.net': 'earthlink.net', + 'earthling.net': 'earthling.net', + 'google.com': 'google.com', + 'gmail.com': 'google.com,gmail.com', + }, + check: { + proto_mismatch: true, + match_re: true, + bare_ip: true, + dynamic: true, + big_company: true, + valid_hostname: true, + rdns_match: true, + forward_dns: true, + host_mismatch: true, + literal_mismatch: 2, + }, + list_re: /^()$/i, + }) }) -}) -describe('uses text fixtures', () => { - it('sets up a connection', () => { - this.connection = fixtures.connection.createConnection({}) - assert.ok(this.connection.server) + describe('host_mismatch', () => { + beforeEach(_set_up) + + it('host_mismatch, reject=false', (t, done) => { + this.plugin.init(() => {}, this.connection, 'helo.example.com') + this.plugin.cfg.check.host_mismatch = true + this.plugin.cfg.reject.host_mismatch = false + this.plugin.host_mismatch( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + 'anything', + ) + }) + + it('host_mismatch, reject=true', (t, done) => { + this.plugin.init(() => {}, this.connection, 'helo.example.com') + this.plugin.cfg.check.host_mismatch = true + this.plugin.cfg.reject.host_mismatch = true + this.plugin.host_mismatch( + (rc) => { + assert.equal(rc, DENY) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + 'anything', + ) + }) + }) + + describe('proto_mismatch', () => { + beforeEach(_set_up) + + it('proto_mismatch, reject=false, esmtp=false', (t, done) => { + this.plugin.init(() => {}, this.connection, 'helo.example.com') + this.connection.esmtp = false + this.plugin.cfg.check.proto_mismatch = true + this.plugin.cfg.reject.proto_mismatch = false + this.plugin.proto_mismatch( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + 'anything', + 'esmtp', + ) + }) + + it('proto_mismatch, reject=false, esmtp=true', (t, done) => { + this.plugin.init(() => {}, this.connection, 'helo.example.com') + this.connection.esmtp = true + this.plugin.cfg.check.proto_mismatch = true + this.plugin.cfg.reject.proto_mismatch = false + this.plugin.proto_mismatch( + (rc) => { + assert.equal(rc, undefined) + assert.ok( + this.connection.results.get('helo.checks').fail.length === 0, + ) + done() + }, + this.connection, + 'anything', + 'esmtp', + ) + }) + + it('proto_mismatch, reject=true', (t, done) => { + this.plugin.init(() => {}, this.connection, 'helo.example.com') + this.connection.esmtp = false + this.plugin.cfg.check.proto_mismatch = true + this.plugin.cfg.reject.proto_mismatch = true + this.plugin.proto_mismatch( + (rc) => { + assert.equal(rc, DENY) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + 'anything', + 'esmtp', + ) + }) + }) + + describe('rdns_match', () => { + beforeEach(_set_up) + + it('pass', (t, done) => { + this.connection.remote.host = 'helo.example.com' + this.plugin.cfg.check.rdns_match = true + this.plugin.cfg.reject.rdns_match = true + this.plugin.rdns_match( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + 'helo.example.com', + ) + }) + + it('pass (org dom match)', (t, done) => { + this.connection.remote.host = 'ehlo.example.com' + this.plugin.cfg.check.rdns_match = true + this.plugin.cfg.reject.rdns_match = false + this.plugin.rdns_match( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + 'helo.example.com', + ) + }) + + it('fail', (t, done) => { + this.connection.remote.host = 'ehlo.gmail.com' + this.plugin.cfg.check.rdns_match = true + this.plugin.cfg.reject.rdns_match = false + this.plugin.rdns_match( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + 'helo.example.com', + ) + }) + + it('fail, reject', (t, done) => { + this.connection.remote.host = 'ehlo.gmail.com' + this.plugin.cfg.check.rdns_match = true + this.plugin.cfg.reject.rdns_match = true + this.plugin.rdns_match( + (rc) => { + assert.equal(rc, DENY) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + 'helo.example.com', + ) + }) + }) + + describe('bare_ip', () => { + beforeEach(_set_up) + + it('pass', (t, done) => { + this.plugin.cfg.check.bare_ip = true + this.plugin.cfg.reject.bare_ip = true + this.plugin.bare_ip( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + '[192.168.1.2]', + ) + }) + it('fail', (t, done) => { + this.plugin.cfg.check.bare_ip = true + this.plugin.cfg.reject.bare_ip = false + this.plugin.bare_ip( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + '192.168.1.1', + ) + }) + it('fail, reject', (t, done) => { + this.plugin.cfg.check.bare_ip = true + this.plugin.cfg.reject.bare_ip = true + this.plugin.bare_ip( + (rc) => { + assert.equal(rc, DENY) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + '192.168.1.1', + ) + }) + }) + + describe('dynamic', () => { + beforeEach(_set_up) + + it('pass', (t, done) => { + const test_helo = 'matt.simerson.tld' + this.connection.remote.ip = '208.75.177.99' + this.plugin.cfg.check.dynamic = true + this.plugin.cfg.reject.dynamic = true + this.plugin.dynamic( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail', (t, done) => { + const test_helo = 'c-76-121-96-159.hsd1.wa.comcast.net' + this.connection.remote.ip = '76.121.96.159' + this.plugin.cfg.check.dynamic = true + this.plugin.cfg.reject.dynamic = false + this.plugin.dynamic( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject', (t, done) => { + const test_helo = 'c-76-121-96-159.hsd1.wa.comcast.net' + this.connection.remote.ip = '76.121.96.159' + this.plugin.cfg.check.dynamic = true + this.plugin.cfg.reject.dynamic = true + this.plugin.dynamic( + (rc) => { + assert.equal(rc, DENY) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + }) + + describe('big_company', () => { + beforeEach(_set_up) + + it('pass, reject=false', (t, done) => { + const test_helo = 'yahoo.com' + this.connection.remote.host = 'yahoo.com' + this.plugin.cfg.check.big_company = true + this.plugin.cfg.reject.big_company = true + this.plugin.big_company( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=false', (t, done) => { + const test_helo = 'yahoo.com' + this.connection.remote.host = 'anything-else.com' + this.connection.remote.is_private = false + this.plugin.cfg.check.big_company = true + this.plugin.cfg.reject.big_company = false + this.plugin.big_company( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=true', (t, done) => { + const test_helo = 'yahoo.com' + this.connection.remote.host = 'anything-else.com' + this.plugin.cfg.check.big_company = true + this.plugin.cfg.reject.big_company = true + this.plugin.big_company( + (rc) => { + assert.equal(rc, DENY) + assert.equal( + this.connection.results.get('helo.checks').fail.length, + 1, + ) + this.plugin.big_company( + (rc) => { + assert.equal(rc, DENY) + assert.ok( + this.connection.results.get('helo.checks').fail.length, + 2, + ) + done() + }, + this.connection, + test_helo, + ) + }, + this.connection, + test_helo, + ) + }) + }) + + describe('literal_mismatch', () => { + beforeEach(_set_up) + + it('pass', (t, done) => { + const test_helo = '[10.0.1.1]' + this.connection.remote.ip = '10.0.1.1' + this.connection.remote.is_private = true + this.plugin.cfg.check.literal_mismatch = 1 + this.plugin.cfg.reject.literal_mismatch = true + this.plugin.literal_mismatch( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').skip.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('pass, network', (t, done) => { + const test_helo = '[10.0.1.1]' + this.connection.remote.ip = '10.0.1.2' + this.connection.remote.is_private = true + this.plugin.cfg.check.literal_mismatch = 2 + this.plugin.cfg.reject.literal_mismatch = true + this.plugin.literal_mismatch( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').skip.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=false', (t, done) => { + const test_helo = '[10.0.1.1]' + this.connection.remote.ip = '10.0.1.2' + this.connection.remote.is_private = true + this.plugin.cfg.check.literal_mismatch = 0 + this.plugin.cfg.reject.literal_mismatch = false + this.plugin.literal_mismatch( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').skip.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=true', (t, done) => { + const test_helo = '[10.0.1.1]' + this.connection.remote.ip = '10.0.1.2' + this.connection.remote.is_private = true + this.plugin.cfg.check.literal_mismatch = 0 + this.plugin.cfg.reject.literal_mismatch = true + this.plugin.literal_mismatch( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').skip.length) + done() + }, + this.connection, + test_helo, + ) + }) + }) + + describe('valid_hostname', () => { + beforeEach(_set_up) + + it('pass', (t, done) => { + const test_helo = 'great.domain.com' + this.plugin.cfg.check.valid_hostname = true + this.plugin.cfg.reject.valid_hostname = true + this.plugin.valid_hostname( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=false', (t, done) => { + const test_helo = 'great.domain.non-existent-tld' + this.plugin.cfg.check.valid_hostname = true + this.plugin.cfg.reject.valid_hostname = false + this.plugin.valid_hostname( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=true', (t, done) => { + const test_helo = 'great.domain.non-existent-tld' + this.plugin.cfg.check.valid_hostname = true + this.plugin.cfg.reject.valid_hostname = true + this.plugin.valid_hostname( + (rc) => { + assert.equal(rc, DENY) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) }) - it('sets up a transaction', () => { - this.connection = fixtures.connection.createConnection({}) - this.connection.init_transaction() - assert.ok(this.connection.transaction.header) + describe('forward_dns', () => { + beforeEach(_set_up) + + it('pass', (t, done) => { + const test_helo = 'b.resolvers.level3.net' + this.connection.remote.ip = '4.2.2.2' + this.plugin.cfg.check.forward_dns = true + this.plugin.cfg.reject.forward_dns = true + this.connection.results.add(this.plugin, { pass: 'valid_hostname' }) + this.plugin.forward_dns( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=false', (t, done) => { + const test_helo = 'www.google.com' + this.connection.remote.ip = '66.128.51.163' + this.plugin.cfg.check.forward_dns = true + this.plugin.cfg.reject.forward_dns = false + this.plugin.forward_dns( + (rc) => { + assert.equal(rc, undefined) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('fail, reject=true', (t, done) => { + const test_helo = 'www.google.com' + this.connection.remote.ip = '66.128.51.163' + this.plugin.cfg.check.forward_dns = true + this.plugin.cfg.reject.forward_dns = true + this.plugin.forward_dns( + (rc) => { + assert.equal(rc, DENY) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + }) + + describe('match_re', () => { + beforeEach(_set_up) + + it('miss', (t, done) => { + const test_helo = 'not_in_re_list.net' + this.plugin.cfg.list_re = new RegExp(`^(${['bad.tld'].join('|')})$`, 'i') + this.plugin.match_re( + (rc, msg) => { + assert.equal(undefined, rc) + assert.equal(undefined, msg) + assert.ok(this.connection.results.get('helo.checks').pass.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('hit, reject=no', (t, done) => { + const test_helo = 'ylmf-pc' + this.plugin.cfg.reject.match_re = false + this.plugin.cfg.list_re = new RegExp(`^(${['ylmf-pc'].join('|')})$`, 'i') + this.plugin.match_re( + (rc, msg) => { + assert.equal(undefined, rc) + assert.equal(undefined, msg) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('hit, reject=yes, exact', (t, done) => { + const test_helo = 'ylmf-pc' + this.plugin.cfg.reject.match_re = true + this.plugin.cfg.list_re = new RegExp(`^(${['ylmf-pc'].join('|')})$`, 'i') + this.plugin.match_re( + (rc, msg) => { + assert.equal(DENY, rc) + assert.equal('That HELO not allowed here', msg) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) + + it('hit, reject=yes, pattern', (t, done) => { + const test_helo = 'ylmf-pc' + this.plugin.cfg.reject.match_re = true + this.plugin.cfg.list_re = new RegExp(`^(${['ylm.*'].join('|')})$`, 'i') + this.plugin.match_re( + (rc, msg) => { + assert.equal(DENY, rc) + assert.equal('That HELO not allowed here', msg) + assert.ok(this.connection.results.get('helo.checks').fail.length) + done() + }, + this.connection, + test_helo, + ) + }) }) })