mail-auth-rate-limit.test.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import net from 'node:net';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import { AuthenticationRateLimiter } from '../src/auth-rate-limit.js';
  8. import { createDomain, createInboundMailbox, createUser, initDatabase } from '../src/db.js';
  9. import { startMailboxAccessServers } from '../src/mail-access.js';
  10. import { startSubmissionServer } from '../src/submission.js';
  11. test('IMAP, POP3 and SMTP share generic authentication throttling by IP and account', async () => {
  12. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-auth-rate-limit-')), 'auth-rate-limit-secret');
  13. const user = createUser({
  14. username: 'auth-rate-limit-user',
  15. email: 'auth-rate-limit-user@example.com',
  16. password: 'account-password'
  17. });
  18. createDomain(user.id, {
  19. domain: 'auth-rate-limit.example',
  20. selector: 'mh',
  21. verificationToken: 'verify',
  22. dkimPublic: 'public',
  23. dkimPrivate: 'private',
  24. senderHost: 'mail.auth-rate-limit.example',
  25. sendingIp: '192.0.2.45',
  26. spfExtra: '',
  27. dmarcPolicy: 'none',
  28. dmarcRua: ''
  29. });
  30. const mailbox = createInboundMailbox(user.id, {
  31. address: 'admin@auth-rate-limit.example',
  32. password: 'correct-password'
  33. });
  34. const authRateLimiter = new AuthenticationRateLimiter({
  35. combinationLimit: 3,
  36. accountLimit: 10,
  37. ipLimit: 20
  38. });
  39. const [imapServer, pop3Server] = startMailboxAccessServers({
  40. hostname: 'mail.auth-rate-limit.example',
  41. imapEnabled: true,
  42. imapListeners: [{ port: 0, protocol: 'imap' }],
  43. pop3Enabled: true,
  44. pop3Listeners: [{ port: 0, protocol: 'pop3' }],
  45. allowInsecureAuth: true,
  46. authRateLimiter
  47. });
  48. const [smtpServer] = startSubmissionServer({
  49. enabled: true,
  50. listeners: [{ port: 0, protocol: 'smtp' }],
  51. hostname: 'mail.auth-rate-limit.example',
  52. allowInsecureAuth: true,
  53. inboundEnabled: true,
  54. authRateLimiter
  55. });
  56. const servers = [imapServer, pop3Server, smtpServer];
  57. await Promise.all(servers.map(waitForListening));
  58. const clients = [];
  59. try {
  60. const imap = await connectClient(imapServer.address().port);
  61. clients.push(imap);
  62. await imap.readUntil(/\* OK .* IMAP ready\r\n/);
  63. assert.match(
  64. await imap.command(`A1 LOGIN "${mailbox.address}" "wrong-imap"`, /A1 NO/),
  65. /^A1 NO Authentication failed\r\n$/
  66. );
  67. const pop3 = await connectClient(pop3Server.address().port);
  68. clients.push(pop3);
  69. await pop3.readUntil(/\+OK .* POP3 ready\r\n/);
  70. assert.match(await pop3.command(`USER ${mailbox.address}`, /\+OK|\-ERR/), /^\+OK User accepted\r\n$/);
  71. assert.match(await pop3.command('PASS wrong-pop3', /\+OK|\-ERR/), /^\-ERR Authentication failed\r\n$/);
  72. const smtp = await connectClient(smtpServer.address().port);
  73. clients.push(smtp);
  74. await smtp.readUntil(/^220 .* ready\r\n/m);
  75. await smtp.command('EHLO client.example', /250 HELP\r\n/);
  76. const wrongAuth = Buffer.from(`\u0000${mailbox.address}\u0000wrong-smtp`).toString('base64');
  77. assert.match(
  78. await smtp.command(`AUTH PLAIN ${wrongAuth}`, /535 /),
  79. /^535 Authentication failed\r\n$/
  80. );
  81. const blocked = await connectClient(smtpServer.address().port);
  82. clients.push(blocked);
  83. await blocked.readUntil(/^220 .* ready\r\n/m);
  84. await blocked.command('EHLO client.example', /250 HELP\r\n/);
  85. const correctAuth = Buffer.from(`\u0000${mailbox.address}\u0000correct-password`).toString('base64');
  86. assert.match(
  87. await blocked.command(`AUTH PLAIN ${correctAuth}`, /535 /),
  88. /^535 Authentication failed\r\n$/
  89. );
  90. } finally {
  91. for (const client of clients) client.close();
  92. await Promise.all(servers.map(closeServer));
  93. }
  94. });
  95. function connectClient(port) {
  96. return new Promise((resolve, reject) => {
  97. const socket = net.createConnection({ host: '127.0.0.1', port });
  98. socket.setTimeout(5_000);
  99. let buffer = '';
  100. const waiters = [];
  101. socket.on('data', (chunk) => {
  102. buffer += chunk.toString('utf8');
  103. for (const waiter of [...waiters]) {
  104. if (!waiter.pattern.test(buffer)) continue;
  105. waiters.splice(waiters.indexOf(waiter), 1);
  106. const output = buffer;
  107. buffer = '';
  108. clearTimeout(waiter.timer);
  109. waiter.resolve(output);
  110. }
  111. });
  112. socket.once('connect', () => resolve({
  113. command(command, pattern) {
  114. socket.write(`${command}\r\n`);
  115. return this.readUntil(pattern);
  116. },
  117. readUntil(pattern) {
  118. if (pattern.test(buffer)) {
  119. const output = buffer;
  120. buffer = '';
  121. return Promise.resolve(output);
  122. }
  123. return new Promise((waitResolve, waitReject) => {
  124. const waiter = { pattern, resolve: waitResolve, timer: null };
  125. waiter.timer = setTimeout(() => {
  126. waiters.splice(waiters.indexOf(waiter), 1);
  127. waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
  128. }, 5_000);
  129. waiters.push(waiter);
  130. });
  131. },
  132. close() {
  133. socket.destroy();
  134. }
  135. }));
  136. socket.once('error', reject);
  137. socket.once('timeout', () => reject(new Error('Mail protocol client timed out')));
  138. });
  139. }
  140. function waitForListening(server) {
  141. if (server.listening) return Promise.resolve();
  142. return new Promise((resolve) => server.once('listening', resolve));
  143. }
  144. function closeServer(server) {
  145. return new Promise((resolve, reject) => {
  146. server.close((error) => error ? reject(error) : resolve());
  147. });
  148. }