Haraka 3.1.5 → 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.
package/test/server.js CHANGED
@@ -6,6 +6,7 @@ const { createHmac } = require('node:crypto')
6
6
  const net = require('node:net')
7
7
  const path = require('node:path')
8
8
  const tls = require('node:tls')
9
+ const constants = require('haraka-constants')
9
10
 
10
11
  const endpoint = require('../endpoint')
11
12
  const message = require('haraka-email-message')
@@ -254,6 +255,177 @@ describe('server', () => {
254
255
  })
255
256
  })
256
257
 
258
+ describe('lifecycle helpers', () => {
259
+ beforeEach(() => {
260
+ this.server = require('../server')
261
+ this.server.cfg = this.server.cfg || { main: {} }
262
+ this.server.cfg.main = this.server.cfg.main || {}
263
+ })
264
+
265
+ it('init_child_respond OK path starts HTTP listeners', () => {
266
+ let called = 0
267
+ const original = this.server.setup_http_listeners
268
+ this.server.setup_http_listeners = () => {
269
+ called++
270
+ }
271
+ try {
272
+ this.server.init_child_respond(constants.ok)
273
+ assert.equal(called, 1)
274
+ } finally {
275
+ this.server.setup_http_listeners = original
276
+ }
277
+ })
278
+
279
+ it('init_child_respond error path kills master and exits', () => {
280
+ process.env.CLUSTER_MASTER_PID = '12345'
281
+ const originalKill = process.kill
282
+ const originalDump = this.server.logger.dump_and_exit
283
+ let killed = null
284
+ let exitCode = null
285
+ process.kill = (pid) => {
286
+ killed = pid
287
+ }
288
+ this.server.logger.dump_and_exit = (code) => {
289
+ exitCode = code
290
+ }
291
+ try {
292
+ this.server.init_child_respond(constants.deny, 'nope')
293
+ assert.equal(killed, '12345')
294
+ assert.equal(exitCode, 1)
295
+ } finally {
296
+ process.kill = originalKill
297
+ this.server.logger.dump_and_exit = originalDump
298
+ delete process.env.CLUSTER_MASTER_PID
299
+ }
300
+ })
301
+
302
+ it('listening applies configured uid/gid and marks ready', () => {
303
+ this.server.cfg.main.group = 'staff'
304
+ this.server.cfg.main.user = 'nobody'
305
+ const originalGetGid = process.getgid
306
+ const originalSetGid = process.setgid
307
+ const originalGetUid = process.getuid
308
+ const originalSetUid = process.setuid
309
+ const calls = { setgid: 0, setuid: 0 }
310
+ process.getgid = () => 20
311
+ process.setgid = () => {
312
+ calls.setgid++
313
+ }
314
+ process.getuid = () => 501
315
+ process.setuid = () => {
316
+ calls.setuid++
317
+ }
318
+ try {
319
+ this.server.listening()
320
+ assert.equal(calls.setgid, 1)
321
+ assert.equal(calls.setuid, 1)
322
+ assert.equal(this.server.ready, 1)
323
+ } finally {
324
+ process.getgid = originalGetGid
325
+ process.setgid = originalSetGid
326
+ process.getuid = originalGetUid
327
+ process.setuid = originalSetUid
328
+ delete this.server.cfg.main.group
329
+ delete this.server.cfg.main.user
330
+ }
331
+ })
332
+
333
+ it('sendToMaster calls receiveAsMaster when not clustered', () => {
334
+ const originalCluster = this.server.cluster
335
+ const originalReceive = this.server.receiveAsMaster
336
+ const seen = []
337
+ this.server.cluster = null
338
+ this.server.receiveAsMaster = (cmd, params) => {
339
+ seen.push([cmd, params])
340
+ }
341
+ try {
342
+ this.server.sendToMaster('flushQueue', ['example.com'])
343
+ assert.deepEqual(seen[0], ['flushQueue', ['example.com']])
344
+ } finally {
345
+ this.server.cluster = originalCluster
346
+ this.server.receiveAsMaster = originalReceive
347
+ }
348
+ })
349
+
350
+ it('receiveAsMaster ignores invalid commands and executes valid ones', () => {
351
+ const errors = []
352
+ const originalLogError = this.server.logerror
353
+ this.server.logerror = (msg) => errors.push(msg)
354
+ this.server._testCommand = (a, b) => {
355
+ this.server.notes.received = [a, b]
356
+ }
357
+ try {
358
+ this.server.receiveAsMaster('notACommand', [])
359
+ assert.equal(errors.length > 0, true)
360
+
361
+ this.server.receiveAsMaster('_testCommand', ['x', 'y'])
362
+ assert.deepEqual(this.server.notes.received, ['x', 'y'])
363
+ } finally {
364
+ this.server.logerror = originalLogError
365
+ delete this.server._testCommand
366
+ }
367
+ })
368
+ })
369
+
370
+ describe('HTTP helpers', () => {
371
+ beforeEach(() => {
372
+ this.server = require('../server')
373
+ })
374
+
375
+ it('handle404 serves html/json/text based on request accepts', () => {
376
+ const makeReq = (kind) => ({
377
+ accepts(type) {
378
+ return type === kind
379
+ },
380
+ })
381
+ const responses = []
382
+ const makeRes = () => ({
383
+ status(code) {
384
+ responses.push({ code })
385
+ return this
386
+ },
387
+ sendFile(name, opts) {
388
+ responses.push({ type: 'html', name, opts })
389
+ },
390
+ send(body) {
391
+ responses.push({ type: 'body', body })
392
+ },
393
+ })
394
+
395
+ this.server.handle404(makeReq('html'), makeRes())
396
+ this.server.handle404(makeReq('json'), makeRes())
397
+ this.server.handle404(makeReq('none'), makeRes())
398
+
399
+ assert.equal(responses[0].code, 404)
400
+ assert.equal(responses[1].type, 'html')
401
+ assert.equal(responses[3].type, 'body')
402
+ assert.deepEqual(responses[3].body, { err: 'Not found' })
403
+ assert.equal(responses[5].body, 'Not found!')
404
+ })
405
+
406
+ it('init_http_respond logs and returns when ws is unavailable', () => {
407
+ const Module = require('node:module')
408
+ const originalRequire = Module.prototype.require
409
+ const originalLogError = this.server.logerror
410
+ const errors = []
411
+ this.server.logerror = (msg) => {
412
+ errors.push(msg)
413
+ }
414
+ this.server.http = { server: {} }
415
+ Module.prototype.require = function (id) {
416
+ if (id === 'ws') throw new Error('ws missing')
417
+ return originalRequire.apply(this, arguments)
418
+ }
419
+ try {
420
+ this.server.init_http_respond()
421
+ assert.equal(errors.length > 0, true)
422
+ } finally {
423
+ Module.prototype.require = originalRequire
424
+ this.server.logerror = originalLogError
425
+ }
426
+ })
427
+ })
428
+
257
429
  // ── SMTP sessions ─────────────────────────────────────────────────────────
