submission-tls-sniff.test.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync, writeFileSync } from 'node:fs';
  3. import net from 'node:net';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import tls from 'node:tls';
  7. import { test } from 'node:test';
  8. import {
  9. createDomain,
  10. createInboundMailbox,
  11. createUser,
  12. initDatabase
  13. } from '../src/db.js';
  14. import { readMaildirMessage, scanMaildirMailbox } from '../src/maildir-store.js';
  15. import { resolveSubmissionTracking, startSubmissionServer } from '../src/submission.js';
  16. const testCert = `-----BEGIN CERTIFICATE-----
  17. MIIDCTCCAfGgAwIBAgIUKFK3kjPk5eJZ2nsmLbEjfQZp5lQwDQYJKoZIhvcNAQEL
  18. BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcwODA2MjYzMloXDTI2MDgw
  19. NzA2MjYzMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
  20. AAOCAQ8AMIIBCgKCAQEAuUOQ0ewpBEjMNEBcyN79o8ujqetpWh7wbLcc5+aiNUaa
  21. OhmGitZaHuNzLBG/vhd44QJjBmdRV4lG8/t8NCFMczpHzmW5f8ovY+5MqeOMDXDX
  22. zeX+LjyraYTM/NxuZirUOQNjE5oq5T8TirTpp1ZycF/aYz9kyq/BlIR3bMySHV+M
  23. 39o6U1aS71tpP4NthnwOtxWs9Md5axfw5d4PcNEbEx42XfcQxnaZkmIm3O0uQ9z/
  24. E5K9TwMPsIAYVWKUff57thwB27cj/SBEJoPCAcWvIenbPddLGuIm+T9LHoGiMmfd
  25. OSW9Hx+1fKCAsIA9JuUkMCCRWAnI6aft+gI+GByFVQIDAQABo1MwUTAdBgNVHQ4E
  26. FgQUEBUJl3WRWbTyqk1gF9T0tpPQGx8wHwYDVR0jBBgwFoAUEBUJl3WRWbTyqk1g
  27. F9T0tpPQGx8wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAOsr+
  28. ZBjxA+3ynvE0Ay0UHVNKWmkkxVwX9uiYdfP/KZSD8HGuEZ22c9Z4EmfLg4XxXnaK
  29. Up2A3vg/r1TA4LcHSCzCpYgUsT1mNs84jFlkg7qcSr/To3AodQIbU6290nVIHCoA
  30. LLLpDLOMm7HkrbqRRQnLJBz296PZgHZDZ5W680C+QEZhw3BlY6hixdvHikd9xPFv
  31. jBVQrMyBu0kqlhGY3ZsqhMfapn1x/pmCl+kd6KXj5jOn1gSqig4hOh8mGa7xq7D8
  32. /okZ4boyv18n95yjo2YkhpuTidocBDsSoK9U4UQrVl4ID+zydh2vVpvYbCemmBc9
  33. EZVyHdaIa3874ry3Kw==
  34. -----END CERTIFICATE-----`;
  35. const testKey = `-----BEGIN PRIVATE KEY-----
  36. MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5Q5DR7CkESMw0
  37. QFzI3v2jy6Op62laHvBstxzn5qI1Rpo6GYaK1loe43MsEb++F3jhAmMGZ1FXiUbz
  38. +3w0IUxzOkfOZbl/yi9j7kyp44wNcNfN5f4uPKtphMz83G5mKtQ5A2MTmirlPxOK
  39. tOmnVnJwX9pjP2TKr8GUhHdszJIdX4zf2jpTVpLvW2k/g22GfA63Faz0x3lrF/Dl
  40. 3g9w0RsTHjZd9xDGdpmSYibc7S5D3P8Tkr1PAw+wgBhVYpR9/nu2HAHbtyP9IEQm
  41. g8IBxa8h6ds910sa4ib5P0segaIyZ905Jb0fH7V8oICwgD0m5SQwIJFYCcjpp+36
  42. Aj4YHIVVAgMBAAECggEAEPWDqgfHiG4ejVDv/W5WQxp22KzlV7vZz+XiRkM5SY21
  43. PAjOpWJyXP3+ssPMLNtKtDRK+tDV5CC7p0V37PC4ZhrpbFYpCS7psCjmCS48iZVx
  44. F9bMMhDZMx9WQXZvP7h/dVmfRfHYp+QihpHBitExyCIqJGgu8pPssUsAAKxoWrpW
  45. z2VvWXBvIBtvWTJNjBduKgoEAWpqdJU/YlXyDyHKOtGpQEksR4A0CWeMEa4BYgog
  46. MYou8TSiKuIb4M5BKdiHBSO7RiTn6YJ3o7BoGnMVztOPQS6HjMZbAc9oevM2CXQI
  47. 4gy/BgqSRz4FFbGDp5ZscRdEgf4sxvmXiIzWQ0jqSQKBgQDg35FCOmMTIBujh0s1
  48. E0D8wmX/Gt6D63Tn1ORFn0OrOSV3qywb9xuQ3ZpSkQwX5+AwjRIcvB1HUTml5xJb
  49. cIxKom/DmCzsHl3Pi16F5U7kRLV0h++Wi4z5Inqdf1jchcPiVwPMZFd0sRwechdr
  50. 47PUJokwDb4YQuKGzMJDB/4uWQKBgQDS6G59Gc2dS04BXpSjPcY75JzQ8PIQdOgn
  51. oGdSOhr26hMHIuquHcxTmgLQVDJ1zdNMW6o+btYmGbq3kC25T0JtFn/xVN4kGGSt
  52. askSeC3wuoScRmQbC4ibriXtDI0rVVoW+q9PPxqjnCYumgAru8JPk//sF1OrOcgf
  53. fg4NQeNHXQKBgBReK5oED9U7o0U8i+NSyyAZu4NPu7fXK8+TyNlFg6uOkYY1Stl9
  54. mFGWwNOZr666oePD41AW+c/r23zbYR5HI23fKKBeuDLqzTRvOzCFhI/IaWcUqO7J
  55. 1Qr7xH9feXz/4K4vk3h/3iwDnrhjPRvYlAEzPFnN5NnttPbgtPe9UZiBAoGADHoN
  56. lz8Ah+6dhb03o9SStpZWpJGscEbKORXnJtkjITpFt+Vb5sMChGuXAQYKGif7+Qdv
  57. MdRSvNxGzHcuDUlgD54GIZu4rH/47L1leb88UIJoN+p+H492HGeX/McCTu70rmlU
  58. F6cPB2DEbXtUyUGJRDXoLOpQW8/GQ/6sDCK6tE0CgYEAqYZuCqT+mcuZLwwUzQD+
  59. 6Yucas5RNEXuBP+2/VjKLjUoNRmI1G3EvLVXtlhw5PFxXqUnNFoaQZ0LEqeNfhrh
  60. pBFgyA7DS+r2vSQNq52yjyyI6Jhe7dIc6I79e3uqSYQ0ODWm5z8IYzmQ64R/yeqb
  61. 2trVak9Z2+qu6bsdcDk6TEE=
  62. -----END PRIVATE KEY-----`;
  63. test('smtp listeners accept both plain SMTP and implicit TLS clients', async () => {
  64. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-submission-test-'));
  65. const certPath = path.join(dataDir, 'cert.pem');
  66. const keyPath = path.join(dataDir, 'key.pem');
  67. writeFileSync(certPath, testCert);
  68. writeFileSync(keyPath, testKey);
  69. const [server] = startSubmissionServer({
  70. enabled: true,
  71. listeners: [{ port: 0, protocol: 'smtp' }],
  72. hostname: 'localhost',
  73. allowInsecureAuth: true,
  74. tlsCertPath: certPath,
  75. tlsKeyPath: keyPath
  76. });
  77. await waitForListening(server);
  78. const port = server.address().port;
  79. try {
  80. const plainBanner = await readPlainBanner(port);
  81. assert.match(plainBanner, /^220 localhost MailHub SMTP ready/);
  82. const tlsBanner = await readImplicitTlsBanner(port);
  83. assert.match(tlsBanner, /^220 localhost MailHub SMTP ready/);
  84. } finally {
  85. await closeServer(server);
  86. }
  87. });
  88. test('SMTP STARTTLS preserves non-UTF-8 inbound message bytes', async () => {
  89. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-submission-starttls-'));
  90. const maildirRoot = path.join(dataDir, 'maildir');
  91. const certPath = path.join(dataDir, 'cert.pem');
  92. const keyPath = path.join(dataDir, 'key.pem');
  93. writeFileSync(certPath, testCert);
  94. writeFileSync(keyPath, testKey);
  95. initDatabase(dataDir, 'submission-starttls-secret');
  96. const user = createUser({
  97. username: 'submission-starttls',
  98. email: 'submission-starttls@example.com',
  99. password: 'password123'
  100. });
  101. createDomain(user.id, {
  102. domain: 'starttls.example',
  103. selector: 'mh',
  104. verificationToken: 'verify',
  105. dkimPublic: 'public',
  106. dkimPrivate: 'private',
  107. senderHost: 'mail.starttls.example',
  108. sendingIp: '192.0.2.72',
  109. spfExtra: '',
  110. dmarcPolicy: 'none',
  111. dmarcRua: ''
  112. });
  113. const mailbox = createInboundMailbox(user.id, {
  114. address: 'inbox@starttls.example',
  115. password: 'mailbox-password'
  116. });
  117. const [server] = startSubmissionServer({
  118. enabled: true,
  119. listeners: [{ port: 0, protocol: 'smtp' }],
  120. hostname: 'localhost',
  121. allowInsecureAuth: false,
  122. inboundEnabled: true,
  123. maildirEnabled: true,
  124. maildirRoot,
  125. tlsCertPath: certPath,
  126. tlsKeyPath: keyPath
  127. });
  128. await waitForListening(server);
  129. const rawMessage = Buffer.concat([
  130. Buffer.from([
  131. 'From: sender@example.net',
  132. `To: ${mailbox.address}`,
  133. 'Subject: STARTTLS bytes',
  134. 'Content-Type: text/plain; charset=ISO-8859-1',
  135. '',
  136. 'caf'
  137. ].join('\r\n'), 'ascii'),
  138. Buffer.from([0xe9, 0x0d, 0x0a])
  139. ]);
  140. try {
  141. await sendStartTlsMessage(server.address().port, mailbox.address, rawMessage);
  142. const [entry] = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
  143. assert.ok(entry);
  144. const stored = (await readMaildirMessage(entry)).bytes;
  145. assert.equal(stored.includes(Buffer.from([0x63, 0x61, 0x66, 0xe9])), true);
  146. assert.equal(stored.includes(Buffer.from('café', 'utf8')), false);
  147. } finally {
  148. await closeServer(server);
  149. }
  150. });
  151. test('submission tracking honors defaults and explicit control headers', () => {
  152. assert.deepEqual(resolveSubmissionTracking('Subject: A\r\n\r\nBody', false), {
  153. enabled: false,
  154. opens: false,
  155. clicks: false,
  156. explicit: false
  157. });
  158. assert.deepEqual(resolveSubmissionTracking('X-MailHub-Track: opens\r\n\r\nBody', false), {
  159. enabled: true,
  160. opens: true,
  161. clicks: false,
  162. explicit: true
  163. });
  164. assert.deepEqual(resolveSubmissionTracking('X-MailHub-Track: clicks, opens\r\n\r\nBody', false), {
  165. enabled: true,
  166. opens: true,
  167. clicks: true,
  168. explicit: true
  169. });
  170. assert.deepEqual(resolveSubmissionTracking('X-MailHub-Track: off\r\n\r\nBody', true), {
  171. enabled: false,
  172. opens: false,
  173. clicks: false,
  174. explicit: true
  175. });
  176. });
  177. function waitForListening(server) {
  178. if (server.listening) return Promise.resolve();
  179. return new Promise((resolve) => server.once('listening', resolve));
  180. }
  181. function closeServer(server) {
  182. return new Promise((resolve, reject) => {
  183. server.close((error) => error ? reject(error) : resolve());
  184. });
  185. }
  186. function readPlainBanner(port) {
  187. return new Promise((resolve, reject) => {
  188. const socket = net.createConnection({ host: '127.0.0.1', port });
  189. socket.setTimeout(3000);
  190. socket.once('data', (chunk) => {
  191. socket.end();
  192. resolve(chunk.toString('utf8').trim());
  193. });
  194. socket.once('error', reject);
  195. socket.once('timeout', () => reject(new Error('plain SMTP banner timed out')));
  196. });
  197. }
  198. function readImplicitTlsBanner(port) {
  199. return new Promise((resolve, reject) => {
  200. const socket = tls.connect({
  201. host: '127.0.0.1',
  202. port,
  203. rejectUnauthorized: false,
  204. servername: 'localhost'
  205. });
  206. socket.setTimeout(3000);
  207. socket.once('secureConnect', () => {
  208. socket.once('data', (chunk) => {
  209. socket.end();
  210. resolve(chunk.toString('utf8').trim());
  211. });
  212. });
  213. socket.once('error', reject);
  214. socket.once('timeout', () => reject(new Error('implicit TLS SMTP banner timed out')));
  215. });
  216. }
  217. async function sendStartTlsMessage(port, recipient, rawMessage) {
  218. const plainSocket = net.createConnection({ host: '127.0.0.1', port });
  219. plainSocket.setTimeout(3000);
  220. await waitForSocketEvent(plainSocket, 'connect');
  221. await readSmtpResponse(plainSocket, 220);
  222. await smtpCommand(plainSocket, 'EHLO sender.example.net', 250);
  223. await smtpCommand(plainSocket, 'STARTTLS', 220);
  224. const secureSocket = tls.connect({
  225. socket: plainSocket,
  226. rejectUnauthorized: false,
  227. servername: 'localhost'
  228. });
  229. secureSocket.setTimeout(3000);
  230. await waitForSocketEvent(secureSocket, 'secureConnect');
  231. try {
  232. await smtpCommand(secureSocket, 'EHLO sender.example.net', 250);
  233. await smtpCommand(secureSocket, 'MAIL FROM:<sender@example.net>', 250);
  234. await smtpCommand(secureSocket, `RCPT TO:<${recipient}>`, 250);
  235. await smtpCommand(secureSocket, 'DATA', 354);
  236. secureSocket.write(rawMessage);
  237. if (!rawMessage.subarray(-2).equals(Buffer.from('\r\n'))) secureSocket.write('\r\n');
  238. secureSocket.write('.\r\n');
  239. await readSmtpResponse(secureSocket, 250);
  240. await smtpCommand(secureSocket, 'QUIT', 221);
  241. } finally {
  242. secureSocket.destroy();
  243. }
  244. }
  245. async function smtpCommand(socket, command, expectedCode) {
  246. socket.write(`${command}\r\n`);
  247. return readSmtpResponse(socket, expectedCode);
  248. }
  249. function readSmtpResponse(socket, expectedCode) {
  250. return new Promise((resolve, reject) => {
  251. let buffer = '';
  252. const cleanup = () => {
  253. socket.off('data', onData);
  254. socket.off('error', onError);
  255. socket.off('timeout', onTimeout);
  256. };
  257. const onError = (error) => {
  258. cleanup();
  259. reject(error);
  260. };
  261. const onTimeout = () => onError(new Error('SMTP response timed out'));
  262. const onData = (chunk) => {
  263. buffer += Buffer.from(chunk).toString('latin1');
  264. const lines = buffer.split(/\r?\n/);
  265. const terminal = lines.find((line) => new RegExp(`^${expectedCode} `).test(line));
  266. if (!terminal) return;
  267. cleanup();
  268. resolve(buffer);
  269. };
  270. socket.on('data', onData);
  271. socket.once('error', onError);
  272. socket.once('timeout', onTimeout);
  273. });
  274. }
  275. function waitForSocketEvent(socket, event) {
  276. return new Promise((resolve, reject) => {
  277. const cleanup = () => {
  278. socket.off(event, onEvent);
  279. socket.off('error', onError);
  280. socket.off('timeout', onTimeout);
  281. };
  282. const onEvent = () => {
  283. cleanup();
  284. resolve();
  285. };
  286. const onError = (error) => {
  287. cleanup();
  288. reject(error);
  289. };
  290. const onTimeout = () => onError(new Error(`${event} timed out`));
  291. socket.once(event, onEvent);
  292. socket.once('error', onError);
  293. socket.once('timeout', onTimeout);
  294. });
  295. }