Skip to content

Commit fb8428f

Browse files
Copilotmsimerson
andcommitted
Add commonly abused names detection feature
Co-authored-by: msimerson <261635+msimerson@users.noreply.github.com>
1 parent 2bf74ac commit fb8428f

File tree

3 files changed

+296
-1
lines changed

3 files changed

+296
-1
lines changed

config/known-senders.ini

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,33 @@ aol.com
1313
yahoo.com
1414
icloud.com
1515
me.com
16-
mac.com
16+
mac.com
17+
18+
; Commonly abused brand names mapped to their legitimate domains
19+
; Format: brand_name=legitimate_domain.com
20+
; Multiple variations can point to the same domain
21+
[commonly_abused]
22+
costco=costco.com
23+
c0stc0=costco.com
24+
paypal=paypal.com
25+
paypa1=paypal.com
26+
amazon=amazon.com
27+
amaz0n=amazon.com
28+
microsoft=microsoft.com
29+
micr0soft=microsoft.com
30+
apple=apple.com
31+
app1e=apple.com
32+
fedex=fedex.com
33+
fed3x=fedex.com
34+
ups=ups.com
35+
dhl=dhl.com
36+
usps=usps.com
37+
bankofamerica=bankofamerica.com
38+
wellsfargo=wellsfargo.com
39+
chase=chase.com
40+
citibank=citibank.com
41+
walmart=walmart.com
42+
wa1mart=walmart.com
43+
target=target.com
44+
bestbuy=bestbuy.com
45+
homedepot=homedepot.com

index.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const tlds = require('haraka-tld')
4+
const constants = require('haraka-constants')
45

56
exports.register = function () {
67
this.inherits('haraka-plugin-redis')
@@ -14,6 +15,7 @@ exports.register = function () {
1415
this.register_hook('rcpt_ok', 'check_recipient')
1516
this.register_hook('queue_ok', 'update_sender')
1617
this.register_hook('data_post', 'is_dkim_authenticated')
18+
this.register_hook('data_post', 'check_abused_names')
1719
}
1820

1921
exports.load_sender_ini = function () {
@@ -24,6 +26,7 @@ exports.load_sender_ini = function () {
2426
})
2527

2628
if (plugin.cfg.ignored_ods === undefined) plugin.cfg.ignored_ods = {}
29+
if (plugin.cfg.commonly_abused === undefined) plugin.cfg.commonly_abused = {}
2730

2831
plugin.merge_redis_ini()
2932
}
@@ -324,3 +327,98 @@ exports.has_spf_match = function (sender_od, connection) {
324327

325328
return false
326329
}
330+
331+
/*
332+
* Check for Commonly Abused Brand Names
333+
*
334+
* Check envelope from, header from, and subject for commonly abused
335+
* brand names. If found and the sending domain doesn't match the
336+
* legitimate domain, reject the message.
337+
*/
338+
339+
exports.check_abused_names = function (next, connection) {
340+
const plugin = this
341+
342+
// Only check inbound messages
343+
if (connection.relaying) return next()
344+
345+
// Skip if no commonly abused names configured
346+
if (!plugin.cfg.commonly_abused || Object.keys(plugin.cfg.commonly_abused).length === 0) {
347+
return next()
348+
}
349+
350+
const txn = connection.transaction
351+
if (!txn) return next()
352+
353+
try {
354+
// Get envelope from domain
355+
const envelope_from_domain = txn.mail_from && txn.mail_from.host
356+
? tlds.get_organizational_domain(txn.mail_from.host)
357+
: null
358+
359+
// Get header from
360+
const header_from = txn.header.get('from')
361+
let header_from_domain = null
362+
let header_from_text = ''
363+
364+
if (header_from) {
365+
header_from_text = header_from.toLowerCase()
366+
// Extract domain from header From (simple regex to get domain from email address)
367+
const domain_match = header_from.match(/@([^\s>]+)/i)
368+
if (domain_match && domain_match[1]) {
369+
header_from_domain = tlds.get_organizational_domain(domain_match[1])
370+
}
371+
}
372+
373+
// Get subject
374+
const subject = txn.header.get('subject')
375+
const subject_text = subject ? subject.toLowerCase() : ''
376+
377+
// Get envelope from text (local part and any display name)
378+
const envelope_from_text = txn.mail_from && txn.mail_from.user
379+
? txn.mail_from.user.toLowerCase()
380+
: ''
381+
382+
// Check each commonly abused name
383+
for (const [abused_name, legitimate_domain] of Object.entries(plugin.cfg.commonly_abused)) {
384+
const name_lower = abused_name.toLowerCase()
385+
386+
// Check if the abused name appears in from or subject
387+
const found_in_header_from = header_from_text.includes(name_lower)
388+
const found_in_subject = subject_text.includes(name_lower)
389+
const found_in_envelope_from = envelope_from_text.includes(name_lower)
390+
391+
if (found_in_header_from || found_in_subject || found_in_envelope_from) {
392+
// Get the legitimate OD for comparison
393+
const legitimate_od = tlds.get_organizational_domain(legitimate_domain)
394+
395+
// Check if the actual sending domains match the legitimate domain
396+
const envelope_matches = envelope_from_domain === legitimate_od
397+
const header_matches = header_from_domain === legitimate_od
398+
399+
if (!envelope_matches && !header_matches) {
400+
// The abused name was found but neither domain matches - REJECT
401+
const locations = []
402+
if (found_in_envelope_from) locations.push('envelope from')
403+
if (found_in_header_from) locations.push('header from')
404+
if (found_in_subject) locations.push('subject')
405+
406+
connection.loginfo(plugin,
407+
`rejecting: abused name '${abused_name}' found in ${locations.join(', ')} ` +
408+
`but domain is not ${legitimate_domain} ` +
409+
`(envelope: ${envelope_from_domain || 'none'}, header: ${header_from_domain || 'none'})`
410+
)
411+
412+
return next(constants.DENY,
413+
`This message appears to impersonate ${legitimate_domain} and has been rejected`
414+
)
415+
}
416+
}
417+
}
418+
419+
next()
420+
} catch (err) {
421+
connection.logerror(plugin, `check_abused_names error: ${err}`)
422+
next()
423+
}
424+
}

