Haraka 3.1.6 → 3.1.7

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 (53) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/CONTRIBUTORS.md +8 -8
  3. package/Plugins.md +99 -99
  4. package/config/smtp_forward.ini +10 -0
  5. package/config/smtp_proxy.ini +10 -0
  6. package/connection.js +25 -8
  7. package/docs/plugins/queue/smtp_forward.md +19 -3
  8. package/docs/plugins/queue/smtp_proxy.md +10 -2
  9. package/haraka.js +1 -1
  10. package/outbound/hmail.js +39 -39
  11. package/outbound/index.js +4 -4
  12. package/outbound/tls.js +2 -43
  13. package/package.json +49 -48
  14. package/plugins/auth/auth_base.js +9 -3
  15. package/plugins/auth/auth_proxy.js +14 -11
  16. package/plugins/block_me.js +4 -2
  17. package/plugins/prevent_credential_leaks.js +3 -1
  18. package/plugins/process_title.js +6 -6
  19. package/plugins/queue/qmail-queue.js +15 -19
  20. package/plugins/queue/smtp_forward.js +12 -4
  21. package/plugins/queue/smtp_proxy.js +14 -3
  22. package/plugins/tls.js +13 -5
  23. package/plugins/xclient.js +3 -1
  24. package/server.js +5 -3
  25. package/smtp_client.js +20 -11
  26. package/test/config/block_me.recipient +1 -0
  27. package/test/config/block_me.senders +1 -0
  28. package/test/connection.js +24 -0
  29. package/test/outbound/bounce_net_errors.js +3 -2
  30. package/test/plugins/auth/auth_bridge.js +80 -0
  31. package/test/plugins/auth/flat_file.js +128 -0
  32. package/test/plugins/block_me.js +157 -0
  33. package/test/plugins/data.signatures.js +114 -0
  34. package/test/plugins/delay_deny.js +263 -0
  35. package/test/plugins/prevent_credential_leaks.js +178 -0
  36. package/test/plugins/process_title.js +135 -0
  37. package/test/plugins/queue/deliver.js +99 -0
  38. package/test/plugins/queue/discard.js +79 -0
  39. package/test/plugins/queue/lmtp.js +138 -0
  40. package/test/plugins/queue/qmail-queue.js +99 -0
  41. package/test/plugins/queue/quarantine.js +81 -0
  42. package/test/plugins/queue/smtp_bridge.js +154 -0
  43. package/test/plugins/queue/smtp_forward.js +42 -6
  44. package/test/plugins/queue/smtp_proxy.js +139 -0
  45. package/test/plugins/reseed_rng.js +34 -0
  46. package/test/plugins/tarpit.js +91 -0
  47. package/test/plugins/tls.js +25 -0
  48. package/test/plugins/toobusy.js +21 -0
  49. package/test/plugins/xclient.js +14 -0
  50. package/test/server.js +59 -0
  51. package/test/smtp_client.js +45 -12
  52. package/test/tls_socket.js +82 -0
  53. package/tls_socket.js +50 -0
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert/strict')
4
+ const { describe, it } = require('node:test')
5
+
6
+ const fixtures = require('haraka-test-fixtures')
7
+
8
+ describe('toobusy', () => {
9
+ describe('register', () => {
10
+ it('handles missing toobusy-js gracefully (does not throw)', () => {
11
+ const plugin = new fixtures.plugin('toobusy')
12
+ // toobusy-js is not installed; register should catch the error and return
13
+ let registered = false
14
+ plugin.register_hook = () => {
15
+ registered = true
16
+ }
17
+ assert.doesNotThrow(() => plugin.register())
18
+ assert.equal(registered, false, 'hook should not be registered without toobusy-js')
19
+ })
20
+ })
21
+ })
@@ -99,4 +99,18 @@ describe('xclient', () => {
99
99
  })
100
100
  }
101
101
  })
