UNPKG

haraka-plugin-spf

Version:

Sender Policy Framework (SPF) plugin for Haraka

367 lines (319 loc) 10.4 kB
// spf const SPF = require('./lib/spf').SPF const net_utils = require('haraka-net-utils') const DSN = require('haraka-dsn') exports.SPF = SPF exports.register = function () { // Override logging in SPF module SPF.prototype.log_debug = (str) => this.logdebug(str) this.load_spf_ini() this.register_hook('helo', 'helo_spf') this.register_hook('ehlo', 'helo_spf') } exports.load_spf_ini = function () { this.nu = net_utils // so tests can set public_ip this.SPF = SPF this.cfg = this.config.get( 'spf.ini', { booleans: [ '-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', ], }, () => { this.load_spf_ini() }, ) // when set, preserve legacy config settings for (const phase of ['helo', 'mail']) { if (this.cfg.main[`${phase}_softfail_reject`]) { this.cfg.deny[`${phase}_softfail`] = true } if (this.cfg.main[`${phase}_fail_reject`]) { this.cfg.deny[`${phase}_fail`] = true } if (this.cfg.main[`${phase}_temperror_defer`]) { this.cfg.defer[`${phase}_temperror`] = true } if (this.cfg.main[`${phase}_permerror_reject`]) { this.cfg.deny[`${phase}_permerror`] = true } } if (!this.cfg.relay) { this.cfg.relay = { context: 'sender' } // default/legacy } this.cfg.lookup_timeout = this.cfg.main.lookup_timeout || this.timeout - 1 } exports.helo_spf = async function (next, connection, helo) { const plugin = this // bypass auth'ed or relay'ing hosts if told to const skip_reason = this.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') next() }, plugin.cfg.lookup_timeout * 1000) timer.unref() try { const result = await spf.check_host(connection.remote.ip, helo, null) if (timer) clearTimeout(timer) if (timeout) return 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 }) } catch (err) { connection.logerror(plugin, err) } next() } exports.hook_mail = async 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 = this.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 (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) timer.unref() 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) } try { // Always check the client IP first. 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. Only check the public IP if the // client IP returns a result other than 'Pass'. const result = await spf.check_host(connection.remote.ip, host, mfrom) // typical inbound (!relay) if (!connection.relaying) return ch_cb(null, result) // outbound (relaying), context=sender if (plugin.cfg.relay.context === 'sender') return ch_cb(null, result) // outbound (relaying), context=myself const my_public_ip = await net_utils.get_public_ip() let spf_result if (result) spf_result = spf.result(result).toLowerCase() if (spf_result && spf_result !== 'pass') { if (!my_public_ip) { return ch_cb(new Error(`failed to discover public IP`)) } spf = new SPF() const r = await spf.check_host(my_public_ip, host, mfrom) return ch_cb(null, r, my_public_ip) } ch_cb(null, result, connection.remote.ip) } catch (err) { ch_cb(err) } } 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 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 (this.cfg[deny][`${scope}_none`]) { text = this.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 (this.cfg[deny][`${scope}_softfail`]) { text = this.cfg[deny].openspf_text ? text : `${msgpre} SPF SoftFail` return next(DENY, text) } return next() case spf.SPF_FAIL: if (this.cfg[deny][`${scope}_fail`]) { text = this.cfg[deny].openspf_text ? text : `${msgpre} SPF Fail` return next(DENY, text) } return next() case spf.SPF_TEMPERROR: if (this.cfg[defer][`${scope}_temperror`]) { return next(DENYSOFT, `${msgpre} SPF Temporary Error`) } return next() case spf.SPF_PERMERROR: if (this.cfg[deny][`${scope}_permerror`]) { return next(DENY, `${msgpre} SPF Permanent Error`) } return next() default: // Unknown result connection.logerror(this, `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 skip = this?.cfg?.skip if (skip) { if (skip.relaying && connection.relaying) return 'relay' if (skip.auth && connection.notes.auth_user) return 'auth' } }