submission-webmail-sso.test.js 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  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 {
  8. createDomain,
  9. createInboundMailbox,
  10. createUser,
  11. createWebmailLoginTicket,
  12. exchangeWebmailLoginTicket,
  13. initDatabase,
  14. replaceInboundMailboxGrants
  15. } from '../src/db.js';
  16. import { startSubmissionServer } from '../src/submission.js';
  17. test('an established Webmail SMTP session rechecks live send permission', async () => {
  18. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-webmail-')), 'webmail-submission-secret');
  19. const owner = createUser({
  20. username: 'webmail-owner',
  21. email: 'webmail-owner@example.com',
  22. password: 'password123'
  23. });
  24. const delegate = createUser({
  25. username: 'webmail-delegate',
  26. email: 'webmail-delegate@example.com',
  27. password: 'password123'
  28. });
  29. createDomain(owner.id, {
  30. domain: 'webmail-submit.example',
  31. selector: 'mh',
  32. verificationToken: 'verify-webmail-submit',
  33. dkimPublic: 'public',
  34. dkimPrivate: 'private',
  35. senderHost: 'in.webmail-submit.example',
  36. sendingIp: '192.0.2.10',
  37. spfExtra: '',
  38. dmarcPolicy: 'none',
  39. dmarcRua: ''
  40. });
  41. const mailbox = createInboundMailbox(owner.id, {
  42. address: 'shared@webmail-submit.example',
  43. password: 'mailbox-password'
  44. });
  45. replaceInboundMailboxGrants(mailbox.id, [{
  46. userId: delegate.id,
  47. permissions: { receive: true, send: true }
  48. }]);
  49. const ticket = createWebmailLoginTicket(delegate.id, mailbox.id, {
  50. audience: 'https://mail.us.ss5.xyz'
  51. });
  52. const login = exchangeWebmailLoginTicket(ticket.ticket, {
  53. audience: 'https://mail.us.ss5.xyz'
  54. });
  55. const [server] = startSubmissionServer({
  56. enabled: true,
  57. listeners: [{ port: 0, protocol: 'smtp' }],
  58. hostname: 'in.webmail-submit.example',
  59. allowInsecureAuth: true,
  60. inboundEnabled: true
  61. });
  62. await waitForListening(server);
  63. const client = await connectSmtp(server.address().port);
  64. try {
  65. assert.match(await client.readReply(), /^220 /);
  66. client.write('EHLO roundcube.example');
  67. assert.match(await client.readReply(), /^250 HELP$/m);
  68. const auth = Buffer.from(`\u0000${mailbox.address}\u0000${login.credential}`).toString('base64');
  69. client.write(`AUTH PLAIN ${auth}`);
  70. assert.match(await client.readReply(), /^235 /);
  71. replaceInboundMailboxGrants(mailbox.id, [{
  72. userId: delegate.id,
  73. permissions: { receive: true, send: false }
  74. }]);
  75. client.write(`MAIL FROM:<${mailbox.address}>`);
  76. assert.match(await client.readReply(), /^530 Authentication no longer valid$/);
  77. client.write(`AUTH PLAIN ${auth}`);
  78. assert.match(await client.readReply(), /^535 Authentication failed$/);
  79. } finally {
  80. client.close();
  81. await closeServer(server);
  82. }
  83. });
  84. function waitForListening(server) {
  85. if (server.listening) return Promise.resolve();
  86. return new Promise((resolve, reject) => {
  87. server.once('listening', resolve);
  88. server.once('error', reject);
  89. });
  90. }
  91. function closeServer(server) {
  92. return new Promise((resolve) => server.close(resolve));
  93. }
  94. function connectSmtp(port) {
  95. return new Promise((resolve, reject) => {
  96. const socket = net.connect(port, '127.0.0.1');
  97. socket.setEncoding('utf8');
  98. let buffer = '';
  99. const pending = [];
  100. socket.on('data', (chunk) => {
  101. buffer += chunk;
  102. flush();
  103. });
  104. socket.once('error', reject);
  105. socket.once('connect', () => {
  106. socket.off('error', reject);
  107. resolve({
  108. write(line) {
  109. socket.write(`${line}\r\n`);
  110. },
  111. readReply() {
  112. return new Promise((replyResolve) => {
  113. pending.push(replyResolve);
  114. flush();
  115. });
  116. },
  117. close() {
  118. socket.destroy();
  119. }
  120. });
  121. });
  122. function flush() {
  123. if (!pending.length) return;
  124. const lines = buffer.split('\r\n');
  125. if (lines.length < 2) return;
  126. const complete = lines.slice(0, -1);
  127. const finalIndex = complete.findIndex((line) => /^\d{3} /.test(line));
  128. if (finalIndex === -1) return;
  129. const reply = complete.slice(0, finalIndex + 1).join('\n');
  130. buffer = `${complete.slice(finalIndex + 1).join('\r\n')}${lines.at(-1) ? `\r\n${lines.at(-1)}` : ''}`;
  131. pending.shift()(reply);
  132. flush();
  133. }
  134. });
  135. }