Haraka 3.1.4 → 3.1.6

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 (55) hide show
  1. package/.prettierignore +1 -1
  2. package/{Changes.md → CHANGELOG.md} +34 -0
  3. package/CONTRIBUTORS.md +26 -26
  4. package/README.md +68 -93
  5. package/SECURITY.md +178 -0
  6. package/bin/haraka +7 -14
  7. package/config/plugins +0 -3
  8. package/docs/Connection.md +126 -39
  9. package/docs/CoreConfig.md +92 -74
  10. package/docs/HAProxy.md +41 -25
  11. package/docs/Logging.md +68 -38
  12. package/docs/Outbound.md +124 -179
  13. package/docs/Plugins.md +38 -59
  14. package/docs/Transaction.md +78 -83
  15. package/docs/Tutorial.md +122 -209
  16. package/docs/plugins/aliases.md +1 -141
  17. package/docs/plugins/auth/auth_ldap.md +2 -39
  18. package/docs/plugins/max_unrecognized_commands.md +4 -18
  19. package/docs/plugins/process_title.md +3 -3
  20. package/docs/plugins/reseed_rng.md +11 -13
  21. package/docs/plugins/tls.md +7 -7
  22. package/docs/plugins/toobusy.md +10 -4
  23. package/docs/tutorials/SettingUpOutbound.md +40 -48
  24. package/endpoint.js +32 -2
  25. package/outbound/hmail.js +3 -2
  26. package/outbound/index.js +3 -0
  27. package/package.json +21 -34
  28. package/plugins/queue/smtp_forward.js +4 -4
  29. package/run_tests +3 -15
  30. package/server.js +17 -7
  31. package/smtp_client.js +8 -6
  32. package/test/connection.js +234 -0
  33. package/test/endpoint.js +32 -4
  34. package/test/host_pool.js +57 -31
  35. package/test/logger.js +75 -135
  36. package/test/outbound/bounce_net_errors.js +87 -131
  37. package/test/outbound/bounce_rfc3464.js +177 -254
  38. package/test/outbound/hmail.js +19 -0
  39. package/test/outbound/index.js +189 -0
  40. package/test/outbound/queue.js +92 -0
  41. package/test/plugins/auth/auth_base.js +39 -44
  42. package/test/plugins/auth/auth_vpopmaild.js +8 -9
  43. package/test/plugins/queue/smtp_forward.js +953 -183
  44. package/test/plugins/rcpt_to.host_list_base.js +58 -93
  45. package/test/plugins/rcpt_to.in_host_list.js +126 -175
  46. package/test/plugins/record_envelope_addresses.js +8 -8
  47. package/test/plugins/status.js +10 -10
  48. package/test/plugins/tls.js +9 -19
  49. package/test/plugins/xclient.js +75 -110
  50. package/test/plugins.js +10 -13
  51. package/test/rfc1869.js +50 -70
  52. package/test/server.js +438 -421
  53. package/test/smtp_client.js +1192 -218
  54. package/test/tls_socket.js +242 -0
  55. package/tls_socket.js +18 -22
