Haraka 3.1.6 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/CONTRIBUTORS.md +8 -8
  3. package/Plugins.md +99 -99
  4. package/address.js +53 -0
  5. package/bin/haraka +1 -1
  6. package/config/smtp_forward.ini +10 -0
  7. package/config/smtp_proxy.ini +10 -0
  8. package/connection.js +28 -11
  9. package/docs/Outbound.md +1 -1
  10. package/docs/Transaction.md +1 -1
  11. package/docs/plugins/queue/smtp_forward.md +19 -3
  12. package/docs/plugins/queue/smtp_proxy.md +10 -2
  13. package/docs/plugins/status.md +21 -5
  14. package/haraka.js +1 -1
  15. package/outbound/hmail.js +41 -41
  16. package/outbound/index.js +5 -5
  17. package/outbound/queue.js +1 -1
  18. package/outbound/tls.js +2 -43
  19. package/package.json +48 -48
  20. package/plugins/auth/auth_base.js +9 -3
  21. package/plugins/auth/auth_proxy.js +14 -11
  22. package/plugins/block_me.js +6 -4
  23. package/plugins/prevent_credential_leaks.js +3 -1
  24. package/plugins/process_title.js +6 -6
  25. package/plugins/queue/qmail-queue.js +15 -19
  26. package/plugins/queue/smtp_forward.js +14 -6
  27. package/plugins/queue/smtp_proxy.js +14 -3
  28. package/plugins/rcpt_to.host_list_base.js +1 -1
  29. package/plugins/record_envelope_addresses.js +2 -2
  30. package/plugins/status.js +34 -5
  31. package/plugins/tls.js +13 -5
  32. package/plugins/xclient.js +3 -1
  33. package/server.js +5 -3
  34. package/smtp_client.js +20 -11
  35. package/test/config/block_me.recipient +1 -0
  36. package/test/config/block_me.senders +1 -0
  37. package/test/connection.js +25 -1
  38. package/test/fixtures/util_hmailitem.js +1 -1
  39. package/test/outbound/bounce_net_errors.js +3 -2
  40. package/test/outbound/index.js +2 -2
  41. package/test/plugins/auth/auth_base.js +1 -1
  42. package/test/plugins/auth/auth_bridge.js +80 -0
  43. package/test/plugins/auth/flat_file.js +128 -0
  44. package/test/plugins/block_me.js +157 -0
  45. package/test/plugins/data.signatures.js +114 -0
  46. package/test/plugins/delay_deny.js +263 -0
  47. package/test/plugins/prevent_credential_leaks.js +178 -0
  48. package/test/plugins/process_title.js +135 -0
  49. package/test/plugins/queue/deliver.js +99 -0
  50. package/test/plugins/queue/discard.js +79 -0
  51. package/test/plugins/queue/lmtp.js +138 -0
  52. package/test/plugins/queue/qmail-queue.js +99 -0
  53. package/test/plugins/queue/quarantine.js +81 -0
  54. package/test/plugins/queue/smtp_bridge.js +154 -0
  55. package/test/plugins/queue/smtp_forward.js +43 -7
  56. package/test/plugins/queue/smtp_proxy.js +139 -0
  57. package/test/plugins/rcpt_to.host_list_base.js +1 -1
  58. package/test/plugins/rcpt_to.in_host_list.js +1 -1
  59. package/test/plugins/record_envelope_addresses.js +2 -2
  60. package/test/plugins/reseed_rng.js +34 -0
  61. package/test/plugins/status.js +71 -0
  62. package/test/plugins/tarpit.js +91 -0
  63. package/test/plugins/tls.js +25 -0
  64. package/test/plugins/toobusy.js +21 -0
  65. package/test/plugins/xclient.js +14 -0
  66. package/test/server.js +59 -0
  67. package/test/smtp_client.js +46 -13
  68. package/test/tls_socket.js +82 -0
  69. package/tls_socket.js +50 -0
