diff --git a/Changes.md b/Changes.md index 07a5d0d..23cd765 100644 --- a/Changes.md +++ b/Changes.md @@ -1,3 +1,6 @@ + + + ## 1.0.0 - 2022-07-23 -- Initial release +- Import from Haraka diff --git a/README.md b/README.md index 35752c8..8bc9c9c 100644 --- a/README.md +++ b/README.md @@ -4,67 +4,143 @@ # haraka-plugin-spf -Clone me, to create a new Haraka plugin! +=== + +This plugin implements RFC 4408 Sender Policy Framework (SPF) +See the [Wikipedia article on SPF](http://en.wikipedia.org/wiki/Sender_Policy_Framework) for details. + +By default this plugin with only add trace Received-SPF headers to a message. +To make it reject mail then you will need to enable the relevant options below. +`[deny]helo_fail` and `[deny]mfrom_fail` are the closest match for the intent +of SPF but you will need to whitelist any hosts forwarding mail from another +domain whilst preserving the original return-path. + +Configuration +------------- + +This plugin uses spf.ini for configuration and the following options are +available: -# Template Instructions + [relay] + context=sender (default: sender) -These instructions will not self-destruct after use. Use and destroy. +On connections with relaying privileges (MSA or mail relay), it is often +desirable to evaluate SPF from the context of Haraka's public IP(s), in the +same fashion the next mail server will evaluate it when we send to them. +In that use case, Haraka should use context=myself. -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. + * context=sender evaluate SPF based on the sender (connection.remote.ip) + * context=myself evaluate SPF based on Haraka's public IP -## Create a new repo for your plugin +The rest of the optional settings (disabled by default) permit deferring or +denying mail from senders whose SPF fails the checks. -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. +Additional settings allow you to control the small things (defaults are shown): -Once you've settled on a name, create the GitHub repo. On the repo's main page, click the _Clone or download_ button and copy the URL. Then paste that URL into a local ENV variable with a command like this: + ; The lookup timeout, in seconds. Better set it to something much lower than this. + lookup_timeout = 29 -```sh -export MY_GITHUB_ORG=haraka -export MY_PLUGIN_NAME=haraka-plugin-SOMETHING -``` + ; bypass hosts that match these conditions + [skip] + ; hosts that relay through us + relaying = false + ; hosts that are SMTP AUTH'ed + auth = false -Clone and rename the spf repo: +There's a special setting that would allow the plugin to emit a funny explanation text on SPF DENY, essentially meant to be visible to end-users that will receive the bounce. The text is `http://www.openspf.org/Why?s=${scope}&id=${sender_id}&ip=${connection.remote.ip}` and is enabled by: -```sh -git clone git@github.com:haraka/haraka-plugin-spf.git -mv haraka-plugin-spf $MY_PLUGIN_NAME -cd $MY_PLUGIN_NAME -git remote rm origin -git remote add origin "git@github.com:$MY_GITHUB_ORG/$MY_PLUGIN_NAME.git" -``` + [deny] + openspf_text = true + + ; in case you DENY on failing SPF on hosts that are relaying (but why?) + [deny_relay] + openspf_text = true -Now you'll have a local git repo to begin authoring your plugin +### Things to Know -## rename boilerplate +* Most senders do not publish SPF records for their mail server *hostname*, + which means that the SPF HELO test rarely passes. During observation in 2014, + more spam senders have valid SPF HELO than ham senders. If you expect very + little from SPF HELO validation, you might still be disappointed. -Replaces all uses of the word `spf` with your plugin's name. +* Enabling error deferrals will cause excessive delays and perhaps bounced + mail for senders with broken DNS. Enable this only if you are willing to + delay and sometimes lose valid mail. -./redress.sh [something] +* Broken SPF records by valid senders are common. Keep that in mind when + considering denial of SPF error results. If you deny on error, budget + time for instructing senders on how to correct their SPF records so they + can email you. -You'll then be prompted to update package.json and then force push this repo onto the GitHub repo you've created earlier. +* The only deny option most sites should consider is `mfrom_fail`. That will + reject messages that explicitely fail SPF tests. SPF failures have a high + correlation with spam. However, up to 10% of ham transits forwarders and/or + email lists which frequently break SPF. SPF results are best used as inputs + to other plugins such as DMARC, [spamassassin](http://haraka.github.io/manual/plugins/spamassassin.html), and [karma](http://haraka.github.io/manual/plugins/karma.html). +* Heed well the implications of SPF, as described in [RFC 4408](http://tools.ietf.org/html/rfc4408#section-9.3) -# Add your content here + [defer] + helo_temperror + mfrom_temperror -## INSTALL + [deny] + helo_none + helo_softfail + helo_fail + helo_permerror -```sh -cd /path/to/local/haraka -npm install haraka-plugin-spf -echo "spf" >> config/plugins -service haraka restart -``` + mfrom_none + mfrom_softfail + mfrom_fail + mfrom_permerror + + openspf_text -### Configuration + ; SPF settings used when connection.relaying=true + [defer_relay] + helo_temperror + mfrom_temperror -If the default configuration is not sufficient, copy the config file from the distribution into your haraka config dir and then modify it: + [deny_relay] + helo_none + helo_softfail + helo_fail + helo_permerror -```sh -cp node_modules/haraka-plugin-spf/config/spf.ini config/spf.ini -$EDITOR config/spf.ini -``` + mfrom_none + mfrom_softfail + mfrom_fail + mfrom_permerror + + openspf_text -## USAGE + +Testing +------- + +This plugin also provides a command-line test tool that can be used to debug SPF issues or to check results. + +To check the SPF record for a domain: + +```` +# spf --ip 1.2.3.4 --domain fsl.com +ip=1.2.3.4 helo="" domain="fsl.com" result=Fail +```` + +To check the SPF record for a HELO/EHLO name: + +```` +# spf --ip 1.2.3.4 --helo foo.bar.com +ip=1.2.3.4 helo="foo.bar.com" domain="" result=None +```` + +You can add `--debug` to the option arguments to see a full trace of the SPF processing. + +### SPF Resource Record Type + +Node does not support the SPF DNS Resource Record type. Only TXT records are +checked. This is a non-issue as < 1% (as of 2014) of SPF records use the SPF RR type. Due to lack of adoption, SPF has deprecated the SPF RR type. diff --git a/config/spf.ini b/config/spf.ini index 2a92888..d536b9e 100644 --- a/config/spf.ini +++ b/config/spf.ini @@ -1,2 +1 @@ - -[main] +; See 'haraka -h spf' for options diff --git a/index.js b/index.js index dc91b9f..58ff4c1 100644 --- a/index.js +++ b/index.js @@ -1,20 +1,327 @@ -'use strict' +// spf + +const SPF = require('./spf').SPF; +const net_utils = require('haraka-net-utils'); +const DSN = require('haraka-dsn'); exports.register = function () { - this.load_spf_ini() + const plugin = this; + + // Override logging in SPF module + SPF.prototype.log_debug = str => plugin.logdebug(str); + + plugin.load_spf_ini(); + + plugin.register_hook('helo', 'helo_spf'); + plugin.register_hook('ehlo', 'helo_spf'); } exports.load_spf_ini = function () { - const plugin = this + const plugin = this; + plugin.nu = net_utils; // so tests can set public_ip + plugin.SPF = SPF; plugin.cfg = plugin.config.get('spf.ini', { booleans: [ - '+enabled', // plugin.cfg.main.enabled=true - '-disabled', // plugin.cfg.main.disabled=false - '+feature_section.yes' // plugin.cfg.feature_section.yes=true + '-defer.helo_temperror', + '-defer.mfrom_temperror', + + '-defer_relay.helo_temperror', + '-defer_relay.mfrom_temperror', + + '-deny.helo_none', + '-deny.helo_softfail', + '-deny.helo_fail', + '-deny.helo_permerror', + '-deny.openspf_text', + + '-deny.mfrom_none', + '-deny.mfrom_softfail', + '-deny.mfrom_fail', + '-deny.mfrom_permerror', + + '-deny_relay.helo_none', + '-deny_relay.helo_softfail', + '-deny_relay.helo_fail', + '-deny_relay.helo_permerror', + + '-deny_relay.mfrom_none', + '-deny_relay.mfrom_softfail', + '-deny_relay.mfrom_fail', + '-deny_relay.mfrom_permerror', + '-deny_relay.openspf_text', + + '-skip.relaying', + '-skip.auth', ] }, - function () { - plugin.load_example_ini() - }) + () => { plugin.load_spf_ini(); } + ); + + // when set, preserve legacy config settings + ['helo','mail'].forEach(phase => { + if (plugin.cfg.main[`${phase}_softfail_reject`]) { + plugin.cfg.deny[`${phase}_softfail`] = true; + } + if (plugin.cfg.main[`${phase}_fail_reject`]) { + plugin.cfg.deny[`${phase}_fail`] = true; + } + if (plugin.cfg.main[`${phase}_temperror_defer`]) { + plugin.cfg.defer[`${phase}_temperror`] = true; + } + if (plugin.cfg.main[`${phase}_permerror_reject`]) { + plugin.cfg.deny[`${phase}_permerror`] = true; + } + }); + + if (!plugin.cfg.relay) { + plugin.cfg.relay = { context: 'sender' }; // default/legacy + } + + plugin.cfg.lookup_timeout = plugin.cfg.main.lookup_timeout || plugin.timeout - 1; +} + +exports.helo_spf = function (next, connection, helo) { + const plugin = this; + + // bypass auth'ed or relay'ing hosts if told to + const skip_reason = exports.skip_hosts(connection); + if (skip_reason) { + connection.results.add(plugin, {skip: `helo(${skip_reason})`}); + return next(); + } + + // Bypass private IPs + if (connection.remote.is_private) { + connection.results.add(plugin, {skip: 'helo(private_ip)'}); + return next(); + } + + // RFC 4408, 2.1: "SPF clients must be prepared for the "HELO" + // identity to be malformed or an IP address literal. + if (net_utils.is_ip_literal(helo)) { + connection.results.add(plugin, {skip: 'helo(ip_literal)'}); + return next(); + } + + // avoid 2nd EHLO evaluation if EHLO host is identical + const results = connection.results.get(plugin); + if (results && results.domain === helo) return next(); + + let timeout = false; + const spf = new SPF(); + const timer = setTimeout(() => { + timeout = true; + connection.loginfo(plugin, 'timeout'); + return next(); + }, plugin.cfg.lookup_timeout * 1000); + + spf.check_host(connection.remote.ip, helo, null, (err, result) => { + if (timer) clearTimeout(timer); + if (timeout) return; + if (err) { + connection.logerror(plugin, err); + return next(); + } + const host = connection.hello.host; + plugin.log_result(connection, 'helo', host, `postmaster@${host}`, spf.result(result)); + + connection.notes.spf_helo = result; // used between hooks + connection.results.add(plugin, { + scope: 'helo', + result: spf.result(result), + domain: host, + emit: true, + }); + if (spf.result(result) === 'Pass') connection.results.add(plugin, { pass: host }); + next(); + }); +} + +exports.hook_mail = function (next, connection, params) { + const plugin = this; + + const txn = connection?.transaction; + if (!txn) return next(); + + // bypass auth'ed or relay'ing hosts if told to + const skip_reason = exports.skip_hosts(connection); + if (skip_reason) { + txn.results.add(plugin, {skip: `host(${skip_reason})`}); + return next(CONT, `skipped because host(${skip_reason})`); + } + + // For messages from private IP space... + if (connection.remote?.is_private) { + if (!connection.relaying) return next(); + if (connection.relaying && plugin.cfg.relay?.context !== 'myself') { + txn.results.add(plugin, {skip: 'host(private_ip)'}); + return next(CONT, 'envelope from private IP space'); + } + } + + const mfrom = params[0].address(); + const host = params[0].host; + let spf = new SPF(); + let auth_result; + + if (connection.notes?.spf_helo) { + const h_result = connection.notes.spf_helo; + const h_host = connection.hello?.host; + plugin.save_to_header(connection, spf, h_result, mfrom, h_host, 'helo'); + if (!host) { // Use results from HELO if the return-path is null + auth_result = spf.result(h_result).toLowerCase(); + connection.auth_results(`spf=${auth_result} smtp.helo=${h_host}`); + + const sender = `<> via ${h_host}`; + return plugin.return_results(next, connection, spf, 'helo', h_result, sender); + } + } + + if (!host) return next(); // null-sender + + let timeout = false; + const timer = setTimeout(() => { + timeout = true; + connection.loginfo(plugin, 'timeout'); + next(); + }, plugin.cfg.lookup_timeout * 1000); + + spf.helo = connection.hello?.host; + + function ch_cb (err, result, ip) { + if (timer) clearTimeout(timer); + if (timeout) return; + if (err) { + connection.logerror(plugin, err); + return next(); + } + plugin.log_result(connection, 'mfrom', host, mfrom, spf.result(result), (ip ? ip : connection.remote.ip)); + plugin.save_to_header(connection, spf, result, mfrom, host, 'mailfrom', (ip ? ip : connection.remote.ip)); + + auth_result = spf.result(result).toLowerCase(); + connection.auth_results(`spf=${auth_result} smtp.mailfrom=${host}`); + + txn.notes.spf_mail_result = spf.result(result); + txn.notes.spf_mail_record = spf.spf_record; + txn.results.add(plugin, { + scope: 'mfrom', + result: spf.result(result), + domain: host, + emit: true, + }); + if (spf.result(result) === 'Pass') connection.results.add(plugin, { pass: host }); + plugin.return_results(next, connection, spf, 'mfrom', result, mfrom); + } + + // typical inbound (!relay) + if (!connection.relaying) { + return spf.check_host(connection.remote.ip, host, mfrom, ch_cb); + } + + // outbound (relaying), context=sender + if (plugin.cfg.relay.context === 'sender') { + return spf.check_host(connection.remote.ip, host, mfrom, ch_cb); + } + + // outbound (relaying), context=myself + net_utils.get_public_ip((e, my_public_ip) => { + // We always check the client IP first, because a relay + // could be sending inbound mail from a non-local domain + // which could case an incorrect SPF Fail result if we + // check the public IP first, so we only check the public + // IP if the client IP returns a result other than 'Pass'. + spf.check_host(connection.remote.ip, host, mfrom, (err, result) => { + let spf_result; + if (result) { + spf_result = spf.result(result).toLowerCase(); + } + if (err || (spf_result && spf_result !== 'pass')) { + if (e) return ch_cb(e); // Error looking up public IP + + if (!my_public_ip) { + return ch_cb(new Error(`failed to discover public IP`)); + } + spf = new SPF(); + spf.check_host(my_public_ip, host, mfrom, (er, r) => { + ch_cb(er, r, my_public_ip); + }); + return; + } + ch_cb(err, result, connection.remote.ip); + }); + }); +} + +exports.log_result = function (connection, scope, host, mfrom, result, ip) { + const show_ip=ip ? ip : connection.remote.ip; + connection.loginfo(this, `identity=${scope} ip=${show_ip} domain="${host}" mfrom=<${mfrom}> result=${result}`); +} + +exports.return_results = function (next, connection, spf, scope, result, sender) { + const plugin = this; + const msgpre = (scope === 'helo') ? `sender ${sender}` : `sender <${sender}>`; + const deny = connection.relaying ? 'deny_relay' : 'deny'; + const defer = connection.relaying ? 'defer_relay' : 'defer'; + const sender_id = (scope === 'helo') ? connection.hello_host : sender; + let text = DSN.sec_unauthorized(`http://www.openspf.org/Why?s=${scope}&id=${sender_id}&ip=${connection.remote.ip}`); + + switch (result) { + case spf.SPF_NONE: + if (plugin.cfg[deny][`${scope}_none`]) { + text = plugin.cfg[deny].openspf_text ? text : `${msgpre} SPF record not found`; + return next(DENY, text); + } + return next(); + case spf.SPF_NEUTRAL: + case spf.SPF_PASS: + return next(); + case spf.SPF_SOFTFAIL: + if (plugin.cfg[deny][`${scope}_softfail`]) { + text = plugin.cfg[deny].openspf_text ? text : `${msgpre} SPF SoftFail`; + return next(DENY, text); + } + return next(); + case spf.SPF_FAIL: + if (plugin.cfg[deny][`${scope}_fail`]) { + text = plugin.cfg[deny].openspf_text ? text : `${msgpre} SPF Fail`; + return next(DENY, text); + } + return next(); + case spf.SPF_TEMPERROR: + if (plugin.cfg[defer][`${scope}_temperror`]) { + return next(DENYSOFT, `${msgpre} SPF Temporary Error`); + } + return next(); + case spf.SPF_PERMERROR: + if (plugin.cfg[deny][`${scope}_permerror`]) { + return next(DENY, `${msgpre} SPF Permanent Error`); + } + return next(); + default: + // Unknown result + connection.logerror(plugin, `unknown result code=${result}`); + return next(); + } +} + +exports.save_to_header = (connection, spf, result, mfrom, host, id, ip) => { + // Add a trace header + if (!connection?.transaction) return; + + const des = result === spf.SPF_PASS ? 'designates' : 'does not designate'; + const identity = `identity=${id}; client-ip=${ip ? ip : connection.remote.ip}`; + connection.transaction.add_leading_header('Received-SPF', + `${spf.result(result)} (${connection.local.host}: domain of ${host} ${des} ${connection.remote.ip} as permitted sender) receiver=${connection.local.host}; ${identity} helo=${connection.hello.host}; envelope-from=<${mfrom}>` + ); +} + +exports.skip_hosts = function (connection) { + const plugin = this; + + const skip = plugin.cfg.skip; + if (skip) { + if (skip.relaying && connection.relaying) return 'relay'; + if (skip.auth && connection.notes.auth_user) return 'auth'; + } } diff --git a/package.json b/package.json index 5fc46f0..15f9f3f 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "haraka-plugin-spf", - "version": "1.0.3", - "description": "Haraka plugin that frobnicates email connections", + "version": "1.0.0", + "description": "Sender Policy Framework (SPF) plugin for Haraka", "main": "index.js", "scripts": { - "lint": "npx eslint *.js test/*.js", - "lintfix": "npx eslint --fix *.js test/*.js", + "lint": "npx eslint *.js test", + "lintfix": "npx eslint --fix *.js test", "versions": "npx dependency-version-checker check", "test": "npx mocha" }, @@ -18,7 +18,7 @@ "plugin", "spf" ], - "author": "Welcome Member ", + "author": "Haraka Team ", "license": "MIT", "bugs": { "url": "https://github.com/haraka/haraka-plugin-spf/issues" @@ -29,5 +29,10 @@ "eslint-plugin-haraka": "*", "haraka-test-fixtures": "*", "mocha": "9" + }, + "dependencies": { + "haraka-dsn": "^1.0.4", + "haraka-net-utils": "^1.4.1", + "ipaddr.js": "^2.0.1" } } diff --git a/redress.sh b/redress.sh deleted file mode 100755 index ebcf134..0000000 --- a/redress.sh +++ /dev/null @@ -1,39 +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 < replace.length) ? replace.length : strip); + replace = replace.slice(0,strip); + } + if (reverse) replace = replace.reverse(); + replace = replace.join('.'); + } + str = str.replace(match[0], replace); + } + } + // Process any other expansions + str = str.replace(/%%/g, '%'); + str = str.replace(/%_/g, ' '); + str = str.replace(/%-/g, '%20'); + return str; + } + + log_debug (str) { + console.error(str); + } + + valid_ip (ip) { + const ip_split = /^:([^/ ]+)(?:\/([^ ]+))?$/.exec(ip); + if (!ip_split) { + this.log_debug(`invalid IP address: ${ip}`); + return false; + } + if (!ipaddr.isValid(ip_split[1])) { + this.log_debug(`invalid IP address: ${ip_split[1]}`); + return false; + } + return true; + } + + check_host (ip, domain, mail_from, cb) { + const self = this; + domain = domain.toLowerCase(); + if (mail_from) { + mail_from = mail_from.toLowerCase(); + } + else { + mail_from = `postmaster@${domain}`; + } + this.ipaddr = ipaddr.parse(ip); + this.ip_ver = this.ipaddr.kind(); + if (this.ip_ver === 'ipv6') { + this.ip = this.ipaddr.toString(); + } + else { + this.ip = ip; + } + this.domain = domain; + this.mail_from = mail_from; + + this.log_debug(`ip=${ip} domain=${domain} mail_from=${mail_from}`); + + // Get the SPF record for domain + dns.resolveTxt(domain, (err, txt_rrs) => { + if (err) { + self.log_debug(`error looking up TXT record: ${err.message}`); + switch (err.code) { + case dns.NOTFOUND: + case dns.NODATA: + case dns.NXDOMAIN: return cb(null, self.SPF_NONE); + default: return cb(null, self.SPF_TEMPERROR); + } + } + + let spf_record; + let match; + for (let txt_rr of txt_rrs) { + // Node 0.11.x compatibility + if (Array.isArray(txt_rr)) txt_rr = txt_rr.join(''); + + match = /^(v=spf1(?:$|\s.+$))/i.exec(txt_rr); + if (!match) { + self.log_debug(`discarding TXT record: ${txt_rr}`); + continue; + } + + if (!spf_record) { + self.log_debug(`found SPF record for domain ${domain}: ${match[1]}`); + spf_record = match[1].replace(/\s+/, ' ').toLowerCase(); + } + else { + // already found an MX record + self.log_debug(`found additional SPF record for domain ${domain}: ${match[1]}`); + return cb(null, self.SPF_PERMERROR); + } + } + + if (!spf_record) return cb(null, self.SPF_NONE); // No SPF record? + + // Store the SPF record used in the object + self.spf_record = spf_record; + + // Validate SPF record and build call chain + const mech_array = []; + const mod_array = []; + const mech_regexp1 = /^([-+~?])?(all|a|mx|ptr)$/; + const mech_regexp2 = /^([-+~?])?(a|mx|ptr|ip4|ip6|include|exists)((?::[^/ ]+(?:\/\d+(?:\/\/\d+)?)?)|\/\d+(?:\/\/\d+)?)$/; + const mod_regexp = /^([^ =]+)=([a-z0-9:/._-]+)$/; + const split = spf_record.split(' '); + for (let i=1; i self.LIMIT) { + self.log_debug('lookup limit reached'); + return cb(null, self.SPF_PERMERROR); + } + // Return any result that is not SPF_NONE + if (result && result !== self.SPF_NONE) { + return cb(err2, result); + } + if (!mod_array.length) { + return cb(null, self.SPF_NEUTRAL); + } + const next_in_chain = mod_array.shift(); + const func = Object.keys(next_in_chain); + const args = next_in_chain[func]; + self.log_debug(`running modifier: ${func} args=${args} domain=${self.domain}`); + self[`mod_${func}`](args, mod_chain_caller); + } + + // Run all the mechanisms first + function mech_chain_caller (err3, result) { + // Throw any errors + if (err3) throw err3; + // Check limits + if (self.count > self.LIMIT) { + self.log_debug('lookup limit reached'); + return cb(null, self.SPF_PERMERROR); + } + // If we have a result other than SPF_NONE + if (result && result !== self.SPF_NONE) { + return cb(err3, result); + } + // Return default if no more mechanisms to run + if (!mech_array.length) { + // Now run any modifiers + if (mod_array.length) { + return mod_chain_caller(); + } + else { + return cb(null, self.SPF_NEUTRAL); + } + } + const next_in_chain = mech_array.shift(); + const func = Object.keys(next_in_chain); + const args = next_in_chain[func]; + self.log_debug(`running mechanism: ${func} args=${args} domain=${self.domain}`); + self[`mech_${func}`](((args && args.length) ? args[0] : null), ((args && args.length) ? args[1] : null), mech_chain_caller); + } + // Start the chain + mech_chain_caller(); + }); + } + + mech_all (qualifier, args, cb) { + return cb(null, this.return_const(qualifier)); + } + + mech_include (qualifier, args, cb) { + const self = this; + const domain = args.substr(1); + // Avoid circular references + if (self.been_there[domain]) { + self.log_debug(`circular reference detected: ${domain}`); + return cb(null, self.SPF_NONE); + } + self.count++; + self.been_there[domain] = true; + // Recurse + const recurse = new SPF(self.count, self.been_there); + recurse.check_host(self.ip, domain, self.mail_from, (err, result) => { + if (!err) { + self.log_debug(`mech_include: domain=${domain} returned=${self.const_translate(result)}`); + switch (result) { + case self.SPF_PASS: return cb(null, self.SPF_PASS); + case self.SPF_FAIL: + case self.SPF_SOFTFAIL: + case self.SPF_NEUTRAL: return cb(null, self.SPF_NONE); + case self.SPF_TEMPERROR: return cb(null, self.SPF_TEMPERROR); + default: return cb(null, self.SPF_PERMERROR); + } + } + }); + } + + mech_exists (qualifier, args, cb) { + const self = this; + self.count++; + const exists = args.substr(1); + dns.resolve(exists, (err, addrs) => { + if (err) { + self.log_debug(`mech_exists: ${err}`); + switch (err.code) { + case dns.NOTFOUND: + case dns.NODATA: + case dns.NXDOMAIN: + return cb(null, self.SPF_NONE); + default: + return cb(null, self.SPF_TEMPERROR); + } + } + self.log_debug(`mech_exists: ${exists} result=${addrs.join(',')}`); + return cb(null, self.return_const(qualifier)); + }); + } + + mech_a (qualifier, args, cb) { + const self = this; + self.count++; + // Parse any arguments + let cm; + let cidr4; + let cidr6; + if (args && (cm = /\/(\d+)(?:\/\/(\d+))?$/.exec(args))) { + cidr4 = cm[1]; + cidr6 = cm[2]; + } + let dm; + let domain = self.domain; + if (args && (dm = /^:([^/ ]+)/.exec(args))) { + domain = dm[1]; + } + // Calculate with IP method to use + let resolve_method; + let cidr; + if (self.ip_ver === 'ipv4') { + cidr = cidr4; + resolve_method = 'resolve4'; + } + else if (self.ip_ver === 'ipv6') { + cidr = cidr6; + resolve_method = 'resolve6'; + } + // Use current domain + dns[resolve_method](domain, (err, addrs) => { + if (err) { + self.log_debug(`mech_a: ${err}`); + switch (err.code) { + case dns.NOTFOUND: + case dns.NODATA: + case dns.NXDOMAIN: return cb(null, self.SPF_NONE); + default: return cb(null, self.SPF_TEMPERROR); + } + } + for (let a=0; a ${addrs[a]}/${cidr}: MATCH!`); + return cb(null, self.return_const(qualifier)); + } + else { + self.log_debug(`mech_a: ${self.ip} => ${addrs[a]}/${cidr}: NO MATCH`); + } + } + else { + if (addrs[a] === self.ip) { + return cb(null, self.return_const(qualifier)); + } + else { + self.log_debug(`mech_a: ${self.ip} => ${addrs[a]}: NO MATCH`); + } + } + } + return cb(null, self.SPF_NONE); + }); + } + + mech_mx (qualifier, args, cb) { + const self = this; + this.count++; + // Parse any arguments + let cm; + let cidr4; + let cidr6; + if (args && (cm = /\/(\d+)((?:\/\/(\d+))?)$/.exec(args))) { + cidr4 = cm[1]; + cidr6 = cm[2]; + } + let dm; + let domain = this.domain; + if (args && (dm = /^:([^/ ]+)/.exec(args))) { + domain = dm[1]; + } + // Fetch the MX records for the specified domain + net_utils.get_mx(domain, (err, mxes) => { + if (err) { + switch (err.code) { + case dns.NOTFOUND: + case dns.NODATA: + case dns.NXDOMAIN: return cb(null, self.SPF_NONE); + default: return cb(null, self.SPF_TEMPERROR); + } + } + let pending = 0; + let addresses = []; + // RFC 4408 Section 10.1 + if (mxes.length > self.LIMIT) { + return cb(null, self.SPF_PERMERROR); + } + for (let a=0; a { + pending--; + if (err4) { + switch (err4.code) { + case dns.NOTFOUND: + case dns.NODATA: + case dns.NXDOMAIN: break; + default: return cb(null, self.SPF_TEMPERROR); + } + } + else { + self.log_debug(`mech_mx: mx=${mx} addresses=${addrs.join(',')}`); + addresses = addrs.concat(addresses); + } + if (pending === 0) { + if (!addresses.length) return cb(null, self.SPF_NONE); + // All queries run; see if our IP matches + if (cidr) { + // CIDR match type + for (let i=0; i ${addresses[i]}/${cidr}: MATCH!`); + return cb(null, self.return_const(qualifier)); + } + else { + self.log_debug(`mech_mx: ${self.ip} => ${addresses[i]}/${cidr}: NO MATCH`); + } + } + // No matches + return cb(null, self.SPF_NONE); + } + else { + if (addresses.includes(self.ip)) { + self.log_debug(`mech_mx: ${self.ip} => ${addresses.join(',')}: MATCH!`); + return cb(null, self.return_const(qualifier)); + } + else { + self.log_debug(`mech_mx: ${self.ip} => ${addresses.join(',')}: NO MATCH`); + return cb(null, self.SPF_NONE); + } + } + } + }); + // In case we didn't run any queries... + if (pending === 0) { + return cb(null, self.SPF_NONE); + } + } + if (pending === 0) { + return cb(null, self.SPF_NONE); + } + }); + } + + mech_ptr (qualifier, args, cb) { + const self = this; + this.count++; + let dm; + let domain = this.domain; + if (args && (dm = /^:([^/ ]+)/.exec(args))) { + domain = dm[1]; + } + // First do a PTR lookup for the connecting IP + dns.reverse(this.ip, (err, ptrs) => { + if (err) { + self.log_debug(`mech_ptr: lookup=${self.ip} => ${err}`); + return cb(null, self.SPF_NONE); + } + else { + let resolve_method; + if (self.ip_ver === 'ipv4') resolve_method = 'resolve4'; + if (self.ip_ver === 'ipv6') resolve_method = 'resolve6'; + let pending = 0; + const names = []; + // RFC 4408 Section 10.1 + if (ptrs.length > self.LIMIT) { + return cb(null, self.SPF_PERMERROR); + } + for (let i=0; i { + pending--; + if (err3) { + // Skip on error + self.log_debug(`mech_ptr: lookup=${ptr} => ${err3}`); + } + else { + for (let a=0; a ${ptr} => ${addrs[a]}: MATCH!`); + names.push(ptr.toLowerCase()); + } + else { + self.log_debug(`mech_ptr: ${self.ip} => ${ptr} => ${addrs[a]}: NO MATCH`); + } + } + } + // Finished + if (pending === 0) { + let re; + // Catch bogus PTR matches e.g. ptr:*.bahnhof.se (should be ptr:bahnhof.se) + // These will cause a regexp error, so we can catch them. + try { + re = new RegExp(`${domain.replace('.','\\.')}$`, 'i'); + } + catch (e) { + self.log_debug( + 'mech_ptr', + { + domain: self.domain, + err: e.message + } + ); + return cb(null, self.SPF_PERMERROR); + } + for (let t=0; t ${domain}: MATCH!`); + return cb(null, self.return_const(qualifier)); + } + else { + self.log_debug(`mech_ptr: ${names[t]} => ${domain}: NO MATCH`); + } + } + return cb(null, self.SPF_NONE); + } + }); + } + if (pending === 0) { + // No queries run + return cb(null, self.SPF_NONE); + } + } + }); + } + + mech_ip (qualifier, args, cb) { + const cidr = args.substr(1); + const match = /^([^/ ]+)(?:\/(\d+))?$/.exec(cidr); + if (!match) { return cb(null, this.SPF_NONE); } + + // match[1] == ip + // match[2] == mask + try { + if (!match[2]) { + // Default masks for each IP version + if (this.ip_ver === 'ipv4') match[2] = '32'; + if (this.ip_ver === 'ipv6') match[2] = '128'; + } + const range = ipaddr.parse(match[1]); + const rtype = range.kind(); + if (this.ip_ver !== rtype) { + this.log_debug(`mech_ip: ${this.ip} => ${cidr}: SKIP`); + return cb(null, this.SPF_NONE); + } + if (this.ipaddr.match(range, match[2])) { + this.log_debug(`mech_ip: ${this.ip} => ${cidr}: MATCH!`); + return cb(null, this.return_const(qualifier)); + } + else { + this.log_debug(`mech_ip: ${this.ip} => ${cidr}: NO MATCH`); + } + } + catch (e) { + this.log_debug(e.message); + return cb(null, this.SPF_PERMERROR); + } + return cb(null, this.SPF_NONE); + } + + mod_redirect (domain, cb) { + // Avoid circular references + if (this.been_there[domain]) { + this.log_debug(`circular reference detected: ${domain}`); + return cb(null, this.SPF_NONE); + } + this.count++; + this.been_there[domain] = 1; + return this.check_host(this.ip, domain, this.mail_from, cb); + } + + mod_exp (str, cb) { + // NOT IMPLEMENTED + return cb(null, this.SPF_NONE); + } +} + +exports.SPF = SPF; diff --git a/test/index.js b/test/index.js index 202f419..e47c58e 100644 --- a/test/index.js +++ b/test/index.js @@ -3,50 +3,219 @@ const assert = require('assert') // npm modules -const fixtures = require('haraka-test-fixtures') +const Address = require('address-rfc2821').Address; +const constants = require('haraka-constants'); +const fixtures = require('haraka-test-fixtures') -// start of tests -// assert: https://nodejs.org/api/assert.html -// mocha: http://mochajs.org +const SPF = require('../spf').SPF; +const spf = new SPF(); -beforeEach(function (done) { +beforeEach(function () { this.plugin = new fixtures.plugin('spf') - done() // if a test hangs, assure you called done() + + this.plugin.timeout = 8000; + this.plugin.load_spf_ini(); + + // uncomment this line to see detailed SPF evaluation + this.plugin.SPF.prototype.log_debug = () => {}; + + this.connection = fixtures.connection.createConnection(); + this.connection.transaction = fixtures.transaction.createTransaction(); }) describe('spf', function () { - it('loads', function (done) { + it('loads', function () { assert.ok(this.plugin) - done() }) }) describe('load_spf_ini', function () { - it('loads spf.ini from config/spf.ini', function (done) { + it('loads spf.ini from config/spf.ini', function () { this.plugin.load_spf_ini() - assert.ok(this.plugin.cfg) - done() + assert.ok(this.plugin.cfg.main) + }) +}) + +describe('return_results', function () { + it('result, none, reject=false', function (done) { + this.plugin.cfg.deny.mfrom_none=false; + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_NONE, 'test@example.com'); }) + it('result, none, reject=true', function (done) { - it('initializes enabled boolean', function (done) { - this.plugin.load_spf_ini() - assert.equal(this.plugin.cfg.main.enabled, true, this.plugin.cfg) - done() + this.plugin.cfg.deny.mfrom_none=true; + this.plugin.return_results(function next () { + assert.equal(DENY, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_NONE, 'test@example.com'); + }) + it('result, neutral', function (done) { + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_NEUTRAL, 'test@example.com'); + }) + it('result, pass', function (done) { + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_PASS, 'test@example.com'); + }) + it('result, softfail, reject=false', function (done) { + + this.plugin.cfg.deny.mfrom_softfail=false; + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_SOFTFAIL, 'test@example.com'); + }) + it('result, softfail, reject=true', function (done) { + this.plugin.cfg.deny.mfrom_softfail=true; + this.plugin.return_results(function next () { + assert.equal(DENY, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_SOFTFAIL, 'test@example.com'); + }) + it('result, fail, reject=false', function (done) { + this.plugin.cfg.deny.mfrom_fail=false; + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_FAIL, 'test@example.com'); + }) + it('result, fail, reject=true', function (done) { + this.plugin.cfg.deny.mfrom_fail=true; + this.plugin.return_results(function next () { + assert.equal(DENY, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_FAIL, 'test@example.com'); + }) + it('result, temperror, reject=false', function (done) { + this.plugin.cfg.defer.mfrom_temperror=false; + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_TEMPERROR, 'test@example.com'); + }) + it('result, temperror, reject=true', function (done) { + function next () { + assert.equal(DENYSOFT, arguments[0]); + done() + } + this.plugin.cfg.defer.mfrom_temperror=true; + this.plugin.return_results(next, this.connection, spf, 'mfrom', spf.SPF_TEMPERROR, 'test@example.com'); + }) + it('result, permerror, reject=false', function (done) { + this.plugin.cfg.deny.mfrom_permerror=false; + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_PERMERROR, 'test@example.com'); + }) + it('result, permerror, reject=true', function (done) { + this.plugin.cfg.deny.mfrom_permerror=true; + this.plugin.return_results( function next () { + assert.equal(DENY, arguments[0]); + done() + }, this.connection, spf, 'mfrom', spf.SPF_PERMERROR, 'test@example.com'); + }) + it('result, unknown', function (done) { + this.plugin.return_results(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, spf, 'mfrom', 'unknown', 'test@example.com'); }) }) -describe('uses text fixtures', function () { - it('sets up a connection', function (done) { - this.connection = fixtures.connection.createConnection({}) - assert.ok(this.connection.server) - done() +describe('hook_helo', function () { + it('rfc1918', function (done) { + let completed = 0; + function next (rc) { + completed++; + assert.equal(undefined, rc); + if (completed >= 2) done() + } + this.connection.remote.is_private=true; + this.plugin.helo_spf(next, this.connection); + this.plugin.helo_spf(next, this.connection, 'helo.sender.com'); }) + it('IPv4 literal', function (done) { + this.connection.remote.ip='190.168.1.1'; + this.plugin.helo_spf(function next (rc) { + assert.equal(undefined, rc); + done() + }, this.connection, '[190.168.1.1]' ); + }) +}) + +const test_addr = new Address(''); + +describe('hook_mail', function () { + it('rfc1918', function (done) { - it('sets up a transaction', function (done) { - this.connection = fixtures.connection.createConnection({}) - this.connection.transaction = fixtures.transaction.createTransaction({}) - // console.log(this.connection.transaction) - assert.ok(this.connection.transaction.header) - done() + this.connection.remote.is_private=true; + this.connection.remote.ip='192.168.1.1'; + this.plugin.hook_mail(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, [test_addr]); }) -}) \ No newline at end of file + it('rfc1918 relaying', function (done) { + this.connection.set('remote.is_private', true); + this.connection.set('remote.ip','192.168.1.1'); + this.connection.relaying=true; + this.plugin.hook_mail(function next () { + assert.ok([undefined, constants.CONT].includes(arguments[0])); + done() + }, this.connection, [test_addr]); + }) + it('no txn', function (done) { + this.connection.remote.ip='207.85.1.1'; + delete this.connection.transaction; + this.plugin.hook_mail(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection); + }) + it('txn, no helo', function (done) { + this.plugin.cfg.deny.mfrom_fail = false; + this.connection.remote.ip='207.85.1.1'; + this.plugin.hook_mail(function next () { + assert.equal(undefined, arguments[0]); + done() + }, this.connection, [test_addr]); + }) + it('txn', function (done) { + this.connection.set('remote', 'ip', '207.85.1.1'); + this.connection.set('hello', 'host', 'mail.example.com'); + this.plugin.hook_mail(function next (rc) { + assert.equal(undefined, rc); + done() + }, this.connection, [test_addr]); + }) + it('txn, relaying', function (done) { + this.connection.set('remote.ip', '207.85.1.1'); + this.connection.relaying=true; + this.connection.set('hello.host', 'mail.example.com'); + this.plugin.hook_mail(function next (rc) { + assert.equal(undefined, rc); + done() + }, this.connection, [test_addr]); + }) + it('txn, relaying, is_private', function (done) { + this.plugin.cfg.relay.context='myself'; + this.plugin.cfg.deny_relay.mfrom_fail = true; + this.connection.set('remote.ip', '127.0.1.1'); + this.connection.set('remote.is_private', true); + this.connection.relaying = true; + this.connection.set('hello.host', 'www.tnpi.net'); + this.plugin.nu.public_ip = '66.128.51.165'; + this.plugin.hook_mail(function next (rc) { + assert.equal(undefined, rc); + done() + }, this.connection, [new Address('')]); + }) +}) diff --git a/test/spf.js b/test/spf.js new file mode 100644 index 0000000..37364fb --- /dev/null +++ b/test/spf.js @@ -0,0 +1,94 @@ +const assert = require('assert') + +const SPF = require('../spf').SPF; + +SPF.prototype.log_debug = () => {}; // noop, hush debug output + + +beforeEach(function () { + this.SPF = new SPF() +}) + +describe('SPF', function () { + it('new SPF', function () { + assert.ok(this.SPF); + }) + + it('constants', function () { + assert.equal(1, this.SPF.SPF_NONE); + assert.equal(2, this.SPF.SPF_PASS); + assert.equal(3, this.SPF.SPF_FAIL); + assert.equal(4, this.SPF.SPF_SOFTFAIL); + assert.equal(5, this.SPF.SPF_NEUTRAL); + assert.equal(6, this.SPF.SPF_TEMPERROR); + assert.equal(7, this.SPF.SPF_PERMERROR); + assert.equal(10, this.SPF.LIMIT); + }) + + it('mod_redirect, true', function (done) { + this.SPF.been_there['example.com'] = true; + this.SPF.mod_redirect('example.com', (err, rc) => { + assert.equal(null, err); + assert.equal(1, rc); + done() + }) + }) + + it('mod_redirect, false', function (done) { + this.SPF.count=0; + this.SPF.ip='212.70.129.94'; + this.SPF.mail_from='fraud@aexp.com'; + this.SPF.mod_redirect('aexp.com', (err, rc) => { + assert.equal(null, err); + switch (rc) { + case 7: + // from time to time (this is the third time we've seen it, + // American Express publishes an invalid SPF record which results + // in a PERMERROR. Ignore it. + assert.equal(rc, 7, "aexp SPF record is broken again"); + break; + case 6: + assert.equal(rc, 6, "temporary (likely DNS timeout) error"); + break; + default: + assert.equal(rc, 3); + } + done() + }) + }) + + it('check_host, gmail.com, fail', function (done) { + this.SPF.count=0; + this.SPF.check_host('212.70.129.94', 'gmail.com', 'haraka.mail@gmail.com', (err, rc) => { + assert.equal(null, err); + switch (rc) { + case 1: + assert.equal(rc, 1, "none"); + console.log('Why do DNS lookup fail to find gmail SPF record on GitHub Actions?'); + break; + case 3: + assert.equal(rc, 3, "fail"); + break; + case 4: + assert.equal(rc, 4, "soft fail"); + break; + case 7: + assert.equal(rc, 7, "perm error"); + break; + default: + assert.equal(rc, 4) + } + done() + }) + }) + + it('valid_ip, true', function (done) { + assert.equal(this.SPF.valid_ip(':212.70.129.94'), true); + done() + }) + + it('valid_ip, false', function (done) { + assert.equal(this.SPF.valid_ip(':212.70.d.94'), false); + done() + }) +})