@@ -0,0 +1,242 @@
1
+ 'use strict'
2
+
3
+ const test = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const path = require('node:path')
6
+ const net = require('node:net')
7
+ const tls = require('node:tls')
8
+ const fs = require('node:fs')
9
+ const { EventEmitter } = require('node:events')
10
+
11
+ // Mock dependencies before requiring the target
12
+ const mock = require('node:test').mock
13
+
14
+ const tls_socket = require('../tls_socket')
15
+
16
+ const TEST_CERT = fs.readFileSync(path.join(__dirname, 'config/tls_cert.pem'))
17
+ const TEST_KEY = fs.readFileSync(path.join(__dirname, 'config/tls_key.pem'))
18
+
19
+ test('tls_socket', async (t) => {
20
+ await t.test('parse_x509', async (t) => {
21
+ await t.test('handles empty string', async () => {
22
+ const res = await tls_socket.parse_x509('')
23
+ assert.deepEqual(res, {})
24
+ })
25
+
26
+ await t.test('handles null/undefined', async () => {
27
+ const res = await tls_socket.parse_x509(null)
28
+ assert.deepEqual(res, {})
29
+ })
30
+
31
+ // This would exercise the uninitialized res.names bug if we had a cert string
32
+ // but since it spawns openssl, we'd need to mock spawn or provide a real cert.
33
+ })
34
+
35
+ await t.test('get_rejectUnauthorized', async (t) => {
36
+ await t.test('returns true if rejectUnauthorized is true', () => {
37
+ assert.strictEqual(tls_socket.get_rejectUnauthorized(true, 25, [25]), true)
38
+ })
39
+
40
+ await t.test('returns true if port is in port_list', () => {
41
+ assert.strictEqual(tls_socket.get_rejectUnauthorized(false, 465, [465]), true)
42
+ })
43
+
44
+ await t.test('returns false if port is not in port_list', () => {
45
+ assert.strictEqual(tls_socket.get_rejectUnauthorized(false, 25, [465]), false)
46
+ })
47
+ })
48
+
49
+ await t.test('SNICallback', async (t) => {
50
+ await t.test('calls sniDone with default context if servername unknown', (t, done) => {
51
+ // This test requires some setup of ctxByHost which is private to the module
52
+ // but we can test if it's a function
53
+ assert.strictEqual(typeof tls_socket.SNICallback, 'function')
54
+ done()
55
+ })
56
+ })
57
+
58
+ await t.test('pluggableStream', async (t) => {
59
+ // This is a class inside the file, but not exported.
60
+ // We can test it via createServer or connect if we mock net.
61
+ })
62
+
63
+ await t.test('connect', async (t) => {
64
+ // Exercise the `new tls.connect` bug
65
+ // We can't easily catch the 'new' keyword usage without proxying tls.connect
66
+ assert.strictEqual(typeof tls_socket.connect, 'function')
67
+ })
68
+
69
+ await t.test('connect upgrade error propagation', async (t) => {
70
+ // Verify that TLS errors during socket.upgrade() are propagated to the outer
71
+ // pluggableStream socket, not silently swallowed.
72
+ // A TLS server that requires a client cert; connecting without one triggers
73
+ // a post-handshake "certificate required" alert (TLSv1.3).
74
+ await t.test('emits error on outer socket when client cert is missing', async () => {
75
+ const server = tls.createServer(
76
+ { cert: TEST_CERT, key: TEST_KEY, requestCert: true, rejectUnauthorized: true },
77
+ () => {},
78
+ )
79
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
80
+ const { port } = server.address()
81
+
82
+ try {
83
+ const err = await new Promise((resolve, reject) => {
84
+ const socket = tls_socket.connect({ host: '127.0.0.1', port })
85
+ socket.upgrade({ rejectUnauthorized: false }, () => {})
86
+ socket.on('error', resolve)
87
+ socket.on('close', () => reject(new Error('closed without error')))
88
+ setTimeout(() => reject(new Error('timeout')), 3000)
89
+ })
90
+ assert.ok(
91
+ /certificate required|socket hang up|disconnected/.test(err.message),
92
+ `unexpected error: ${err.message}`,
93
+ )
94
+ assert.equal(err.source, 'tls', 'error.source should be "tls"')
95
+ } finally {
96
+ await new Promise((resolve) => server.close(resolve))
97
+ }
98
+ })
99
+
100
+ await t.test('second error handler does not crash when first handler removes all listeners', () => {
101
+ // Regression test for issue #3553
102
+ const originalNetConnect = net.connect
103
+ const originalTlsConnect = tls.connect
104
+ const originalTlsValid = tls_socket.tls_valid
105
+
106
+ const fakeCrypto = new EventEmitter()
107
+ fakeCrypto.writable = true
108
+ fakeCrypto.removeAllListeners = EventEmitter.prototype.removeAllListeners
109
+ fakeCrypto.setTimeout = () => {}
110
+ fakeCrypto.setKeepAlive = () => {}
111
+
112
+ let capturedCleartext
113
+ net.connect = () => fakeCrypto
114
+ tls.connect = () => {
115
+ capturedCleartext = new EventEmitter()
116
+ capturedCleartext.writable = true
117
+ capturedCleartext.setTimeout = () => {}
118
+ capturedCleartext.setKeepAlive = () => {}
119
+ return capturedCleartext
120
+ }
121
+ tls_socket.tls_valid = false
122
+
123
+ try {
124
+ const socket = tls_socket.connect({ host: 'bad-tls.example.com', port: 25 })
125
+
126
+ // Simulate what release_client does: strip all listeners on first error
127
+ socket.once('error', () => socket.removeAllListeners())
128
+
129
+ socket.upgrade({}, () => {})
130
+
131
+ // capturedCleartext now has two 'error' handlers (on from upgrade, once from attach).
132
+ // Emitting error must NOT throw even though the first handler removes all
133
+ // listeners from the outer socket before the second fires.
134
+ const tlsError = new Error('dh key too small')
135
+ assert.doesNotThrow(() => capturedCleartext.emit('error', tlsError))
136
+ } finally {
137
+ net.connect = originalNetConnect
138
+ tls.connect = originalTlsConnect
139
+ tls_socket.tls_valid = originalTlsValid
140
+ }
141
+ })
142
+ })
143
+
144
+ await t.test('getSocketOpts', async (t) => {
145
+ // Exercise the typo path (would requires failing config.getDir)
146
+ assert.strictEqual(typeof tls_socket.getSocketOpts, 'function')
147
+ })
148
+
149
+ await t.test('getSocketOpts handles missing tls dir', async () => {
150
+ const originalGetCertsDir = tls_socket.get_certs_dir
151
+ tls_socket.get_certs_dir = async () => {
152
+ const err = new Error('missing')
153
+ err.code = 'ENOENT'
154
+ throw err
155
+ }
156
+ try {
157
+ const opts = await tls_socket.getSocketOpts('*')
158
+ assert.ok(opts)
159
+ } finally {
160
+ tls_socket.get_certs_dir = originalGetCertsDir
161
+ }
162
+ })
163
+
164
+ await t.test('connect upgrade applies mutual auth cert and timeout/keepalive', async () => {
165
+ const originalNetConnect = net.connect
166
+ const originalTlsConnect = tls.connect
167
+ const originalTlsValid = tls_socket.tls_valid
168
+ const originalCfg = tls_socket.cfg
169
+ const originalCertMap = {
170
+ default: tls_socket.certsByHost['*'],
171
+ host: tls_socket.certsByHost['client-cert.example'],
172
+ }
173
+
174
+ const fakeSocket = new EventEmitter()
175
+ fakeSocket.remotePort = 2525
176
+ fakeSocket.remoteAddress = '127.0.0.1'
177
+ fakeSocket.localPort = 25
178
+ fakeSocket.localAddress = '127.0.0.1'
179
+ fakeSocket.writable = true
180
+ fakeSocket.removeAllListeners = EventEmitter.prototype.removeAllListeners
181
+ fakeSocket.setTimeout = () => {}
182
+ fakeSocket.setKeepAlive = () => {}
183
+
184
+ let capturedOptions
185
+ let timeoutSeen = null
186
+ let keepaliveSeen = null
187
+
188
+ net.connect = () => fakeSocket
189
+ tls.connect = (options) => {
190
+ capturedOptions = options
191
+ const clear = new EventEmitter()
192
+ clear.writable = true
193
+ clear.getCipher = () => ({ name: 'TLS_AES_256_GCM_SHA384' })
194
+ clear.getProtocol = () => 'TLSv1.3'
195
+ clear.getPeerCertificate = () => ({})
196
+ clear.setTimeout = (ms) => {
197
+ timeoutSeen = ms
198
+ }
199
+ clear.setKeepAlive = (value) => {
200
+ keepaliveSeen = value
201
+ }
202
+ process.nextTick(() => clear.emit('secureConnect'))
203
+ return clear
204
+ }
205
+
206
+ tls_socket.tls_valid = true
207
+ tls_socket.cfg = {
208
+ mutual_auth_hosts: { 'mx.example.com': 'client-cert.example' },
209
+ mutual_auth_hosts_exclude: {},
210
+ main: { mutual_tls: false },
211
+ }
212
+ tls_socket.certsByHost['*'] = { key: 'default-key', cert: 'default-cert' }
213
+ tls_socket.certsByHost['client-cert.example'] = { key: 'host-key', cert: 'host-cert' }
214
+
215
+ try {
216
+ const socket = tls_socket.connect({ host: 'mx.example.com', port: 25 })
217
+ socket.setTimeout(3210)
218
+ socket.setKeepAlive(true)
219
+
220
+ await new Promise((resolve) => {
221
+ socket.upgrade({ rejectUnauthorized: false }, () => resolve())
222
+ })
223
+
224
+ assert.equal(capturedOptions.key, 'host-key')
225
+ assert.equal(capturedOptions.cert, 'host-cert')
226
+ assert.equal(capturedOptions.socket, fakeSocket)
227
+ assert.equal(timeoutSeen, 3210)
228
+ assert.equal(keepaliveSeen, true)
229
+ } finally {
230
+ net.connect = originalNetConnect
231
+ tls.connect = originalTlsConnect
232
+ tls_socket.tls_valid = originalTlsValid
233
+ tls_socket.cfg = originalCfg
234
+ tls_socket.certsByHost['*'] = originalCertMap.default
235
+ if (originalCertMap.host === undefined) {
236
+ delete tls_socket.certsByHost['client-cert.example']
237
+ } else {
238
+ tls_socket.certsByHost['client-cert.example'] = originalCertMap.host
239
+ }
240
+ }
241
+ })
242
+ })
package/tls_socket.js CHANGED
@@ -76,7 +76,7 @@ class pluggableStream extends stream.Stream {
76
76
  this.targetsocket.once('error', (exception) => {
77
77
  this.writable = this.targetsocket.writable
78
78
  exception.source = 'tls'
79
- this.emit('error', exception)
79
+ if (this.listenerCount('error') > 0) this.emit('error', exception)
80
80
  })
81
81
  this.targetsocket.on('timeout', () => {
82
82
  this.emit('timeout')
@@ -159,21 +159,16 @@ exports.parse_x509 = async (string) => {
159
159
  const res = {}
160
160
  if (!string) return res
161
161
 
162
- const keyRe = new RegExp('([-]+BEGIN (?:\\w+ )?PRIVATE KEY[-]+[^-]*[-]+END (?:\\w+ )?PRIVATE KEY[-]+)', 'gm')
162
+ const keyRe = /([-]+BEGIN (?:\w+ )?PRIVATE KEY[-]+[^-]*[-]+END (?:\w+ )?PRIVATE KEY[-]+)/gm
163
163
  res.keys = string.match(keyRe)
164
164
 
165
- const certRe = new RegExp('([-]+BEGIN CERTIFICATE[-]+[^-]*[-]+END CERTIFICATE[-]+)', 'gm')
165
+ const certRe = /([-]+BEGIN CERTIFICATE[-]+[^-]*[-]+END CERTIFICATE[-]+)/gm
166
166
  res.chain = string.match(certRe)
167
167
 
168
168
  if (res.chain?.length) {
169
- const opensslArgs = [res.chain[0], 'x509', '-noout']
170
- // shush openssl, https://github.com/openssl/openssl/issues/22893
171
- // if (['darwin','linux','freebsd'].includes(process.platform))
172
- // opensslArgs.push('-in', '/dev/stdin')
173
-
174
169
  // it's cleaner to call openssl with each of -enddate, -subject, etc, but it costs
175
170
  // 40-50ms per spawn with node v21 on a M1 MBP
176
- const raw = await openssl(...opensslArgs, '-enddate', '-subject', '-ext', 'subjectAltName')
171
+ const raw = await openssl(res.chain[0], 'x509', '-noout', '-enddate', '-subject', '-ext', 'subjectAltName')
177
172
  if (!raw) return res
178
173
 
179
174
  res.expire = new Date(raw.match(/notAfter=(.* [A-Z]{3})/)[1])
@@ -191,7 +186,7 @@ exports.parse_x509 = async (string) => {
191
186
  }
192
187
 
193
188
  exports.load_tls_ini = (opts) => {
194
- log.info(`loading tls.ini`) // from ${this.config.root_path}`);
189
+ log.info('loading tls.ini')
195
190
 
196
191
  const cfg = exports.config.get(
197
192
  'tls.ini',
@@ -230,7 +225,7 @@ exports.load_tls_ini = (opts) => {
230
225
 
231
226
  if (ocsp === undefined && cfg.main.requestOCSP) {
232
227
  try {
233
- ocsp = require('ocsp')
228
+ ocsp = require('@haraka/ocsp')
234
229
  log.debug('ocsp loaded')
235
230
  ocspCache = new ocsp.Cache()
236
231
  } catch (ignore) {
@@ -339,7 +334,7 @@ exports.load_default_opts = () => {
339
334
  if (!Array.isArray(cfg.key)) cfg.key = [cfg.key]
340
335
  if (!Array.isArray(cfg.cert)) cfg.cert = [cfg.cert]
341
336
 
342
- if (cfg.key.length != cfg.cert.length) {
337
+ if (cfg.key.length !== cfg.cert.length) {
343
338
  log.error(`number of keys (${cfg.key.length}) not equal to certs (${cfg.cert.length}).`)
344
339
  }
345
340
 
@@ -454,19 +449,20 @@ exports.get_certs_dir = async (tlsDir) => {
454
449
  function openssl(crt, ...params) {
455
450
  return new Promise((resolve) => {
456
451
  let crtTxt = ''
452
+ let errTxt = ''
457
453
 
458
- const o = spawn('openssl', [...params], { timeout: 1000 })
454
+ const o = spawn('openssl', params, { timeout: 2000 })
459
455
  o.stdout.on('data', (data) => {
460
456
  crtTxt += data
461
457
  })
462
458
 
463
459
  o.stderr.on('data', (data) => {
464
- log.debug(`err: ${data.toString().trim()}`)
460
+ errTxt += data
465
461
  })
466
462
 
467
463
  o.on('close', (code) => {
468
464
  if (code !== 0) {
469
- if (code) console.error(code)
465
+ log.error(`openssl ${params.join(' ')} failed with code ${code}: ${errTxt.trim()}`)
470
466
  }
471
467
  resolve(crtTxt)
472
468
  })
@@ -484,8 +480,7 @@ exports.getSocketOpts = async (name) => {
484
480
  await this.get_certs_dir('tls')
485
481
  } catch (err) {
486
482
  if (err.code !== 'ENOENT') {
487
- console.error(err.messsage)
488
- log.error(err)
483
+ log.error(err.message)
489
484
  }
490
485
  }
491
486
 
@@ -519,7 +514,7 @@ exports.ensureDhparams = (done) => {
519
514
 
520
515
  log.info(`Generating a 2048 bit dhparams file at ${fpResolved}`)
521
516
 
522
- const o = spawn('openssl', ['dhparam', '-out', `${fpResolved}`, '2048'])
517
+ const o = spawn('openssl', ['dhparam', '-out', fpResolved, '2048'], { timeout: 30000 })
523
518
  o.stdout.on('data', (data) => {
524
519
  // normally empty output
525
520
  log.debug(data)
@@ -586,9 +581,9 @@ exports.shutdown = () => {
586
581
 
587
582
  function cleanOcspCache() {
588
583
  log.debug(`Cleaning ocspCache. How many keys? ${Object.keys(ocspCache.cache).length}`)
589
- Object.keys(ocspCache.cache).forEach((key) => {
584
+ for (const key of Object.keys(ocspCache.cache)) {
590
585
  clearTimeout(ocspCache.cache[key].timer)
591
- })
586
+ }
592
587
  }
593
588
 
594
589
  exports.certsByHost = certsByHost
@@ -688,12 +683,13 @@ function connect(conn_options = {}) {
688
683
  }
689
684
  options.socket = cryptoSocket
690
685
 
691
- const cleartext = new tls.connect(options)
686
+ const cleartext = tls.connect(options)
692
687
 
693
688
  pipe(cleartext, cryptoSocket)
694
689
 
695
690
  cleartext.on('error', (err) => {
696
- if (err.reason) log.error(`client TLS error: ${err}`)
691
+ err.source = 'tls'
692
+ socket.emit('error', err)
697
693
  })
698
694
 
699
695
  cleartext.once('secureConnect', () => {