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/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "server",
10
10
  "email"
11
11
  ],
12
- "version": "3.1.6",
12
+ "version": "3.2.0",
13
13
  "homepage": "http://haraka.github.io",
14
14
  "repository": {
15
15
  "type": "git",
@@ -20,58 +20,57 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "address-rfc2821": "^2.2.0",
24
- "address-rfc2822": "^2.2.3",
25
- "haraka-config": "^1.5.0",
26
- "haraka-constants": "^1.0.7",
27
- "haraka-dsn": "^1.1.0",
28
- "haraka-email-message": "^1.3.3",
29
- "haraka-net-utils": "^1.8.2",
30
- "haraka-notes": "^1.1.1",
31
- "haraka-plugin-redis": "^2.0.11",
32
- "haraka-results": "^2.3.0",
33
- "haraka-tld": "^1.3.4",
34
- "haraka-utils": "^1.1.4",
23
+ "@haraka/email-address": "~3.1.3",
24
+ "haraka-config": "~1.6.0",
25
+ "haraka-constants": "~1.0.7",
26
+ "haraka-dsn": "~1.2.0",
27
+ "haraka-email-message": "~1.3.3",
28
+ "haraka-net-utils": "~1.8.2",
29
+ "haraka-notes": "~1.1.3",
30
+ "haraka-plugin-redis": "~2.0.11",
31
+ "haraka-results": "~2.3.0",
32
+ "haraka-tld": "~1.3.4",
33
+ "haraka-utils": "~1.1.4",
35
34
  "ipaddr.js": "~2.4.0",
36
- "node-gyp": "^12.3.0",
37
- "nopt": "^9.0.0",
35
+ "node-gyp": "~12.3.0",
36
+ "nopt": "~10.0.0",
38
37
  "redis": "~5.12.1",
39
- "semver": "^7.8.0"
38
+ "semver": "~7.8.0"
40
39
  },
41
40
  "optionalDependencies": {
42
- "@haraka/ocsp": "^1.2.0",
43
- "haraka-plugin-access": "^1.2.0",
44
- "haraka-plugin-aliases": "^1.0.3",
45
- "haraka-plugin-asn": "^2.0.6",
46
- "haraka-plugin-attachment": "^1.2.0",
47
- "haraka-plugin-bounce": "^2.1.2",
48
- "haraka-plugin-clamd": "^1.0.2",
49
- "haraka-plugin-dcc": "^1.0.3",
50
- "haraka-plugin-dkim": "^1.1.2",
51
- "haraka-plugin-dns-list": "^1.2.4",
52
- "haraka-plugin-early_talker": "^1.0.2",
53
- "haraka-plugin-fcrdns": "^1.1.2",
54
- "haraka-plugin-geoip": "^1.1.2",
55
- "haraka-plugin-greylist": "^1.1.1",
56
- "haraka-plugin-headers": "^1.1.0",
57
- "haraka-plugin-helo.checks": "^1.1.0",
58
- "haraka-plugin-karma": "^2.4.1",
59
- "haraka-plugin-known-senders": "^1.1.3",
60
- "haraka-plugin-limit": "^1.2.7",
61
- "haraka-plugin-mail_from.is_resolvable": "^1.2.0",
62
- "haraka-plugin-messagesniffer": "^1.0.1",
63
- "haraka-plugin-qmail-deliverable": "^1.3.5",
64
- "haraka-plugin-relay": "^1.0.1",
65
- "haraka-plugin-rspamd": "^1.5.0",
66
- "haraka-plugin-spamassassin": "^1.0.4",
67
- "haraka-plugin-spf": "^1.2.11",
68
- "haraka-plugin-syslog": "^1.0.7",
69
- "haraka-plugin-uribl": "^1.0.10"
41
+ "@haraka/ocsp": "~1.2.0",
42
+ "haraka-plugin-access": "~1.2.0",
43
+ "haraka-plugin-aliases": "~1.0.3",
44
+ "haraka-plugin-asn": "~2.1.0",
45
+ "haraka-plugin-attachment": "~1.2.0",
46
+ "haraka-plugin-bounce": "~2.1.2",
47
+ "haraka-plugin-clamd": "~1.0.2",
48
+ "haraka-plugin-dcc": "~1.0.3",
49
+ "haraka-plugin-dkim": "~1.1.2",
50
+ "haraka-plugin-dns-list": "~1.2.4",
51
+ "haraka-plugin-early_talker": "~1.0.2",
52
+ "haraka-plugin-fcrdns": "~1.1.2",
53
+ "haraka-plugin-geoip": "~1.1.2",
54
+ "haraka-plugin-greylist": "~1.1.1",
55
+ "haraka-plugin-headers": "~1.1.2",
56
+ "haraka-plugin-helo.checks": "~1.1.1",
57
+ "haraka-plugin-karma": "~2.4.1",
58
+ "haraka-plugin-known-senders": "~1.1.4",
59
+ "haraka-plugin-limit": "~1.2.7",
60
+ "haraka-plugin-mail_from.is_resolvable": "~1.2.0",
61
+ "haraka-plugin-messagesniffer": "~1.0.1",
62
+ "haraka-plugin-qmail-deliverable": "~1.3.5",
63
+ "haraka-plugin-relay": "~1.0.2",
64
+ "haraka-plugin-rspamd": "~1.5.0",
65
+ "haraka-plugin-spamassassin": "~1.0.4",
66
+ "haraka-plugin-spf": "~1.2.11",
67
+ "haraka-plugin-syslog": "~1.1.0",
68
+ "haraka-plugin-uribl": "~1.0.10"
70
69
  },