102
+
103
+ describe('DESTPORT type', () => {
104
+ it('stores local.port as an integer (587/465 auth check)', async () => {
105
+ this.connection.remote.ip = '127.0.0.1'
106
+ await new Promise((resolve) => {
107
+ this.plugin.hook_unrecognized_command(() => resolve(), this.connection, [
108
+ 'XCLIENT',
109
+ 'ADDR=1.2.3.4 DESTPORT=587',
110
+ ])
111
+ })
112
+ assert.strictEqual(this.connection.local.port, 587)
113
+ assert.equal(typeof this.connection.local.port, 'number')
114
+ })
115
+ })
102
116
  })
package/test/server.js CHANGED
@@ -527,3 +527,62 @@ describe('server', () => {
527
527
  })
528
528
  })
529
529
  })
530
+
531
+ describe('_graceful (cluster restart)', () => {
532
+ it('actually disconnects workers (queued thunks are invoked)', async () => {
533
+ const cluster = require('node:cluster')
534
+ const Server = require('../server')
535
+ Server.cfg = Server.cfg || { main: {} }
536
+ Server.cfg.main = Server.cfg.main || {}
537
+ Server.cfg.main.force_shutdown_timeout = 1
538
+
539
+ const saved = {
540
+ cluster: Server.cluster,
541
+ workers: cluster.workers,
542
+ fork: cluster.fork,
543
+ rmAll: cluster.removeAllListeners,
544
+ }
545
+
546
+ let disconnected = 0
547
+ const mkWorker = () => ({
548
+ _cbs: {},
549
+ send() {},
550
+ kill() {},
551
+ once(ev, cb) {
552
+ ;(this._cbs[ev] ||= []).push(cb)
553
+ },
554
+ on(ev, cb) {
555
+ ;(this._cbs[ev] ||= []).push(cb)
556
+ },
557
+ _fire(ev) {
558
+ for (const cb of this._cbs[ev] || []) cb()
559
+ },
560
+ disconnect() {
561
+ disconnected++
562
+ setImmediate(() => {
563
+ this._fire('disconnect')
564
+ setImmediate(() => this._fire('exit'))
565
+ })
566
+ },
567
+ })
568
+
569
+ cluster.workers = { 1: mkWorker() }
570
+ cluster.removeAllListeners = () => {}
571
+ cluster.fork = () => {
572
+ const nw = mkWorker()
573
+ setImmediate(() => nw._fire('listening'))
574
+ return nw
575
+ }
576
+ Server.cluster = cluster
577
+
578
+ try {
579
+ await Server._graceful()
580
+ assert.equal(disconnected, 1, 'worker.disconnect() was invoked')
581
+ } finally {
582
+ Server.cluster = saved.cluster
583
+ cluster.workers = saved.workers
584
+ cluster.fork = saved.fork
585
+ cluster.removeAllListeners = saved.rmAll
586
+ }
587
+ })
588
+ })
@@ -436,18 +436,18 @@ describe('SMTPClient closed() handler', () => {
436
436
  })
437
437
  })
438
438
 
439
- // ─── load_tls_config ──────────────────────────────────────────────────────────
439
+ // ─── load_tls_options ──────────────────────────────────────────────────────────
440
440
 
