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
@@ -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) {
@@ -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) => {
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) {
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
@@ -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
  })
@@ -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
  {
@@ -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
+ })