71
70
  "devDependencies": {
72
- "@haraka/eslint-config": "^2.0.4",
73
- "haraka-test-fixtures": "^1.4.3",
74
- "mock-require": "^3.0.3"
71
+ "@haraka/eslint-config": "~2.0.4",
72
+ "haraka-test-fixtures": "~1.6.0",
73
+ "mock-require": "~3.0.3"
75
74
  },
76
75
  "bugs": {
77
76
  "mail": "haraka.mail@gmail.com",
@@ -90,7 +89,8 @@
90
89
  "test": "sh ./run_tests",
91
90
  "versions": "npx npm-dep-mgr check",
92
91
  "versions:fix": "npx npm-dep-mgr update",
93
- "test:coverage": "npx c8 --reporter=text --reporter=text-summary npm test"
92
+ "test:coverage": "node --test --test-concurrency=1 --experimental-test-coverage",
93
+ "test:coverage:lcov": "mkdir -p coverage && node --test --test-concurrency=1 --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info"
94
94
  },
95
95
  "prettier": {
96
96
  "singleQuote": true,
@@ -99,6 +99,12 @@ exports.check_user = function (next, connection, credentials, method) {
99
99
  (typeof opts === 'object' ? opts.message : opts) ||
100
100
  (valid ? '2.7.0 Authentication successful' : '5.7.8 Authentication failed')
101
101
 
102
+ // The AUTH username is attacker-controlled (base64-decoded). Strip
103
+ // control chars before it is stored in notes or emitted into the
104
+ // Authentication-Results header (header injection).
105
+ // eslint-disable-next-line no-control-regex
106
+ const safe_user = String(credentials[0] ?? '').replace(/[\x00-\x1f\x7f]/g, '')
107
+
102
108
  if (valid) {
103
109
  connection.relaying = true
104
110
  connection.results.add({ name: 'relay' }, { pass: plugin.name })
@@ -108,14 +114,14 @@ exports.check_user = function (next, connection, credentials, method) {
108
114
  {
109
115
  pass: plugin.name,
110
116
  method,
111
- user: credentials[0],
117
+ user: safe_user,
112
118
  },
113
119
  )
114
120
 
115
121
  connection.respond(status_code, status_message, () => {
116
122
  connection.authheader = '(authenticated bits=0)\n'
117
123
  connection.auth_results(`auth=pass (${method.toLowerCase()})`)
118
- connection.notes.auth_user = credentials[0]
124
+ connection.notes.auth_user = safe_user
119
125
  if (!plugin.blankout_password) connection.notes.auth_passwd = credentials[1]
120
126
  next(OK)
121
127
  })
@@ -133,7 +139,7 @@ exports.check_user = function (next, connection, credentials, method) {
133
139
  }
134
140
  connection.lognotice(plugin, `delaying for ${delay} seconds`)
135
141
  // here we include the username, as shown in RFC 5451 example
136
- connection.auth_results(`auth=fail (${method.toLowerCase()}) smtp.auth=${credentials[0]}`)
142
+ connection.auth_results(`auth=fail (${method.toLowerCase()}) smtp.auth=${safe_user}`)
137
143
  setTimeout(() => {
138
144
  connection.respond(status_code, status_message, () => {
139
145
  connection.reset_transaction(() => next(OK))
@@ -93,7 +93,9 @@ exports.try_auth_proxy = function (connection, hosts, user, passwd, cb) {
93
93
  if (cmd === 'dot') {
94
94
  line = '.'
95
95
  }
96
- connection.logprotocol(self, `C: ${line}`)
96
+ // Don't leak proxied SASL credentials (AUTH PLAIN <base64>) to logs
97
+ const safe = line.replace(/^(AUTH\s+\S+\s+).+$/i, '$1[redacted]')
98
+ connection.logprotocol(self, `C: ${safe}`)
97
99
  command = cmd.toLowerCase()
98
100
  this.write(`${line}\r\n`)
99
101
  // Clear response buffer from previous command
@@ -128,18 +130,19 @@ exports.try_auth_proxy = function (connection, hosts, user, passwd, cb) {
128
130
  for (const i in response) {
129
131
  if (/^STARTTLS/.test(response[i])) {
130
132
  if (secure) continue // silly remote, we've already upgraded
131
- // Use TLS opportunistically if we found the key and certificate
133
+ // Opportunistic TLS: a client does not need its own
134
+ // certificate to negotiate TLS, so always STARTTLS when
135
+ // the backend offers it. The local key/cert are only
136
+ // attached if configured (mutual TLS), not required.
132
137
  key = self.config.get(self.tls_cfg.main.key || 'tls_key.pem', 'binary')
133
138
  cert = self.config.get(self.tls_cfg.main.cert || 'tls_cert.pem', 'binary')
134
- if (key && cert) {
135
- this.on('secure', () => {
136
- if (secure) return
137
- secure = true
138
- socket.send_command('EHLO', connection.local.host)
139
- })
140
- socket.send_command('STARTTLS')
141
- return
142
- }
139
+ this.on('secure', () => {
140
+ if (secure) return
141
+ secure = true
142
+ socket.send_command('EHLO', connection.local.host)
143
+ })
144
+ socket.send_command('STARTTLS')
145
+ return
143
146
  } else if (/^AUTH /.test(response[i])) {
144
147
  // Parse supported AUTH methods
145
148
  const parse = /^AUTH (.+)$/.exec(response[i])
@@ -4,6 +4,7 @@
4
4
  // mail_from.blocklist plugin for this to work fully.
5
5
 
6
6
  const fs = require('node:fs')
7
+ const path = require('node:path')
7
8
  const utils = require('haraka-utils')
8
9
 
9
10
  exports.hook_data = (next, connection) => {
@@ -24,12 +25,12 @@ exports.hook_data_post = function (next, connection) {
24
25
  }
25
26
 
26
27
  // Check recipient is the right one
27
- if (connection.transaction.rcpt_to[0].address().toLowerCase() != recip) {
28
+ if (connection.transaction.rcpt_to[0].address.toLowerCase() != recip) {
28
29
  return next()
29
30
  }
30
31
 
31
32
  // Check sender is in list
32
- const sender = connection.transaction.mail_from.address()
33
+ const sender = connection.transaction.mail_from.address
33
34
  if (!utils.in_array(sender, senders)) {
34
35
  return next(DENY, `You are not allowed to block mail, ${sender}`)
35
36
  }
@@ -45,8 +46,9 @@ exports.hook_data_post = function (next, connection) {
45
46
 
46
47
  connection.transaction.notes.block_me = 1
47
48
 
48
- // add to mail_from.blocklist
49
- fs.open('./config/mail_from.blocklist', 'a', (err, fd) => {
49
+ // add to mail_from.blocklist, in the same config dir the plugin reads from
50
+ const blocklist = path.join(this.config.root_path, 'mail_from.blocklist')
51
+ fs.open(blocklist, 'a', (err, fd) => {
50
52
  if (err) {
51
53
  connection.logerror(this, `Unable to append to mail_from.blocklist: ${err}`)
52
54
  return
@@ -22,9 +22,11 @@ exports.hook_data_post = (next, connection) => {
22
22
  let user = connection.notes.auth_user
23
23
  let domain
24
24
  const idx = user.indexOf('@')
25
- if (idx) {
25
+ if (idx > 0) {
26
26
  // If the username is qualified (e.g. user@domain.com)
27
27
  // then we make the @domain.com part optional in the regexp.
28
+ // (idx === -1 is "no @"; idx === 0 is a leading @ — neither is
29
+ // a qualified user@domain, so don't split.)
28
30
  domain = user.substring(idx)
29
31
  user = user.substring(0, idx)
30
32
  }
@@ -64,17 +64,17 @@ exports.hook_init_master = function (next, server) {
64
64
  server.notes.pt_connections++
65
65
  server.notes.pt_concurrent_cluster[msg.wid]++
66
66
  count = 0
67
- Object.keys(server.notes.pt_concurrent_cluster).forEach((id) => {
67
+ for (const id of Object.keys(server.notes.pt_concurrent_cluster)) {
68
68
  count += server.notes.pt_concurrent_cluster[id]
69
- })
69
+ }
70
70
  server.notes.pt_concurrent = count
71
71
  break
72
72
  case 'process_title.disconnect':
73
73
  server.notes.pt_concurrent_cluster[msg.wid]--
74
74
  count = 0
75
- Object.keys(server.notes.pt_concurrent_cluster).forEach((id) => {
75
+ for (const id of Object.keys(server.notes.pt_concurrent_cluster)) {
76
76
  count += server.notes.pt_concurrent_cluster[id]
77
- })
77
+ }
78
78
  server.notes.pt_concurrent = count
79
79
  break
80
80
  case 'process_title.recipient':
@@ -109,9 +109,9 @@ exports.hook_init_master = function (next, server) {
109
109
  delete server.notes.pt_concurrent_cluster[worker.id]
110
110
  // Update concurrency
111
111
  let count = 0
112
- Object.keys(server.notes.pt_concurrent_cluster).forEach((id) => {
112
+ for (const id of Object.keys(server.notes.pt_concurrent_cluster)) {
113
113
  count += server.notes.pt_concurrent_cluster[id]
114
- })
114
+ }
115
115
  server.notes.pt_concurrent = count
116
116
  server.notes.pt_child_exits++
117
117
  })
@@ -28,6 +28,20 @@ exports.load_qmail_queue_ini = function () {
28
28
  )
29
29
  }
30
30
 
31
+ // qmail-queue envelope: F<sender>\0 (T<rcpt>\0)* \0
32
+ // Built dynamically, sized to exactly the bytes needed.
33
+ // doesn't emit zero padding after the terminating NUL.
34
+ // encodes non-ASCII (SMTPUTF8) addresses correctly
35
+ exports.build_envelope = function (transaction) {
36
+ const NUL = Buffer.from([0])
37
+ const parts = [Buffer.from('F'), Buffer.from(transaction.mail_from.address), NUL]
38
+ for (const rcpt of transaction.rcpt_to) {
39
+ parts.push(Buffer.from('T'), Buffer.from(rcpt.address), NUL)
40
+ }
41
+ parts.push(NUL)
42
+ return Buffer.concat(parts)
43
+ }
44
+
31
45
  exports.hook_queue = function (next, connection) {
32
46
  const plugin = this
33
47
 
@@ -72,25 +86,7 @@ exports.hook_queue = function (next, connection) {
72
86
  return
73
87
  }
74
88
  plugin.loginfo('Message Stream sent to qmail. Now sending envelope')
75
- // now send envelope
76
- // Hope this will be big enough...
77
- const buf = Buffer.alloc(4096)
78
- let p = 0
79
- buf[p++] = 70
80
- const mail_from = connection.transaction.mail_from.address()
81
- for (let i = 0; i < mail_from.length; i++) {
82
- buf[p++] = mail_from.charCodeAt(i)
83
- }
84
- buf[p++] = 0
85
- connection.transaction.rcpt_to.forEach((rcpt) => {
86
- buf[p++] = 84
87
- const rcpt_to = rcpt.address()
88
- for (let j = 0; j < rcpt_to.length; j++) {
89
- buf[p++] = rcpt_to.charCodeAt(j)
90
- }
91
- buf[p++] = 0
92
- })
93
- buf[p++] = 0
89
+ const buf = plugin.build_envelope(connection.transaction)
94
90
  qmail_queue.stdout.on('error', (err) => {}) // stdout throws an error on close
95
91
  qmail_queue.stdout.end(buf)
96
92
  })
@@ -7,6 +7,7 @@
7
7
  const url = require('node:url')
8
8
 
9
9
  const smtp_client_mod = require('../../smtp_client')
10
+ const tls_socket = require('../../tls_socket')
10
11
 
11
12
  exports.register = function () {
12
13
  this.load_errs = []
@@ -46,12 +47,19 @@ exports.load_smtp_forward_ini = function () {
46
47
  '-main.check_recipient',
47
48
  '*.enable_tls',
48
49
  '*.enable_outbound',
50
+ '+tls.requestCert',
51
+ '+tls.honorCipherOrder',
52
+ '-tls.rejectUnauthorized',
49
53
  ],
50
54
  },
51
55
  () => {
52
56
  this.load_smtp_forward_ini()
53
57
  },
54
58
  )
59
+
60
+ // Build backend TLS options from tls.ini [main] + this plugin's [tls] section.
61
+ // Re-derived on every (re)load so SIGHUP picks up edits.
62
+ this.tls_options = tls_socket.load_plugin_tls_options(this.cfg.tls || {})
55
63
  }
56
64
 
57
65
  exports.get_config = function (conn) {
@@ -61,7 +69,7 @@ exports.get_config = function (conn) {
61
69
  if (this.cfg.main.domain_selector === 'mail_from') {
62
70
  if (!conn.transaction.mail_from) return this.cfg.main
63
71
  dom = conn.transaction.mail_from.host
64
- address = conn.transaction.mail_from.address()
72
+ address = conn.transaction.mail_from.address
65
73
  } else {
66
74
  if (!conn.transaction.rcpt_to[0]) return this.cfg.main
67
75
  dom = conn.transaction.rcpt_to[0].host
@@ -84,7 +92,7 @@ exports.check_sender = function (next, connection, params) {
84
92
  const txn = connection?.transaction
85
93
  if (!txn) return
86
94
 
87
- const email = params[0].address()
95
+ const email = params[0].address
88
96
  if (!email) {
89
97
  txn.results.add(this, { skip: 'mail_from.null', emit: true })
90
98
  return next()
@@ -264,7 +272,7 @@ exports.queue_forward = function (next, connection) {
264
272
  smtp_client.send_command('DATA')
265
273
  return
266
274
  }
267
- smtp_client.send_command('RCPT', `TO:${txn.rcpt_to[rcpt].format(!smtp_client.smtp_utf8)}`)
275
+ smtp_client.send_command('RCPT', `TO:${txn.rcpt_to[rcpt].format(!smtp_client.smtputf8)}`)
268
276
  rcpt++
269
277
  }
270
278
 
@@ -354,10 +362,10 @@ exports.get_mx = function (next, hmail, domain) {
354
362
  }
355
363
 
356
364
  // apply auth/mx options
357
- mx_opts.forEach((o) => {
358
- if (cfg[o] === undefined) return
365
+ for (const o of mx_opts) {
366
+ if (cfg[o] === undefined) continue
359
367
  mx[o] = this.cfg[dom][o]
360
- })
368
+ }
361
369
 
362
370
  next(OK, mx)
363
371
  }
@@ -4,7 +4,8 @@
4
4
  // and passes back any errors seen on the ongoing server to the
5
5
  // originating server.
6
6
 
7
- const smtp_client_mod = require('./smtp_client')
7
+ const smtp_client_mod = require('../../smtp_client')
8
+ const tls_socket = require('../../tls_socket')
8
9
 
9
10
  exports.register = function () {
10
11
  this.load_smtp_proxy_ini()
@@ -18,13 +19,23 @@ exports.load_smtp_proxy_ini = function () {
18
19
  this.cfg = this.config.get(
19
20
  'smtp_proxy.ini',
20
21
  {
21
- booleans: ['-main.enable_tls', '+main.enable_outbound'],
22
+ booleans: [
23
+ '-main.enable_tls',
24
+ '+main.enable_outbound',
25
+ '+tls.requestCert',
26
+ '+tls.honorCipherOrder',
27
+ '-tls.rejectUnauthorized',
28
+ ],
22
29
  },
23
30
  () => {
24
31
  this.load_smtp_proxy_ini()
25
32
  },
26
33
  )
27
34
 
35
+ // Build backend TLS options from tls.ini [main] + this plugin's [tls] section.
36
+ // Re-derived on every (re)load so SIGHUP picks up edits.
37
+ this.tls_options = tls_socket.load_plugin_tls_options(this.cfg.tls || {})
38
+
28
39
  if (this.cfg.main.enable_outbound) {
29
40
  this.lognotice('outbound enabled, will default to disabled in Haraka v3 (see #1472)')
30
41
  }
@@ -82,7 +93,7 @@ exports.hook_rcpt_ok = (next, connection, recipient) => {
82
93
  return
83
94
  }
84
95
  smtp_client.next = next
85
- smtp_client.send_command('RCPT', `TO:${recipient.format(!smtp_client.smtp_utf8)}`)
96
+ smtp_client.send_command('RCPT', `TO:${recipient.format(!smtp_client.smtputf8)}`)
86
97
  }
87
98
 
88
99
  exports.hook_data = (next, connection) => {
@@ -26,7 +26,7 @@ exports.hook_mail = function (next, connection, params) {
26
26
  const txn = connection?.transaction
27
27
  if (!txn) return
28
28
 
29
- const email = params[0].address()
29
+ const email = params[0].address
30
30
  if (!email) {
31
31
  txn.results.add(this, { skip: 'mail_from.null', emit: true })
32
32
  return next()
@@ -4,14 +4,14 @@
4
4
 
5
5
  exports.hook_rcpt = (next, connection, params) => {
6
6
  if (connection?.transaction) {
7
- connection.transaction.add_header('X-Envelope-To', params[0].address())
7
+ connection.transaction.add_header('X-Envelope-To', params[0].address)
8
8
  }
9
9
  next()
10
10
  }
11
11
 
12
12
  exports.hook_mail = (next, connection, params) => {
13
13
  if (connection?.transaction) {
14
- connection.transaction.add_header('X-Envelope-From', params[0].address())
14
+ connection.transaction.add_header('X-Envelope-From', params[0].address)
15
15
  }
16
16
  next()
17
17
  }
package/plugins/status.js CHANGED
@@ -171,7 +171,8 @@ exports.hook_init_master = function (next) {
171
171
  if (msg.event !== 'status.request') return
172
172
 
173
173
  plugin.call_workers(msg, (response) => {
174
- msg.result = response.filter((el) => el != null)
174
+ const valid = response.filter((el) => el != null)
175
+ msg.result = plugin.merge_worker_responses(msg.params, valid)
175
176
  msg.event = 'status.result'
176
177
  sender.send(msg)
177
178
  })
@@ -212,13 +213,41 @@ exports.call_master = (cmd, cb) => {
212
213
 
213
214
  exports.call_workers = function (cmd, cb) {
214
215
  Promise.allSettled(Object.values(server.cluster.workers).map((w) => this.call_worker(w, cmd))).then((r) => {
215
- cb(
216
- // r.filter(s => s.status === 'rejected').flatMap(s => s.reason),
217
- r.filter((s) => s.status === 'fulfilled').flatMap((s) => s.value),
218
- )
216
+ cb(r.filter((s) => s.status === 'fulfilled').map((s) => s.value))
219
217
  })
220
218
  }
221
219
 
220
+ // Merge per-worker responses into a single result matching non-cluster output shape.
221
+ exports.merge_worker_responses = (params, results) => {
222
+ const cmd = params.trim().split(/\s+/).slice(0, 2).join(' ').toUpperCase()
223
+
224
+ switch (cmd) {
225
+ case 'POOL LIST': {
226
+ return Object.assign({}, ...results)
227
+ }
228
+ case 'QUEUE INSPECT': {
229
+ const merged = { delivery_queue: [], temp_fail_queue: [] }
230
+ for (const r of results) {
231
+ if (!r) continue
232
+ if (Array.isArray(r.delivery_queue)) merged.delivery_queue.push(...r.delivery_queue)
233
+ if (Array.isArray(r.temp_fail_queue)) merged.temp_fail_queue.push(...r.temp_fail_queue)
234
+ }
235
+ return merged
236
+ }
237
+ case 'QUEUE STATS': {
238
+ const totals = [0, 0, 0]
239
+ for (const r of results) {
240
+ if (!r) continue
241
+ const parts = String(r).split('/')
242
+ for (let i = 0; i < 3; i++) totals[i] += parseInt(parts[i] ?? 0, 10)
243
+ }
244
+ return totals.join('/')
245
+ }
246
+ default:
247
+ return results
248
+ }
249
+ }
250
+
222
251
  // sends command to worker and then wait for response or timeout
223
252
  exports.call_worker = (worker, cmd) => {
224
253
  return new Promise((resolve) => {
package/plugins/tls.js CHANGED
@@ -85,6 +85,14 @@ exports.upgrade_connection = function (next, connection, params) {
85
85
  /* Watch for STARTTLS directive from client. */
86
86
  if (params[0].toUpperCase() !== 'STARTTLS') return next()
87
87
 
88
+ // RFC 3207 §4: discard any plaintext the client pipelined after
89
+ // STARTTLS. Otherwise connection.respond() restores state to CMD and
90
+ // re-enters _process_data(), parsing and executing those buffered
91
+ // cleartext commands before the TLS handshake completes (STARTTLS
92
+ // command injection). Mirrors the buffer-nuke already used for
93
+ // SSL-over-plaintext detection in connection.process_line().
94
+ connection.current_data = null
95
+
88
96
  /* Respond to STARTTLS command. */
89
97
  connection.respond(220, 'Go ahead.')
90
98
 
@@ -109,7 +117,7 @@ exports.upgrade_connection = function (next, connection, params) {
109
117
  connection.notes.cleanUpDisconnect = nextOnce
110
118
 
111
119
  /* Upgrade the connection to TLS. */
112
- connection.client.upgrade((verified, verifyErr, cert, cipher) => {
120
+ connection.client.upgrade((verified, verifyError, cert, cipher) => {
113
121
  if (called_next) return
114
122
  clearTimeout(connection.notes.tls_timer)
115
123
  called_next = true
@@ -117,12 +125,12 @@ exports.upgrade_connection = function (next, connection, params) {
117
125
  connection.setTLS({
118
126
  cipher,
119
127
  verified,
120
- authorizationError: verifyErr,
128
+ verifyError,
121
129
  peerCertificate: cert,
122
130
  })
123
131
 
124
132
  connection.results.add(plugin, connection.tls)
125
- plugin.emit_upgrade_msg(connection, verified, verifyErr, cert, cipher)
133
+ plugin.emit_upgrade_msg(connection, verified, verifyError, cert, cipher)
126
134
  next(OK)
127
135
  })
128
136
  })
@@ -135,13 +143,13 @@ exports.hook_disconnect = (next, connection) => {
135
143
  next()
136
144
  }
137
145
 
138
- exports.emit_upgrade_msg = function (conn, verified, verifyErr, cert, cipher) {
146
+ exports.emit_upgrade_msg = function (conn, verified, verifyError, cert, cipher) {
139
147
  let msg = 'secured:'
140
148
  if (cipher) {
141
149
  msg += ` cipher=${cipher.name} version=${cipher.version}`
142
150
  }
143
151
  msg += ` verified=${verified}`
144
- if (verifyErr) msg += ` error="${verifyErr}"`
152
+ if (verifyError) msg += ` error="${verifyError}"`
145
153
  if (cert) {
146
154
  if (cert.subject) {
147
155
  msg += ` cn="${cert.subject.CN}" organization="${cert.subject.O}"`
@@ -110,7 +110,9 @@ exports.hook_unrecognized_command = function (next, connection, params) {
110
110
  connection.set('remote.login', xclient.login ? xclient.login : undefined)
111
111
  connection.set('hello.host', xclient.helo ? xclient.helo : undefined)
112
112
  connection.set('local.ip', xclient.destaddr ? xclient.destaddr : undefined)
113
- connection.set('local.port', xclient.destport ? xclient.destport : undefined)
113
+ // parseInt so downstream numeric checks (e.g. the 587/465 auth-required
114
+ // gate in connection.cmd_mail) work; matches the PROXY path.
115
+ connection.set('local.port', xclient.destport ? parseInt(xclient.destport, 10) : undefined)
114
116
  if (xclient.proto) {
115
117
  connection.set('hello', 'verb', xclient.proto === 'esmtp' ? 'EHLO' : 'HELO')
116
118
  }
package/server.js CHANGED
@@ -175,7 +175,7 @@ Server._graceful = async (shutdown) => {
175
175
  const todo = []
176
176
 
177
177
  for (const id of Object.keys(cluster.workers)) {
178
- todo.push((id) => {
178
+ todo.push(() => {
179
179
  return new Promise((resolve) => {
180
180
  Server.lognotice(`Killing worker: ${id}`)
181
181
  const worker = cluster.workers[id]
@@ -223,8 +223,10 @@ Server._graceful = async (shutdown) => {
223
223
  }
224
224
 
225
225
  while (todo.length) {
226
- // process batches of workers
227
- await Promise.all(todo.splice(0, limit))
226
+ // process batches of workers: invoke each queued thunk so we
227
+ // actually await the worker shutdown promises (passing the bare
228
+ // functions to Promise.all would resolve immediately).
229
+ await Promise.all(todo.splice(0, limit).map((fn) => fn()))
228
230
  }
229
231
 
230
232
  if (shutdown) {