index.js
// validate message headers and some fields
const tlds = require('haraka-tld')
const phish_targets = []
exports.register = function () {
this.load_headers_ini()
try {
this.addrparser = require('address-rfc2822')
} catch (e) {
this.logerror(
"unable to load address-rfc2822, try\n\n\t'npm install -g address-rfc2822'\n\n",
)
}
if (this.cfg.check.duplicate_singular)
this.register_hook('data_post', 'duplicate_singular')
if (this.cfg.check.missing_required)
this.register_hook('data_post', 'missing_required')
if (this.cfg.check.invalid_return_path)
this.register_hook('data_post', 'invalid_return_path')
if (this.cfg.check.invalid_date)
this.register_hook('data_post', 'invalid_date')
if (this.cfg.check.user_agent) this.register_hook('data_post', 'user_agent')
if (this.cfg.check.direct_to_mx)
this.register_hook('data_post', 'direct_to_mx')
if (this.addrparser) {
if (this.cfg.check.from_match) this.register_hook('data_post', 'from_match')
if (this.cfg.check.delivered_to)
this.register_hook('data_post', 'delivered_to')
}
if (this.cfg.check.mailing_list)
this.register_hook('data_post', 'mailing_list')
if (this.cfg.check.from_phish) this.register_hook('data_post', 'from_phish')
}
exports.load_headers_ini = function () {
const plugin = this
plugin.cfg = plugin.config.get(
'headers.ini',
{
booleans: [
'+check.duplicate_singular',
'+check.missing_required',
'+check.invalid_return_path',
'+check.invalid_date',
'+check.user_agent',
'+check.direct_to_mx',
'+check.from_match',
'+check.delivered_to',
'+check.mailing_list',
'+check.from_phish',
'-reject.duplicate_singular',
'-reject.missing_required',
'-reject.invalid_return_path',
'-reject.invalid_date',
'+reject.delivered_to',
'-reject.from_phish',
],
},
() => {
plugin.load_headers_ini()
},
)
for (const d in plugin.cfg.phish_domains) {
phish_targets.push(new RegExp(d.replace('.', '[.]'), 'i'))
}
// console.log(phish_targets)
}
exports.duplicate_singular = function (next, connection) {
const plugin = this
// RFC 5322 Section 3.6, Headers that MUST be unique if present
const singular =
plugin.cfg.main.singular !== undefined
? plugin.cfg.main.singular.split(',')
: [
'Date',
'From',
'Sender',
'Reply-To',
'To',
'Cc',
'Bcc',
'Message-Id',
'In-Reply-To',
'References',
'Subject',
]
const failures = []
for (const name of singular) {
if (connection.transaction.header.get_all(name).length <= 1) {
continue
}
connection.transaction.results.add(plugin, { fail: `duplicate:${name}` })
failures.push(name)
}
if (failures.length) {
if (plugin.cfg.reject.duplicate_singular) {
return next(
DENY,
`Only one ${failures[0]} header allowed. See RFC 5322, Section 3.6`,
)
}
return next()
}
connection.transaction.results.add(plugin, { pass: 'duplicate' })
next()
}
exports.missing_required = function (next, connection) {
const plugin = this
// Enforce RFC 5322 Section 3.6, Headers that MUST be present
const required =
plugin.cfg.main.required !== undefined
? plugin.cfg.main.required.split(',')
: ['Date', 'From']
const failures = []
for (const h of required) {
if (connection.transaction.header.get_all(h).length === 0) {
connection.transaction.results.add(plugin, { fail: `missing:${h}` })
failures.push(h)
}
}
if (failures.length) {
if (plugin.cfg.reject.missing_required) {
return next(DENY, `Required header '${failures[0]}' missing`)
}
return next()
}
connection.transaction.results.add(plugin, { pass: 'missing' })
next()
}
exports.invalid_return_path = function (next, connection) {
const plugin = this
// Tests for Return-Path headers that shouldn't be present
// RFC 5321#section-4.4 Trace Information
// A message-originating SMTP system SHOULD NOT send a message that
// already contains a Return-path header field.
// Return-Path, aka Reverse-PATH, Envelope FROM, RFC5321.MailFrom
const rp = connection.transaction.header.get('Return-Path')
if (rp) {
if (connection.relaying) {
// On messages we originate
connection.transaction.results.add(plugin, {
fail: 'Return-Path',
emit: true,
})
if (plugin.cfg.reject.invalid_return_path) {
return next(
DENY,
'outgoing mail must not have a Return-Path header (RFC 5321)',
)
}
return next()
}
// generally, messages from the internet shouldn't have a
// Return-Path, except for when they can. Read RFC 5321, it's
// complicated. In most cases, The Right Thing to do here is to
// strip the Return-Path header.
connection.transaction.remove_header('Return-Path')
// unless it was added by Haraka. Which at present, doesn't.
}
connection.transaction.results.add(plugin, { pass: 'Return-Path' })
next()
}
exports.invalid_date = function (next, connection) {
const plugin = this
// Assure Date header value is [somewhat] sane
let msg_date = connection.transaction.header.get_all('Date')
if (!msg_date || msg_date.length === 0) return next()
connection.logdebug(plugin, `message date: ${msg_date}`)
msg_date = Date.parse(msg_date)
const date_future_days =
plugin.cfg.main.date_future_days !== undefined
? plugin.cfg.main.date_future_days
: 2
if (date_future_days > 0) {
const too_future = new Date()
too_future.setHours(too_future.getHours() + 24 * date_future_days)
// connection.logdebug(plugin, "too future: " + too_future);
if (msg_date > too_future) {
connection.transaction.results.add(plugin, {
fail: 'invalid_date(future)',
})
if (plugin.cfg.reject.invalid_date) {
return next(DENY, 'The Date header is too far in the future')
}
return next()
}
}
const date_past_days =
plugin.cfg.main.date_past_days !== undefined
? plugin.cfg.main.date_past_days
: 15
if (date_past_days > 0) {
const too_old = new Date()
too_old.setHours(too_old.getHours() - 24 * date_past_days)
// connection.logdebug(plugin, "too old: " + too_old);
if (msg_date < too_old) {
connection.loginfo(plugin, `date is older than: ${too_old}`)
connection.transaction.results.add(plugin, { fail: 'invalid_date(past)' })
if (plugin.cfg.reject.invalid_date) {
return next(DENY, 'The Date header is too old')
}
return next()
}
}
connection.transaction.results.add(plugin, { pass: 'invalid_date' })
next()
}
exports.user_agent = function (next, connection) {
const plugin = this
if (!connection.transaction) return next()
let found_ua = 0
// User-Agent: Thunderbird, Squirrelmail, Roundcube, Mutt, MacOutlook,
// Kmail, IMP
// X-Mailer: Apple Mail, swaks, Outlook (12-14), Yahoo Webmail,
// Cold Fusion, Zimbra, Evolution
// X-Yahoo-Newman-Property: Yahoo
// X-MS-Has-Attach: Outlook 15
// Check for User-Agent
const headers = [
'user-agent',
'x-mailer',
'x-mua',
'x-yahoo-newman-property',
'x-ms-has-attach',
]
// for (const h in headers) {}
for (const name of headers) {
const header = connection.transaction.header.get(name)
if (!header) continue // header not present
found_ua++
connection.transaction.results.add(plugin, {
pass: `UA(${header.substring(0, 12)})`,
})
}
if (found_ua) return next()
connection.transaction.results.add(plugin, { fail: 'UA' })
next()
}
exports.direct_to_mx = function (next, connection) {
const plugin = this
if (!connection.transaction) return next()
// Legit messages normally have at least 2 hops (Received headers)
// MUA -> sending MTA -> Receiving MTA (Haraka?)
if (connection.notes.auth_user) {
// User authenticated, so we're likely the first MTA
connection.transaction.results.add(plugin, { skip: 'direct-to-mx(auth)' })
return next()
}
// what about connection.relaying?
const received = connection.transaction.header.get_all('received')
if (!received) {
connection.transaction.results.add(plugin, { fail: 'direct-to-mx(none)' })
return next()
}
const c = received.length
if (c < 2) {
connection.transaction.results.add(plugin, {
fail: `direct-to-mx(too few Received(${c}))`,
})
return next()
}
connection.transaction.results.add(plugin, { pass: `direct-to-mx(${c})` })
next()
}
exports.from_match = function (next, connection) {
const plugin = this
// see if the header From matches the envelope FROM. There are valid
// cases to not match (~10% of ham) but a non-match is much more
// likely to be spam than ham. This test is useful for heuristics.
if (!connection.transaction) return next()
const env_addr = connection.transaction.mail_from
if (!env_addr) {
connection.transaction.results.add(plugin, { fail: 'from_match(null)' })
return next()
}
const hdr_from = connection.transaction.header.get_decoded('From')
if (!hdr_from) {
connection.transaction.results.add(plugin, { fail: 'from_match(missing)' })
return next()
}
let hdr_addr
try {
hdr_addr = plugin.addrparser.parse(hdr_from)[0]
} catch (e) {
connection.logwarn(
plugin,
`parsing "${hdr_from.trim()}" with address-rfc2822 plugin returned error: ${e.message}`,
)
connection.transaction.results.add(plugin, {
fail: 'from_match(rfc_violation)',
})
return next()
}
if (!hdr_addr) {
connection.loginfo(plugin, `address at fault is: ${hdr_from}`)
connection.transaction.results.add(plugin, {
fail: 'from_match(unparsable)',
})
return next()
}
if (env_addr.address().toLowerCase() === hdr_addr.address.toLowerCase()) {
connection.transaction.results.add(plugin, { pass: 'from_match' })
return next()
}
const extra = ['domain']
const env_dom = tlds.get_organizational_domain(env_addr.host)
const msg_dom = tlds.get_organizational_domain(hdr_addr.host())
if (env_dom && msg_dom && env_dom.toLowerCase() === msg_dom.toLowerCase()) {
const fcrdns = connection.results.get('fcrdns')
if (
fcrdns &&
fcrdns.fcrdns &&
new RegExp(`${msg_dom}\\b`, 'i').test(fcrdns.fcrdns)
) {
extra.push('fcrdns')
}
const helo = connection.results.get('helo.checks')
if (helo && helo.helo_host && /msg_dom$/.test(helo.helo_host)) {
extra.push('helo')
}
connection.transaction.results.add(plugin, {
pass: `from_match(${extra.join(',')})`,
})
return next()
}
connection.transaction.results.add(plugin, {
emit: true,
fail: `from_match(${env_dom} / ${msg_dom})`,
})
next()
}
exports.delivered_to = function (next, connection) {
const plugin = this
const txn = connection.transaction
if (!txn) return next()
const del_to = txn.header.get('Delivered-To')
if (!del_to) return next()
const rcpts = connection.transaction.rcpt_to
for (const rcptElement of rcpts) {
const rcpt = rcptElement.address()
if (rcpt !== del_to) continue
connection.transaction.results.add(plugin, {
emit: true,
fail: 'delivered_to',
})
if (!plugin.cfg.reject.delivered_to) continue
return next(DENY, 'Invalid Delivered-To header content')
}
next()
}
exports.mailing_list = function (next, connection) {
const plugin = this
if (!connection.transaction) return next()
const mlms = {
'Mailing-List': [
{ mlm: 'ezmlm', match: 'ezmlm' },
{ mlm: 'yahoogroups', match: 'yahoogroups' },
{ mlm: 'googlegroups', match: 'googlegroups' },
],
Sender: [{ mlm: 'majordomo', start: 'owner-' }],
'X-Mailman-Version': [{ mlm: 'mailman' }],
'X-Majordomo-Version': [{ mlm: 'majordomo' }],
'X-Google-Loop': [{ mlm: 'googlegroups' }],
}
let found_mlm = 0
const txr = connection.transaction.results
Object.keys(mlms).forEach((name) => {
const header = connection.transaction.header.get(name)
if (!header) {
return
} // header not present
for (const j of mlms[name]) {
if (j.start) {
if (header.substring(0, j.start.length) === j.start) {
txr.add(plugin, { pass: `MLM(${j.mlm})` })
found_mlm++
continue
}
connection.logdebug(plugin, `mlm start miss: ${name}: ${header}`)
}
if (j.match) {
if (header.match(new RegExp(j.match, 'i'))) {
txr.add(plugin, { pass: `MLM(${j.mlm})` })
found_mlm++
continue
}
connection.logdebug(plugin, `mlm match miss: ${name}: ${header}`)
}
if (name === 'X-Mailman-Version') {
txr.add(plugin, { pass: `MLM(${j.mlm})` })
found_mlm++
continue
}
if (name === 'X-Majordomo-Version') {
txr.add(plugin, { pass: `MLM(${j.mlm})` })
found_mlm++
continue
}
if (name === 'X-Google-Loop') {
txr.add(plugin, { pass: `MLM(${j.mlm})` })
found_mlm++
continue
}
}
})
if (found_mlm) return next()
connection.transaction.results.add(plugin, { msg: 'not MLM' })
next()
}
exports.from_phish = function (next, connection) {
const plugin = this
if (!connection.transaction) return next()
// check the header From display name for common phish domains
const hdr_from = connection.transaction.header.get_decoded('From')
if (!hdr_from) {
connection.transaction.results.add(plugin, { skip: 'from_phish(missing)' })
return next()
}
for (const addr of phish_targets) {
if (!addr.test(hdr_from)) continue // not a sender match
if (!exports.has_auth_match(addr, connection)) {
connection.transaction.results.add(plugin, {
fail: `from_phish(${hdr_from}`,
})
if (plugin.cfg.reject.from_phish)
return next(DENY, `Phishing message detected`)
return next()
}
}
connection.transaction.results.add(plugin, { pass: 'from_phish' })
next()
}
exports.has_auth_match = function (re, conn) {
// check domain RegEx against spf, dkim, and env sender for a match
const spf = conn.transaction.results.get('spf') // only check mfrom
if (spf && re.test(spf.pass)) return true
// try DKIM via results
const dkim = conn.transaction.results.get('dkim_verify')
if (dkim && re.test(dkim.pass)) return true
// fallback DKIM via notes
const dkim_note = conn.transaction.notes.dkim_results
if (dkim_note) {
const passes = dkim_note.filter(
(r) => r.result === 'pass' && re.test(r.domain),
)
if (passes.length) return true
}
const env_addr = conn.transaction.mail_from
if (env_addr && re.test(env_addr)) return true
return false
}