package/smtp_client.js CHANGED
@@ -192,7 +192,7 @@ class SMTPClient extends events.EventEmitter {
192
192
  client.socket.on('end', closed('ended'))
193
193
  }
194
194
 
195
- load_tls_config(opts = {}) {
195
+ load_tls_options(opts = {}) {
196
196
  this.tls_options = { servername: this.host, ...opts }
197
197
  }
198
198
 
@@ -312,8 +312,8 @@ exports.onCapabilitiesOutbound = (smtp_client, secured, connection, config, on_s
312
312
  // Check if there are any banned TLS hosts
313
313
  if (smtp_client.tls_options.no_tls_hosts) {
314
314
  // If there are check if these hosts are in the blacklist
315
- hostBanned = net_utils.ip_in_list(smtp_client.tls_config.no_tls_hosts, config.host)
316
- serverBanned = net_utils.ip_in_list(smtp_client.tls_config.no_tls_hosts, smtp_client.remote_ip)
315
+ hostBanned = net_utils.ip_in_list(smtp_client.tls_options.no_tls_hosts, config.host)
316
+ serverBanned = net_utils.ip_in_list(smtp_client.tls_options.no_tls_hosts, smtp_client.remote_ip)
317
317
  }
318
318
 
319
319
  if (!hostBanned && !serverBanned && config.enable_tls) {
@@ -358,7 +358,7 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
358
358
 
359
359
  let secured = false
360
360
 
361
- smtp_client.load_tls_config(plugin.tls_options)
361
+ smtp_client.load_tls_options(plugin.tls_options)
362
362
 
363
363
  smtp_client.call_next = function (retval, msg) {
364
364
  if (this.next) {
@@ -369,7 +369,10 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
369
369
  }
370
370
 
371
371
  smtp_client.on('client_protocol', (line) => {
372
- connection.logprotocol(plugin, `C: ${line}`)
372
+ // Don't leak SASL credentials (e.g. AUTH PLAIN <base64>) into
373
+ // protocol logs.
374
+ const safe = String(line).replace(/^(AUTH\s+\S+\s+).+$/i, '$1[redacted]')
375
+ connection.logprotocol(plugin, `C: ${safe}`)
373
376
  })
374
377
 
375
378
  smtp_client.on('server_protocol', (line) => {
@@ -401,21 +404,27 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
401
404
  if (!c.auth || smtp_client.authenticated) {
402
405
  if (smtp_client.is_dead_sender(plugin, connection)) return
403
406
 
404
- smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtp_utf8)}`)
407
+ smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtputf8)}`)
405
408
  return
406
409
  }
407
410
 
408
411
  if (c.auth.type === null || typeof c.auth.type === 'undefined') return // Ignore blank
409
412
  const auth_type = c.auth.type.toLowerCase()
413
+ // This listener runs from the socket line handler; an uncaught
414
+ // throw here crashes the forwarding worker. Route failures
415
+ // through the existing smtp_client 'error' flow (logwarn +
416
+ // call_next) so a misconfigured/hostile upstream degrades to a
417
+ // normal SMTP error path instead.
410
418
  if (!smtp_client.auth_capabilities.includes(auth_type)) {
411
- throw new Error(
419
+ return smtp_client.emit(
420
+ 'error',
412
421
  `Auth type "${auth_type}" not supported by server (supports: ${smtp_client.auth_capabilities.join(',')})`,
413
422
  )
414
423
  }
415
424
  switch (auth_type) {
416
425
  case 'plain':
417
426
  if (!c.auth.user || !c.auth.pass) {
418
- throw new Error('Must include auth.user and auth.pass for PLAIN auth.')
427
+ return smtp_client.emit('error', 'Must include auth.user and auth.pass for PLAIN auth.')
419
428
  }
420
429
  logger.debug(`[smtp_client] uuid=${smtp_client.uuid} authenticating as "${c.auth.user}"`)
421
430
  smtp_client.send_command(
@@ -424,9 +433,9 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
424
433
  )
425
434
  break
426
435
  case 'cram-md5':
427
- throw new Error('Not implemented')
436
+ return smtp_client.emit('error', `AUTH ${auth_type} not implemented`)
428
437
  default:
429
- throw new Error(`Unknown AUTH type: ${auth_type}`)
438
+ return smtp_client.emit('error', `Unknown AUTH type: ${auth_type}`)
430
439
  }
431
440
  })
432
441
 
@@ -437,7 +446,7 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
437
446
  if (smtp_client.is_dead_sender(plugin, connection)) return
438
447
 
439
448
  smtp_client.authenticated = true
440
- smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtp_utf8)}`)
449
+ smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtputf8)}`)
441
450
  })