test/index.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const assert = require('assert')
22

33
const Address = require('address-rfc2821').Address
4+
const constants = require('haraka-constants')
45
const fixtures = require('haraka-test-fixtures')
56

67
describe('register', function () {
@@ -293,3 +294,170 @@ describe('is_dkim_authenticated', function () {
293294
}, connection)
294295
})
295296
})
297+
298+
describe('check_abused_names', function () {
299+
let plugin
300+
let connection
301+
302+
beforeEach(function () {
303+
connection = fixtures.connection.createConnection()
304+
connection.init_transaction()
305+
306+
plugin = new fixtures.plugin('index')
307+
plugin.register()
308+
})
309+
310+
it('allows messages when no commonly abused names configured', function (done) {
311+
// Clear the commonly_abused config
312+
plugin.cfg.commonly_abused = {}
313+
314+
const header_from = 'Costco Support <spam@spammer.com>'
315+
connection.transaction.header.add('From', header_from)
316+
connection.transaction.header.add('Subject', 'Your Costco Order')
317+
connection.transaction.mail_from = new Address('<spam@spammer.com>')
318+
319+
plugin.check_abused_names(function (code) {
320+
assert.equal(code, undefined)
321+
done()
322+
}, connection)
323+
})
324+
325+
it('allows outbound messages without checking', function (done) {
326+
connection.relaying = true
327+
const header_from = 'Costco Support <spam@spammer.com>'
328+
connection.transaction.header.add('From', header_from)
329+
connection.transaction.header.add('Subject', 'Your Costco Order')
330+
connection.transaction.mail_from = new Address('<spam@spammer.com>')
331+
332+
plugin.check_abused_names(function (code) {
333+
assert.equal(code, undefined)
334+
done()
335+
}, connection)
336+
})
337+
338+
it('rejects when costco in subject but domain is not costco.com', function (done) {
339+
const header_from = 'Support <spam@spammer.com>'
340+
connection.transaction.header.add('From', header_from)
341+
connection.transaction.header.add('Subject', 'Your Costco Order Confirmation')
342+
connection.transaction.mail_from = new Address('<spam@spammer.com>')
343+
344+
plugin.check_abused_names(function (code, msg) {
345+
assert.equal(code, constants.DENY)
346+
assert.ok(msg.includes('impersonate'))
347+
assert.ok(msg.includes('costco.com'))
348+
done()
349+
}, connection)
350+
})
351+
352+
it('rejects when c0stc0 (with zeros) in subject but domain is not costco.com', function (done) {
353+
const header_from = 'Support <spam@evil.com>'
354+
connection.transaction.header.add('From', header_from)
355+
connection.transaction.header.add('Subject', 'Your c0stc0 Order')
356+
connection.transaction.mail_from = new Address('<spam@evil.com>')
357+
358+
plugin.check_abused_names(function (code, msg) {
359+
assert.equal(code, constants.DENY)
360+
assert.ok(msg.includes('impersonate'))
361+
done()
362+
}, connection)
363+
})
364+
365+
it('rejects when costco in header from but domain is not costco.com', function (done) {
366+
const header_from = 'Costco Support <spam@spammer.com>'
367+
connection.transaction.header.add('From', header_from)
368+
connection.transaction.header.add('Subject', 'Order Update')
369+
connection.transaction.mail_from = new Address('<spam@spammer.com>')
370+
371+
plugin.check_abused_names(function (code, msg) {
372+
assert.equal(code, constants.DENY)
373+
assert.ok(msg.includes('impersonate'))
374+
done()
375+
}, connection)
376+
})
377+
378+
it('rejects when costco in envelope from local part but domain is not costco.com', function (done) {
379+
const header_from = 'Support Team <spam@spammer.com>'
380+
connection.transaction.header.add('From', header_from)
381+
connection.transaction.header.add('Subject', 'Important Notice')
382+
connection.transaction.mail_from = new Address('<costco-support@spammer.com>')
383+
384+
plugin.check_abused_names(function (code, msg) {
385+
assert.equal(code, constants.DENY)
386+
assert.ok(msg.includes('impersonate'))
387+
done()
388+
}, connection)
389+
})
390+
391+
it('allows when costco in subject and envelope domain is costco.com', function (done) {
392+
const header_from = 'Costco Support <noreply@costco.com>'
393+
connection.transaction.header.add('From', header_from)
394+
connection.transaction.header.add('Subject', 'Your Costco Order')
395+
connection.transaction.mail_from = new Address('<noreply@costco.com>')
396+
397+
plugin.check_abused_names(function (code) {
398+
assert.equal(code, undefined)
399+
done()
400+
}, connection)
401+
})
402+
403+
it('allows when costco in subject and header from domain is costco.com', function (done) {
404+
const header_from = 'Costco Support <noreply@costco.com>'
405+
connection.transaction.header.add('From', header_from)
406+
connection.transaction.header.add('Subject', 'Your Costco Order')
407+
connection.transaction.mail_from = new Address('<spam@spammer.com>')
408+
409+
plugin.check_abused_names(function (code) {
410+
assert.equal(code, undefined)
411+
done()
412+
}, connection)
413+
})
414+
415+
it('allows when costco in subject and envelope domain is subdomain of costco.com', function (done) {
416+
const header_from = 'Costco Support <noreply@mail.costco.com>'
417+
connection.transaction.header.add('From', header_from)
418+
connection.transaction.header.add('Subject', 'Your Costco Order')
419+
connection.transaction.mail_from = new Address('<noreply@mail.costco.com>')
420+
421+
plugin.check_abused_names(function (code) {
422+
assert.equal(code, undefined)
423+
done()
424+
}, connection)
425+
})
426+
427+
it('allows messages without abused names', function (done) {
428+
const header_from = 'John Doe <john@example.com>'
429+
connection.transaction.header.add('From', header_from)
430+
connection.transaction.header.add('Subject', 'Hello there')
431+
connection.transaction.mail_from = new Address('<john@example.com>')
432+
433+
plugin.check_abused_names(function (code) {
434+
assert.equal(code, undefined)
435+
done()
436+
}, connection)
437+
})
438+
439+
it('is case-insensitive when checking abused names', function (done) {
440+
const header_from = 'COSTCO Support <spam@spammer.com>'
441+
connection.transaction.header.add('From', header_from)
442+
connection.transaction.header.add('Subject', 'Your COSTCO Order')
443+
connection.transaction.mail_from = new Address('<spam@spammer.com>')
444+
445+
plugin.check_abused_names(function (code) {
446+
assert.equal(code, constants.DENY)
447+
done()
448+
}, connection)
449+
})
450+
451+
it('rejects paypal abuse', function (done) {
452+
const header_from = 'PayPal Security <noreply@phishing.com>'
453+
connection.transaction.header.add('From', header_from)
454+
connection.transaction.header.add('Subject', 'Verify your PayPal account')
455+
connection.transaction.mail_from = new Address('<noreply@phishing.com>')
456+
457+
plugin.check_abused_names(function (code, msg) {
458+
assert.equal(code, constants.DENY)
459+
assert.ok(msg.includes('paypal.com'))
460+
done()
461+
}, connection)
462+
})
463+
})

0 commit comments

Comments
 (0)