441
- describe('SMTPClient#load_tls_config', () => {
441
+ describe('SMTPClient#load_tls_options', () => {
442
442
  it('sets tls_options with servername equal to host', () => {
443
443
  const client = makeClient()
444
- client.load_tls_config()
444
+ client.load_tls_options()
445
445
  assert.equal(client.tls_options.servername, 'mx.example.com')
446
446
  })
447
447
 
448
448
  it('merges additional opts into tls_options', () => {
449
449
  const client = makeClient()
450
- client.load_tls_config({ key: Buffer.from('secret'), rejectUnauthorized: false })
450
+ client.load_tls_options({ key: Buffer.from('secret'), rejectUnauthorized: false })
451
451
  assert.equal(client.tls_options.servername, 'mx.example.com')
452
452
  assert.equal(client.tls_options.rejectUnauthorized, false)
453
453
  assert.ok(Buffer.isBuffer(client.tls_options.key))
@@ -728,10 +728,8 @@ describe('smtp_client.onCapabilitiesOutbound', () => {
728
728
 
729
729
  it('skips STARTTLS when host is in no_tls_hosts ban list', () => {
730
730
  client.response = ['STARTTLS']
731
- // Note: the code checks tls_options.no_tls_hosts but reads tls_config.no_tls_hosts
732
- // (a known quirk) — set both to exercise the branch
731
+ // no_tls_hosts is read from tls_options consistently
733
732
  client.tls_options = { no_tls_hosts: ['10.0.0.0/8'] }
734
- client.tls_config = { no_tls_hosts: ['10.0.0.0/8'] }
735
733
  client.remote_ip = '10.0.0.1'
736
734
  const conn = makeConnection()
737
735
  smtp_client_module.onCapabilitiesOutbound(client, false, conn, { enable_tls: true, host: '10.0.0.1' }, () => {})
@@ -766,6 +764,25 @@ describe('smtp_client.get_client_plugin', () => {
766
764
  assert.ok(client instanceof SMTPClient)
767
765
  })
768
766
 
767
+ it('emits error (does not throw) when AUTH type is unsupported', async () => {
768
+ const c = {
769
+ host: 'relay.example.com',
770
+ port: 25,
771
+ auth_type: 'login',
772
+ auth_user: 'a',
773
+ auth_pass: 'b',
774
+ }
775
+ const client = await getClientPlugin(c)
776
+ client.auth_capabilities = [] // server advertised no AUTH
777
+ let errMsg
778
+ client.on('error', (m) => {
779
+ errMsg = m
780
+ })
781
+ // pre-fix this threw out of the event loop and crashed the worker
782
+ assert.doesNotThrow(() => client.emit('helo'))
783
+ assert.match(String(errMsg), /not supported by server/)
784
+ })
785
+
769
786
  it('merges auth_type / auth_user / auth_pass into c.auth', async () => {
770
787
  const c = { host: 'relay.example.com', port: 25, auth_type: 'plain', auth_user: 'alice', auth_pass: 's3cr3t' }
771
788
  await getClientPlugin(c)
@@ -791,6 +808,16 @@ describe('smtp_client.get_client_plugin', () => {
791
808
  assert.ok(written.some((l) => /EHLO relay\.example\.com/.test(l)))
792
809
  })
793
810
 
811
+ it('redacts AUTH credentials from protocol logs (S4)', async () => {
812
+ const client = await getClientPlugin()
813
+ const logged = []
814
+ conn.logprotocol = (p, msg) => logged.push(msg)
815
+ client.emit('client_protocol', 'AUTH PLAIN AGFsaWNlAHMzY3JldA==')
816
+ assert.equal(logged.length, 1)
817
+ assert.equal(logged[0], 'C: AUTH PLAIN [redacted]')
818
+ assert.ok(!logged[0].includes('AGFsaWNlAHMzY3JldA=='))
819
+ })
820
+
794
821
  it('greeting handler sends EHLO with hello.host when xclient is set', async () => {
795
822
  const client = await getClientPlugin()
796
823
  client.xclient = true
@@ -850,19 +877,25 @@ describe('smtp_client.get_client_plugin', () => {
850
877
  assert.ok(written.some((l) => /AUTH PLAIN/.test(l)))
851
878
  })
852
879
 
853
- // Table-drive the helo-throws cases
880
+ // these used to throw out of the event loop (crashing the worker); they must
881
+ // now route through the smtp_client 'error' flow.
854
882
  for (const [desc, opts, capabilities, pattern] of [
855
883
  ['unsupported auth type', { type: 'plain', user: 'u', pass: 'p' }, ['cram-md5'], /not supported by server/],
856
884
  ['plain auth with no user/pass', { type: 'plain', user: '', pass: '' }, ['plain'], /Must include auth\.user/],
857
- ['cram-md5 (not implemented)', { type: 'cram-md5', user: 'u', pass: 'p' }, ['cram-md5'], /Not implemented/],
885
+ ['cram-md5 (not implemented)', { type: 'cram-md5', user: 'u', pass: 'p' }, ['cram-md5'], /not implemented/i],
858
886
  ['unknown auth type', { type: 'gssapi', user: 'u', pass: 'p' }, ['gssapi'], /Unknown AUTH type/],
859
887
  ]) {
860
- it(`helo handler throws for ${desc}`, async () => {
888
+ it(`helo handler emits error (no throw) for ${desc}`, async () => {
861
889
  const c = { host: 'relay.example.com', port: 25, auth: opts }
862
890
  const client = await getClientPlugin(c)
863
891
  client.authenticated = false
864
892
  client.auth_capabilities = capabilities
865
- assert.throws(() => client.emit('helo'), pattern)
893
+ let errMsg
894
+ client.on('error', (m) => {
895
+ errMsg = m
896
+ })
897
+ assert.doesNotThrow(() => client.emit('helo'))
898
+ assert.match(String(errMsg), pattern)
866
899
  })
867
900
  }
868
901
 
@@ -1241,7 +1274,7 @@ describe('smtp_client', () => {
1241
1274
  }
1242
1275
 
1243
1276
  const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
1244
- client.load_tls_config({ key: Buffer.from('OutboundTlsKeyLoaded') })
1277
+ client.load_tls_options({ key: Buffer.from('OutboundTlsKeyLoaded') })
1245
1278
 
1246
1279
  client.command = 'starttls'
1247
1280
  cmds.line('250 Hello client.example.com\r\n')
@@ -239,4 +239,86 @@ test('tls_socket', async (t) => {
239
239
  }
240
240
  }
241
241
  })
242
+
243
+ await t.test('load_plugin_tls_options', async (t) => {
244
+ // Point haraka-config at test/config so tls.ini fixtures load.
245
+ const origConfig = tls_socket.config
246
+ const origCfg = tls_socket.cfg
247
+ const test_config = require('haraka-config').module_config(path.resolve(__dirname))
248
+
249
+ t.beforeEach(() => {
250
+ tls_socket.config = test_config
251
+ tls_socket.cfg = undefined // bust load_tls_ini cache between cases
252
+ })
253
+
254
+ t.after(() => {
255
+ tls_socket.config = origConfig
256
+ tls_socket.cfg = origCfg
257
+ })
258
+
259
+ await t.test('inherits tls.ini [main] when plugin cfg is empty', () => {
260
+ const opts = tls_socket.load_plugin_tls_options({})
261
+ // From test/config/tls.ini [main]
262
+ assert.equal(opts.rejectUnauthorized, false)
263
+ assert.equal(opts.minVersion, 'TLSv1')
264
+ assert.equal(opts.honorCipherOrder, true)
265
+ assert.ok(opts.ciphers && opts.ciphers.length)
266
+ assert.ok(Buffer.isBuffer(opts.key), 'key resolved to Buffer')
267
+ assert.ok(Buffer.isBuffer(opts.cert), 'cert resolved to Buffer')
268
+ })
269
+
270
+ await t.test('plugin cfg overrides [main]', () => {
271
+ const opts = tls_socket.load_plugin_tls_options({
272
+ rejectUnauthorized: true,
273
+ minVersion: 'TLSv1.3',
274
+ ciphers: 'ECDHE-RSA-AES256-GCM-SHA384',
275
+ })
276
+ assert.equal(opts.rejectUnauthorized, true)
277
+ assert.equal(opts.minVersion, 'TLSv1.3')
278
+ assert.equal(opts.ciphers, 'ECDHE-RSA-AES256-GCM-SHA384')
279
+ })
280
+
281
+ await t.test('resolves key/cert/dhparam file refs to Buffers', () => {
282
+ const opts = tls_socket.load_plugin_tls_options({
283
+ key: 'outbound_tls_key.pem',
284
+ cert: 'outbound_tls_cert.pem',
285
+ dhparam: 'dhparams.pem',
286
+ })
287
+ assert.ok(Buffer.isBuffer(opts.key) && opts.key.length > 0)
288
+ assert.ok(Buffer.isBuffer(opts.cert) && opts.cert.length > 0)
289
+ assert.ok(Buffer.isBuffer(opts.dhparam) && opts.dhparam.length > 0)
290
+ })
291
+
292
+ await t.test('drops missing dhparam rather than leaving null', () => {
293
+ const opts = tls_socket.load_plugin_tls_options({
294
+ dhparam: 'does_not_exist.pem',
295
+ })
296
+ assert.equal(opts.dhparam, undefined)
297
+ })
298
+
299
+ await t.test('normalises no_tls_hosts / force_tls_hosts to arrays', () => {
300
+ const opts = tls_socket.load_plugin_tls_options({
301
+ no_tls_hosts: '10.0.0.5',
302
+ force_tls_hosts: ['a.example.com', 'b.example.com'],
303
+ })
304
+ assert.deepEqual(opts.no_tls_hosts, ['10.0.0.5'])
305
+ assert.deepEqual(opts.force_tls_hosts, ['a.example.com', 'b.example.com'])
306
+
307
+ const opts2 = tls_socket.load_plugin_tls_options({})
308
+ assert.deepEqual(opts2.no_tls_hosts, [])
309
+ assert.deepEqual(opts2.force_tls_hosts, [])
310
+ })
311
+
312
+ await t.test('does not set servername', () => {
313
+ const opts = tls_socket.load_plugin_tls_options({})
314
+ assert.equal(opts.servername, undefined)
315
+ })
316
+
317
+ await t.test('does not mutate the input plugin cfg', () => {
318
+ const input = { rejectUnauthorized: true, no_tls_hosts: '10.0.0.5' }
319
+ const before = JSON.stringify(input)
320
+ tls_socket.load_plugin_tls_options(input)
321
+ assert.equal(JSON.stringify(input), before)
322
+ })
323
+ })
242
324
  })
package/tls_socket.js CHANGED
@@ -251,6 +251,56 @@ exports.load_tls_ini = (opts) => {
251
251
  return cfg
252
252
  }
253
253
 
254
+ // Build a client tls_options, merges a consumers own [tls] section
255
+ // over tls.ini [main].
256
+ exports.load_plugin_tls_options = (plugin_tls_cfg = {}) => {
257
+ const tls_cfg = exports.load_tls_ini({ role: 'client' })
258
+ const cfg = JSON.parse(JSON.stringify(plugin_tls_cfg))
259
+
260
+ // Inheritance from tls.ini [main] deliberately omits no_tls_hosts: the
261
+ // [main].no_tls_hosts list is documented as inbound-only; outbound and
262
+ // queue plugins should opt in explicitly via their own section.
263
+ const inheritable_opts = [
264
+ 'key',
265
+ 'cert',
266
+ 'ciphers',
267
+ 'minVersion',
268
+ 'dhparam',
269
+ 'requestCert',
270
+ 'honorCipherOrder',
271
+ 'rejectUnauthorized',
272
+ 'force_tls_hosts',
273
+ ]
274
+ for (const opt of inheritable_opts) {
275
+ if (cfg[opt] !== undefined) continue // set in plugin [tls]
276
+ if (tls_cfg.main[opt] === undefined) continue // unset in tls.ini [main]
277
+ cfg[opt] = tls_cfg.main[opt]
278
+ }
279
+
280
+ // Resolve key/cert/dhparam file references to buffers. Drop empty results
281
+ // so we never pass null to tls.connect.
282
+ for (const k of ['key', 'cert', 'dhparam']) {
283
+ if (!cfg[k]) {
284
+ delete cfg[k]
285
+ continue
286
+ }
287
+ const ref = Array.isArray(cfg[k]) ? cfg[k][0] : cfg[k]
288
+ const bin = exports.config.get(ref, 'binary')
289
+ if (bin) cfg[k] = bin
290
+ else delete cfg[k]
291
+ }
292
+
293
+ for (const k of ['no_tls_hosts', 'force_tls_hosts']) {
294
+ if (!cfg[k]) {
295
+ cfg[k] = []
296
+ continue
297
+ }
298
+ if (!Array.isArray(cfg[k])) cfg[k] = [cfg[k]]
299
+ }
300
+
301
+ return cfg
302
+ }
303
+
254
304
  exports.applySocketOpts = (name) => {
255
305
  // https://nodejs.org/api/tls.html#tls_new_tls_tlssocket_socket_options
256
306
  const TLSSocketOptions = [