442
451
 
443
452
  // these errors only get thrown when the connection is still active
@@ -0,0 +1 @@
1
+ blocklist@example.com
@@ -0,0 +1 @@
1
+ sender@example.com
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict')
5
5
 
6
6
  const constants = require('haraka-constants')
7
7
  const DSN = require('haraka-dsn')
8
- const { Address } = require('address-rfc2821')
8
+ const { Address } = require('../address')
9
9
 
10
10
  const connection = require('../connection')
11
11
  const Server = require('../server')
@@ -676,4 +676,28 @@ describe('connection', () => {
676
676
  assert.equal(this.connection.transaction.data_bytes, 0)
677
677
  })
678
678
  })
679
+
680
+ describe('header injection', () => {
681
+ beforeEach(setUp)
682
+
683
+ it('cmd_helo rejects control chars in the host', () => {
684
+ const r = this.connection.cmd_helo('evil\rINJECTED')
685
+ assert.match(String(r), /^501/)
686
+ assert.equal(this.connection.hello.host, null)
687
+ })
688
+
689
+ it('cmd_ehlo rejects control chars in the host', () => {
690
+ const r = this.connection.cmd_ehlo('evil\r\nINJECTED')
691
+ assert.match(String(r), /^501/)
692
+ assert.equal(this.connection.hello.host, null)
693
+ })
694
+
695
+ it('auth_results strips CR/LF so it cannot inject a header', () => {
696
+ const out = this.connection.auth_results('auth=fail smtp.auth=evil\r\nInjected-Header: pwned')
697
+ assert.ok(!out.includes('\r\nInjected-Header:'))
698
+ // the only CRLF present is the legitimate folding (;\r\n\t)
699
+ assert.equal(out.replace(/;\r\n\t/g, '').includes('\r'), false)
700
+ assert.equal(out.replace(/;\r\n\t/g, '').includes('\n'), false)
701
+ })
702
+ })
679
703
  })
@@ -2,7 +2,7 @@
2
2
 
3
3
  const assert = require('node:assert')
4
4
 
5
- const { Address } = require('address-rfc2821')
5
+ const { Address } = require('../../address')
6
6
  const fixtures = require('haraka-test-fixtures')
7
7
 