258
430
  describe('SMTP sessions', () => {
259
431
  beforeEach(async () => setupServer('127.0.0.1:2503'))
@@ -6,6 +6,7 @@ const path = require('node:path')
6
6
  const net = require('node:net')
7
7
  const tls = require('node:tls')
8
8
  const fs = require('node:fs')
9
+ const { EventEmitter } = require('node:events')
9
10
 
10
11
  // Mock dependencies before requiring the target
11
12
  const mock = require('node:test').mock
@@ -95,10 +96,147 @@ test('tls_socket', async (t) => {
95
96
  await new Promise((resolve) => server.close(resolve))
96
97
  }
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
+ })
98
142
  })
99
143
 
100
144
  await t.test('getSocketOpts', async (t) => {
101
145
  // Exercise the typo path (would requires failing config.getDir)
102
146
  assert.strictEqual(typeof tls_socket.getSocketOpts, 'function')
103
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
+ })
104
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')
@@ -225,7 +225,7 @@ exports.load_tls_ini = (opts) => {
225
225
 
226
226
  if (ocsp === undefined && cfg.main.requestOCSP) {
227
227
  try {
228
- ocsp = require('ocsp')
228
+ ocsp = require('@haraka/ocsp')
229
229
  log.debug('ocsp loaded')
230
230
  ocspCache = new ocsp.Cache()
231
231
  } catch (ignore) {