import assert from 'node:assert/strict'; import { mkdtempSync } from 'node:fs'; import net from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { AuthenticationRateLimiter } from '../src/auth-rate-limit.js'; import { createDomain, createInboundMailbox, createUser, initDatabase } from '../src/db.js'; import { startMailboxAccessServers } from '../src/mail-access.js'; import { startSubmissionServer } from '../src/submission.js'; test('IMAP, POP3 and SMTP share generic authentication throttling by IP and account', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-auth-rate-limit-')), 'auth-rate-limit-secret'); const user = createUser({ username: 'auth-rate-limit-user', email: 'auth-rate-limit-user@example.com', password: 'account-password' }); createDomain(user.id, { domain: 'auth-rate-limit.example', selector: 'mh', verificationToken: 'verify', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.auth-rate-limit.example', sendingIp: '192.0.2.45', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = createInboundMailbox(user.id, { address: 'admin@auth-rate-limit.example', password: 'correct-password' }); const authRateLimiter = new AuthenticationRateLimiter({ combinationLimit: 3, accountLimit: 10, ipLimit: 20 }); const [imapServer, pop3Server] = startMailboxAccessServers({ hostname: 'mail.auth-rate-limit.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: true, pop3Listeners: [{ port: 0, protocol: 'pop3' }], allowInsecureAuth: true, authRateLimiter }); const [smtpServer] = startSubmissionServer({ enabled: true, listeners: [{ port: 0, protocol: 'smtp' }], hostname: 'mail.auth-rate-limit.example', allowInsecureAuth: true, inboundEnabled: true, authRateLimiter }); const servers = [imapServer, pop3Server, smtpServer]; await Promise.all(servers.map(waitForListening)); const clients = []; try { const imap = await connectClient(imapServer.address().port); clients.push(imap); await imap.readUntil(/\* OK .* IMAP ready\r\n/); assert.match( await imap.command(`A1 LOGIN "${mailbox.address}" "wrong-imap"`, /A1 NO/), /^A1 NO Authentication failed\r\n$/ ); const pop3 = await connectClient(pop3Server.address().port); clients.push(pop3); await pop3.readUntil(/\+OK .* POP3 ready\r\n/); assert.match(await pop3.command(`USER ${mailbox.address}`, /\+OK|\-ERR/), /^\+OK User accepted\r\n$/); assert.match(await pop3.command('PASS wrong-pop3', /\+OK|\-ERR/), /^\-ERR Authentication failed\r\n$/); const smtp = await connectClient(smtpServer.address().port); clients.push(smtp); await smtp.readUntil(/^220 .* ready\r\n/m); await smtp.command('EHLO client.example', /250 HELP\r\n/); const wrongAuth = Buffer.from(`\u0000${mailbox.address}\u0000wrong-smtp`).toString('base64'); assert.match( await smtp.command(`AUTH PLAIN ${wrongAuth}`, /535 /), /^535 Authentication failed\r\n$/ ); const blocked = await connectClient(smtpServer.address().port); clients.push(blocked); await blocked.readUntil(/^220 .* ready\r\n/m); await blocked.command('EHLO client.example', /250 HELP\r\n/); const correctAuth = Buffer.from(`\u0000${mailbox.address}\u0000correct-password`).toString('base64'); assert.match( await blocked.command(`AUTH PLAIN ${correctAuth}`, /535 /), /^535 Authentication failed\r\n$/ ); } finally { for (const client of clients) client.close(); await Promise.all(servers.map(closeServer)); } }); function connectClient(port) { return new Promise((resolve, reject) => { const socket = net.createConnection({ host: '127.0.0.1', port }); socket.setTimeout(5_000); let buffer = ''; const waiters = []; socket.on('data', (chunk) => { buffer += chunk.toString('utf8'); for (const waiter of [...waiters]) { if (!waiter.pattern.test(buffer)) continue; waiters.splice(waiters.indexOf(waiter), 1); const output = buffer; buffer = ''; clearTimeout(waiter.timer); waiter.resolve(output); } }); socket.once('connect', () => resolve({ command(command, pattern) { socket.write(`${command}\r\n`); return this.readUntil(pattern); }, readUntil(pattern) { if (pattern.test(buffer)) { const output = buffer; buffer = ''; return Promise.resolve(output); } return new Promise((waitResolve, waitReject) => { const waiter = { pattern, resolve: waitResolve, timer: null }; waiter.timer = setTimeout(() => { waiters.splice(waiters.indexOf(waiter), 1); waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`)); }, 5_000); waiters.push(waiter); }); }, close() { socket.destroy(); } })); socket.once('error', reject); socket.once('timeout', () => reject(new Error('Mail protocol client timed out'))); }); } function waitForListening(server) { if (server.listening) return Promise.resolve(); return new Promise((resolve) => server.once('listening', resolve)); } function closeServer(server) { return new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); }