8
8
  /**
@@ -98,9 +98,10 @@ const testCases = [
98
98
  trigger: (h) => HMailItem.prototype.get_mx_error.apply(h, [{ code: 'SOME-OTHER-ERR' }, {}]),
99
99
  },
100
100
  {
101
- name: 'found_mx with empty exchange triggers bounce with dsn_status 5.1.2',
101
+ // RFC 7505 NULL MX DSN.addr_null_mx 5.1.10 (per haraka-dsn).
102
+ name: 'found_mx with NULL MX (RFC 7505) triggers bounce with dsn_status 5.1.10',
102
103
  method: 'bounce',
103
- status: '5.1.2',
104
+ status: '5.1.10',
104
105
  trigger: (h) => HMailItem.prototype.found_mx.apply(h, [[{ priority: 0, exchange: '' }]]),
105
106
  },
106
107
  {
@@ -211,7 +211,7 @@ describe('outbound', () => {
211
211
  it('yields to setImmediate before opening process_delivery pipes', async () => {
212
212
  const stream = require('node:stream')
213
213
  const Transaction = require('../../transaction')
214
- const Address = require('address-rfc2821').Address
214
+ const Address = require('../../address').Address
215
215
  const outbound = require('../../outbound')
216
216
  const plugins = require('../../plugins')
217
217
 
@@ -271,7 +271,7 @@ describe('outbound', () => {
271
271
 
272
272
  it('adds missing Message-Id/Date and prepends Received before queueing', async () => {
273
273
  process.env.HARAKA_TEST_DIR = path.resolve('test')
274
- const Address = require('address-rfc2821').Address
274
+ const Address = require('../../address').Address
275
275
  const outbound = require('../../outbound')
276
276
  const plugins = require('../../plugins')
277
277
 
@@ -2,7 +2,7 @@
2
2
  const assert = require('node:assert')
3
3
  const { describe, it, beforeEach } = require('node:test')
4
4
 
5
- const { Address } = require('address-rfc2821')
5
+ const { Address } = require('../../../address')
6
6
  const fixtures = require('haraka-test-fixtures')
7
7
  const utils = require('haraka-utils')
8
8
 
@@ -0,0 +1,80 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { describe, it, beforeEach } = require('node:test')
5
+
6
+ const fixtures = require('haraka-test-fixtures')
7
+
8
+ describe('auth/auth_bridge', () => {
9
+ let plugin
10
+
11
+ beforeEach(() => {
12
+ plugin = new fixtures.plugin('auth/auth_bridge')
13
+ plugin.load_flat_ini()
14
+ })
15
+
16
+ describe('load_flat_ini', () => {
17
+ it('loads smtp_bridge.ini config', () => {
18
+ assert.ok(plugin.cfg)
19
+ assert.ok(plugin.cfg.main)
20
+ })
21
+
22
+ it('cfg.main.host defaults to localhost', () => {
23
+ assert.equal(plugin.cfg.main.host, 'localhost')
24
+ })
25
+ })
26
+
27
+ describe('check_plain_passwd', () => {
28
+ let conn
29
+
30
+ beforeEach(() => {
31
+ conn = fixtures.connection.createConnection()
32
+ })
33
+
34
+ it('calls try_auth_proxy with just host when no port configured', (t, done) => {
35
+ plugin.cfg.main = { host: 'mail.example.com' }
36
+ plugin.try_auth_proxy = (connection, host, user, passwd, cb) => {
37
+ assert.equal(host, 'mail.example.com')
38
+ assert.equal(user, 'testuser')
39
+ assert.equal(passwd, 'testpass')
40
+ cb(true)
41
+ }
42
+ plugin.check_plain_passwd(conn, 'testuser', 'testpass', (result) => {
43
+ assert.equal(result, true)
44
+ done()
45
+ })
46
+ })
47
+
48
+ it('calls try_auth_proxy with host:port when port is configured', (t, done) => {
49
+ plugin.cfg.main = { host: 'mail.example.com', port: '587' }
50
+ plugin.try_auth_proxy = (connection, host, user, passwd, cb) => {
51
+ assert.equal(host, 'mail.example.com:587')
52
+ cb(true)
53
+ }
54
+ plugin.check_plain_passwd(conn, 'testuser', 'testpass', (result) => {
55
+ assert.equal(result, true)
56
+ done()
57
+ })
58
+ })
59
+
60
+ it('passes authentication failure through to callback', (t, done) => {
61
+ plugin.cfg.main = { host: 'mail.example.com' }
62
+ plugin.try_auth_proxy = (connection, host, user, passwd, cb) => {
63
+ cb(false)
64
+ }
65
+ plugin.check_plain_passwd(conn, 'baduser', 'badpass', (result) => {
66
+ assert.equal(result, false)
67
+ done()
68
+ })
69
+ })
70
+
71
+ it('passes the connection object to try_auth_proxy', (t, done) => {
72
+ plugin.cfg.main = { host: 'mail.example.com' }
73
+ plugin.try_auth_proxy = (connection, host, user, passwd, cb) => {
74
+ assert.equal(connection, conn)
75
+ cb(true)
76
+ }
77
+ plugin.check_plain_passwd(conn, 'user', 'pass', () => done())
78
+ })
79
+ })
80
+ })
@@ -0,0 +1,128 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { describe, it, beforeEach } = require('node:test')
5
+
6
+ const fixtures = require('haraka-test-fixtures')
7
+
8
+ function makeConnection(opts = {}) {
9
+ const conn = fixtures.connection.createConnection()
10
+ conn.capabilities = []
11
+ conn.notes.allowed_auth_methods = []
12
+ conn.remote = { is_private: opts.is_private ?? false }
13
+ conn.tls = { enabled: opts.tls_enabled ?? false }
14
+ return conn
15
+ }
16
+
17
+ describe('auth/flat_file', () => {
18
+ let plugin
19
+
20
+ beforeEach(() => {
21
+ plugin = new fixtures.plugin('auth/flat_file')
22
+ plugin.inherits('auth/auth_base')
23
+ plugin.load_flat_ini()
24
+ })
25
+
26
+ describe('load_flat_ini', () => {
27
+ it('populates cfg.users as an object', () => {
28
+ assert.ok(typeof plugin.cfg.users === 'object')
29
+ })
30
+
31
+ it('cfg.users defaults to empty object when not configured', () => {
32
+ // default config has no real users
33
+ assert.deepEqual(plugin.cfg.users, {})
34
+ })
35
+ })
36
+
37
+ describe('hook_capabilities', () => {
38
+ let conn
39
+
40
+ beforeEach(() => {
41
+ conn = makeConnection()
42
+ })
43
+
44
+ it('skips for public non-TLS connection', (t, done) => {
45
+ plugin.hook_capabilities((rc) => {
46
+ assert.equal(rc, undefined)
47
+ assert.equal(conn.capabilities.length, 0)
48
+ done()
49
+ }, conn)
50
+ })
51
+
52
+ it('adds AUTH methods for private connection (non-TLS)', (t, done) => {
53
+ conn.remote.is_private = true
54
+ plugin.cfg.core.methods = 'PLAIN,LOGIN'
55
+ plugin.hook_capabilities((rc) => {
56
+ assert.equal(rc, undefined)
57
+ assert.ok(
58
+ conn.capabilities.some((c) => c.startsWith('AUTH ')),
59
+ 'AUTH capability should be present',
60
+ )
61
+ done()
62
+ }, conn)
63
+ })
64
+
65
+ it('adds AUTH methods when TLS is enabled', (t, done) => {
66
+ conn.tls.enabled = true
67
+ plugin.cfg.core.methods = 'PLAIN,LOGIN'
68
+ plugin.hook_capabilities((rc) => {
69
+ assert.equal(rc, undefined)
70
+ assert.ok(conn.capabilities.some((c) => c.startsWith('AUTH ')))
71
+ done()
72
+ }, conn)
73
+ })
74
+
75
+ it('sets allowed_auth_methods on connection notes', (t, done) => {
76
+ conn.tls.enabled = true
77
+ plugin.cfg.core.methods = 'PLAIN,LOGIN'
78
+ plugin.hook_capabilities(() => {
79
+ assert.deepEqual(conn.notes.allowed_auth_methods, ['PLAIN', 'LOGIN'])
80
+ done()
81
+ }, conn)
82
+ })
83
+
84
+ it('does not add AUTH when no methods configured', (t, done) => {
85
+ conn.tls.enabled = true
86
+ plugin.cfg.core.methods = null
87
+ plugin.hook_capabilities(() => {
88
+ assert.equal(conn.capabilities.length, 0)
89
+ done()
90
+ }, conn)
91
+ })
92
+ })
93
+
94
+ describe('get_plain_passwd', () => {
95
+ beforeEach(() => {
96
+ plugin.cfg.users = { alice: 'secret', bob: 'hunter2' }
97
+ })
98
+
99
+ it('returns password for known user', (t, done) => {
100
+ plugin.get_plain_passwd('alice', {}, (pw) => {
101
+ assert.equal(pw, 'secret')
102
+ done()
103
+ })
104
+ })
105
+
106
+ it('calls cb with no args for unknown user', (t, done) => {
107
+ plugin.get_plain_passwd('unknown', {}, (pw) => {
108
+ assert.equal(pw, undefined)
109
+ done()
110
+ })
111
+ })
112
+
113
+ it('handles multiple users', (t, done) => {
114
+ plugin.get_plain_passwd('bob', {}, (pw) => {
115
+ assert.equal(pw, 'hunter2')
116
+ done()
117
+ })
118
+ })
119
+
120
+ it('coerces password to string via toString()', (t, done) => {
121
+ plugin.cfg.users.numericuser = 12345
122
+ plugin.get_plain_passwd('numericuser', {}, (pw) => {
123
+ assert.equal(pw, '12345')
124
+ done()
125
+ })
126
+ })
127
+ })
128
+ })
@@ -0,0 +1,157 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+ const assert = require('node:assert/strict')
6
+ const { describe, it, beforeEach, after } = require('node:test')
7
+
8
+ const fixtures = require('haraka-test-fixtures')
9
+ const { Address } = require('@haraka/email-address')
10
+ require('haraka-constants').import(global)
11
+
12
+ // block_me appends to <config>/mail_from.blocklist when a sender is blocked;
13
+ // remove the artifact the 'sets block_me note' test produces.
14
+ after(() => {
15
+ fs.rmSync(path.resolve('test/config/mail_from.blocklist'), { force: true })
16
+ })
17
+
18
+ function makeConnection({
19
+ relaying = false,
20
+ mailFrom = 'sender@example.com',
21
+ rcptTo = ['blocklist@example.com'],
22
+ } = {}) {
23
+ const conn = fixtures.connection.createConnection()
24
+ conn.init_transaction()
25
+ conn.relaying = relaying
26
+ conn.transaction.mail_from = new Address(`<${mailFrom}>`)
27
+ conn.transaction.rcpt_to = rcptTo.map((r) => new Address(`<${r}>`))
28
+ conn.transaction.body = { bodytext: '', children: [] }
29
+ return conn
30
+ }
31
+
32
+ describe('block_me', () => {
33
+ let plugin
34
+
35
+ // Read config (block_me.recipient, block_me.senders) from test/config rather
36
+ // than the real config dir. block_me also appends matched senders to
37
+ // mail_from.blocklist; with this override that write lands in test/config too.
38
+ beforeEach(() => {
39
+ plugin = new fixtures.plugin('block_me')
40
+ plugin.config = plugin.config.module_config(path.resolve('test'))
41
+ })
42
+
43
+ describe('hook_data', () => {
44
+ it('enables body parsing and calls next', (t, done) => {
45
+ const conn = makeConnection()
46
+ conn.transaction.parse_body = false
47
+ plugin.hook_data((rc) => {
48
+ assert.equal(rc, undefined)
49
+ assert.equal(conn.transaction.parse_body, true)
50
+ done()
51
+ }, conn)
52
+ })
53
+ })
54
+
55
+ describe('hook_data_post', () => {
56
+ it('calls next when not relaying', (t, done) => {
57
+ const conn = makeConnection({ relaying: false })
58
+ plugin.hook_data_post((rc) => {
59
+ assert.equal(rc, undefined)
60
+ done()
61
+ }, conn)
62
+ })
63
+
64
+ it('calls next when transaction is missing', (t, done) => {
65
+ const conn = fixtures.connection.createConnection()
66
+ conn.relaying = true
67
+ conn.transaction = null
68
+ plugin.hook_data_post((rc) => {
69
+ assert.equal(rc, undefined)
70
+ done()
71
+ }, conn)
72
+ })
73
+
74
+ it('calls next when more than one recipient', (t, done) => {
75
+ const conn = makeConnection({
76
+ relaying: true,
77
+ rcptTo: ['blocklist@example.com', 'other@example.com'],
78
+ })
79
+ plugin.hook_data_post((rc) => {
80
+ assert.equal(rc, undefined)
81
+ done()
82
+ }, conn)
83
+ })
84
+
85
+ it('calls next when recipient does not match configured address', (t, done) => {
86
+ const conn = makeConnection({ relaying: true, rcptTo: ['other@example.com'] })
87
+ plugin.hook_data_post((rc) => {
88
+ assert.equal(rc, undefined)
89
+ done()
90
+ }, conn)
91
+ })
92
+
93
+ it('denies when sender is not in the allowed senders list', (t, done) => {
94
+ const conn = makeConnection({
95
+ relaying: true,
96
+ mailFrom: 'notallowed@example.com',
97
+ rcptTo: ['blocklist@example.com'],
98
+ })
99
+ plugin.hook_data_post((rc, msg) => {
100
+ assert.equal(rc, DENY)
101
+ assert.ok(msg.includes('not allowed'))
102
+ done()
103
+ }, conn)
104
+ })
105
+
106
+ it('calls next when no From header found in body', (t, done) => {
107
+ const conn = makeConnection({
108
+ relaying: true,
109
+ mailFrom: 'sender@example.com',
110
+ rcptTo: ['blocklist@example.com'],
111
+ })
112
+ conn.transaction.body = { bodytext: 'No from header here', children: [] }
113
+ plugin.hook_data_post((rc) => {
114
+ assert.equal(rc, undefined)
115
+ // note should not be set since no From header
116
+ assert.equal(conn.transaction.notes.block_me, undefined)
117
+ done()
118
+ }, conn)
119
+ })
120
+
121
+ it('sets block_me note and calls next when From is extracted', (t, done) => {
122
+ const conn = makeConnection({
123
+ relaying: true,
124
+ mailFrom: 'sender@example.com',
125
+ rcptTo: ['blocklist@example.com'],
126
+ })
127
+ conn.transaction.body = {
128
+ bodytext: 'From: Test User <block_target@example.com>',
129
+ children: [],
130
+ }
131
+ plugin.hook_data_post((rc) => {
132
+ assert.equal(rc, undefined)
133
+ assert.equal(conn.transaction.notes.block_me, 1)
134
+ done()
135
+ }, conn)
136
+ })
137
+ })
138
+
139
+ describe('hook_queue', () => {
140
+ it('returns OK when block_me note is set on transaction', (t, done) => {
141
+ const conn = makeConnection()
142
+ conn.transaction.notes.block_me = 1
143
+ plugin.hook_queue((rc) => {
144
+ assert.equal(rc, OK)
145
+ done()
146
+ }, conn)
147
+ })
148
+
149
+ it('calls next when block_me note is not set', (t, done) => {
150
+ const conn = makeConnection()
151
+ plugin.hook_queue((rc) => {
152
+ assert.equal(rc, undefined)
153
+ done()
154
+ }, conn)
155
+ })
156
+ })
157
+ })
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert/strict')
4
+ const { describe, it, beforeEach } = require('node:test')
5
+
6
+ const fixtures = require('haraka-test-fixtures')
7
+ require('haraka-constants').import(global)
8
+
9
+ function makeConnection(bodytext = '', children = []) {
10
+ const conn = fixtures.connection.createConnection()
11
+ conn.init_transaction()
12
+ conn.transaction.body = { bodytext, children }
13
+ return conn
14
+ }
15
+
16
+ describe('data.signatures', () => {
17
+ let plugin
18
+
19
+ beforeEach(() => {
20
+ plugin = new fixtures.plugin('data.signatures')
21
+ })
22
+
23
+ describe('hook_data', () => {
24
+ it('enables body parsing', (t, done) => {
25
+ const conn = makeConnection()
26
+ conn.transaction.parse_body = false
27
+ plugin.hook_data((rc) => {
28
+ assert.equal(rc, undefined)
29
+ assert.equal(conn.transaction.parse_body, true)
30
+ done()
31
+ }, conn)
32
+ })
33
+
34
+ it('calls next when there is no transaction', (t, done) => {
35
+ const conn = fixtures.connection.createConnection()
36
+ conn.transaction = null
37
+ plugin.hook_data((rc) => {
38
+ assert.equal(rc, undefined)
39
+ done()
40
+ }, conn)
41
+ })
42
+ })
43
+
44
+ describe('hook_data_post', () => {
45
+ it('calls next when there is no transaction', (t, done) => {
46
+ const conn = fixtures.connection.createConnection()
47
+ conn.transaction = null
48
+ plugin.hook_data_post((rc) => {
49
+ assert.equal(rc, undefined)
50
+ done()
51
+ }, conn)
52
+ })
53
+
54
+ it('calls next when signature list is empty', (t, done) => {
55
+ plugin.config.get = (name, type) => (type === 'list' ? [] : {})
56
+ const conn = makeConnection('This is some email body text')
57
+ plugin.hook_data_post((rc) => {
58
+ assert.equal(rc, undefined)
59
+ done()
60
+ }, conn)
61
+ })
62
+
63
+ it('denies when body matches a signature', (t, done) => {
64
+ plugin.config.get = (name, type) => (type === 'list' ? ['spam_signature_text'] : {})
65
+ const conn = makeConnection('Buy cheap meds! spam_signature_text here')
66
+ plugin.hook_data_post((rc, msg) => {
67
+ assert.equal(rc, DENY)
68
+ assert.ok(msg.includes('spam'))
69
+ done()
70
+ }, conn)
71
+ })
72
+
73
+ it('calls next when body does not match any signature', (t, done) => {
74
+ plugin.config.get = (name, type) => (type === 'list' ? ['bad_pattern'] : {})
75
+ const conn = makeConnection('Totally normal email body')
76
+ plugin.hook_data_post((rc) => {
77
+ assert.equal(rc, undefined)
78
+ done()
79
+ }, conn)
80
+ })
81
+
82
+ it('denies when a child body part matches a signature', (t, done) => {
83
+ plugin.config.get = (name, type) => (type === 'list' ? ['spam_in_child'] : {})
84
+ const conn = fixtures.connection.createConnection()
85
+ conn.init_transaction()
86
+ conn.transaction.body = {
87
+ bodytext: 'clean parent text',
88
+ children: [{ bodytext: 'spam_in_child content here', children: [] }],
89
+ }
90
+ plugin.hook_data_post((rc, msg) => {
91
+ assert.equal(rc, DENY)
92
+ done()
93
+ }, conn)
94
+ })
95
+
96
+ it('calls next when multiple signatures do not match', (t, done) => {
97
+ plugin.config.get = (name, type) => (type === 'list' ? ['sig_one', 'sig_two', 'sig_three'] : {})
98
+ const conn = makeConnection('No matching signatures here at all')
99
+ plugin.hook_data_post((rc) => {
100
+ assert.equal(rc, undefined)
101
+ done()
102
+ }, conn)
103
+ })
104
+
105
+ it('matches the first of multiple signatures', (t, done) => {
106
+ plugin.config.get = (name, type) => (type === 'list' ? ['no_match', 'buy_cheap_pills'] : {})
107
+ const conn = makeConnection('This message has buy_cheap_pills for you')
108
+ plugin.hook_data_post((rc) => {
109
+ assert.equal(rc, DENY)
110
+ done()
111
+ }, conn)
112
+ })
113
+ })
114
+ })