dovecot-auth-server.test.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync, writeFileSync } from 'node:fs';
  3. import http from 'node:http';
  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 { createDovecotAuthServer } from '../src/dovecot-auth-server.js';
  9. const sharedSecret = 'mailhub-dovecot-auth-test-secret-0123456789';
  10. test('Dovecot authentication bridge requires a strong file-backed secret', () => {
  11. assert.throws(
  12. () => createDovecotAuthServer({ secret: sharedSecret }),
  13. /secret file is required/
  14. );
  15. assert.throws(
  16. () => createDovecotAuthServer({ secretFile: writeSecret('short') }),
  17. /32-512 byte token/
  18. );
  19. });
  20. test('Dovecot authentication bridge validates transport and returns fixed DTOs', async () => {
  21. const verifierCalls = [];
  22. const errors = [];
  23. const server = createDovecotAuthServer({
  24. secretFile: writeSecret(sharedSecret),
  25. verifyCredential(username, password) {
  26. verifierCalls.push({ username, password });
  27. if (password === 'throw-error') throw new Error(`sensitive ${password}`);
  28. if (password !== 'correct-password') return null;
  29. return {
  30. user: { id: 42, role: 'admin' },
  31. mailbox: {
  32. id: 7,
  33. address: 'Alice@Example.com',
  34. passwordHash: 'must-not-leak',
  35. forwardTo: ['private@example.net']
  36. }
  37. };
  38. },
  39. logger: {
  40. error(message) {
  41. errors.push(message);
  42. }
  43. }
  44. });
  45. await listen(server);
  46. try {
  47. const unauthorized = await request(server, { secret: 'wrong-secret' });
  48. assert.equal(unauthorized.status, 401);
  49. assert.equal(unauthorized.headers['cache-control'], 'no-store');
  50. assert.deepEqual(unauthorized.json, { error: 'Unauthorized.' });
  51. assert.equal(verifierCalls.length, 0);
  52. const wrongMethod = await request(server, { method: 'GET', body: undefined });
  53. assert.equal(wrongMethod.status, 405);
  54. assert.equal(wrongMethod.headers.allow, 'POST');
  55. const wrongContentType = await request(server, { contentType: 'text/plain' });
  56. assert.equal(wrongContentType.status, 415);
  57. const invalidIp = await request(server, { body: authBody({ remoteIp: 'not-an-ip' }) });
  58. assert.equal(invalidIp.status, 400);
  59. assert.equal(verifierCalls.length, 0);
  60. const malformed = await request(server, { rawBody: '{"username":' });
  61. assert.equal(malformed.status, 400);
  62. assert.equal(verifierCalls.length, 0);
  63. const oversized = await request(server, {
  64. body: authBody({ password: 'x'.repeat(9 * 1024) })
  65. });
  66. assert.equal(oversized.status, 413);
  67. assert.equal(verifierCalls.length, 0);
  68. const streamedOversized = await request(server, {
  69. body: authBody({ password: 'x'.repeat(9 * 1024) }),
  70. includeContentLength: false
  71. });
  72. assert.equal(streamedOversized.status, 413);
  73. assert.equal(verifierCalls.length, 0);
  74. const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
  75. assert.equal(failed.status, 200);
  76. assert.deepEqual(failed.json, { authenticated: false });
  77. assert.equal(failed.headers['cache-control'], 'no-store');
  78. const succeeded = await request(server, { body: authBody({ password: 'correct-password' }) });
  79. assert.equal(succeeded.status, 200);
  80. assert.deepEqual(succeeded.json, {
  81. authenticated: true,
  82. user: 'alice@example.com'
  83. });
  84. assert.equal(JSON.stringify(succeeded.json).includes('must-not-leak'), false);
  85. assert.equal(JSON.stringify(succeeded.json).includes('private@example.net'), false);
  86. const pop3Succeeded = await request(server, {
  87. body: authBody({ password: 'correct-password', service: 'pop3' })
  88. });
  89. assert.equal(pop3Succeeded.status, 200);
  90. assert.deepEqual(pop3Succeeded.json, {
  91. authenticated: true,
  92. user: 'alice@example.com'
  93. });
  94. const unavailable = await request(server, { body: authBody({ password: 'throw-error' }) });
  95. assert.equal(unavailable.status, 503);
  96. assert.deepEqual(unavailable.json, { error: 'Service unavailable.' });
  97. assert.deepEqual(errors, ['Dovecot authentication bridge request failed.']);
  98. assert.equal(errors.join(' ').includes('throw-error'), false);
  99. assert.equal(errors.join(' ').includes(sharedSecret), false);
  100. } finally {
  101. await close(server);
  102. }
  103. });
  104. test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => {
  105. let verifierCalls = 0;
  106. const limiter = new AuthenticationRateLimiter({
  107. combinationLimit: 1,
  108. accountLimit: 10,
  109. ipLimit: 10
  110. });
  111. const server = createDovecotAuthServer({
  112. secretFile: writeSecret(sharedSecret),
  113. authRateLimiter: limiter,
  114. verifyCredential(_username, password) {
  115. verifierCalls += 1;
  116. return password === 'correct-password'
  117. ? { mailbox: { address: 'user@example.com' } }
  118. : null;
  119. }
  120. });
  121. await listen(server);
  122. try {
  123. const failure = await request(server, {
  124. body: authBody({ password: 'wrong-password', remoteIp: '203.0.113.10' })
  125. });
  126. assert.deepEqual(failure.json, { authenticated: false });
  127. const blocked = await request(server, {
  128. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.10' })
  129. });
  130. assert.deepEqual(blocked.json, { authenticated: false });
  131. assert.equal(verifierCalls, 1);
  132. const otherIp = await request(server, {
  133. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.11' })
  134. });
  135. assert.deepEqual(otherIp.json, { authenticated: true, user: 'user@example.com' });
  136. assert.equal(verifierCalls, 2);
  137. } finally {
  138. await close(server);
  139. }
  140. });
  141. test('Dovecot authentication bridge rejects mailbox addresses that could escape a home path', async () => {
  142. const unsafeAddresses = [
  143. '../escape@example.com',
  144. 'escape\\child@example.com',
  145. 'nul\u0000byte@example.com',
  146. ' leading@example.com',
  147. 'space user@example.com'
  148. ];
  149. for (const address of unsafeAddresses) {
  150. const server = createDovecotAuthServer({
  151. secretFile: writeSecret(sharedSecret),
  152. verifyCredential() {
  153. return { mailbox: { address } };
  154. },
  155. logger: { error() {} }
  156. });
  157. await listen(server);
  158. try {
  159. const response = await request(server, {
  160. body: authBody({ password: 'correct-password' })
  161. });
  162. assert.equal(response.status, 503);
  163. assert.deepEqual(response.json, { error: 'Service unavailable.' });
  164. } finally {
  165. await close(server);
  166. }
  167. }
  168. });
  169. function writeSecret(value) {
  170. const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-auth-'));
  171. const file = path.join(directory, 'secret');
  172. writeFileSync(file, `${value}\n`, { mode: 0o600 });
  173. return file;
  174. }
  175. function authBody(patch = {}) {
  176. return {
  177. username: 'alice@example.com',
  178. password: 'wrong-password',
  179. service: 'imap',
  180. remoteIp: '203.0.113.10',
  181. ...patch
  182. };
  183. }
  184. function listen(server) {
  185. server.listen(0, '127.0.0.1');
  186. return new Promise((resolve, reject) => {
  187. server.once('listening', resolve);
  188. server.once('error', reject);
  189. });
  190. }
  191. function close(server) {
  192. return new Promise((resolve, reject) => {
  193. server.close((error) => error ? reject(error) : resolve());
  194. });
  195. }
  196. function request(server, {
  197. method = 'POST',
  198. requestPath = '/internal/dovecot/auth',
  199. secret = sharedSecret,
  200. contentType = 'application/json',
  201. body = authBody(),
  202. rawBody: suppliedRawBody,
  203. includeContentLength = true
  204. } = {}) {
  205. const rawBody = suppliedRawBody ?? (body === undefined ? '' : JSON.stringify(body));
  206. return new Promise((resolve, reject) => {
  207. const headers = {
  208. Authorization: `Bearer ${secret}`,
  209. 'Content-Type': contentType
  210. };
  211. if (includeContentLength) headers['Content-Length'] = String(Buffer.byteLength(rawBody));
  212. const req = http.request({
  213. host: '127.0.0.1',
  214. port: server.address().port,
  215. path: requestPath,
  216. method,
  217. headers
  218. }, (res) => {
  219. const chunks = [];
  220. res.on('data', (chunk) => chunks.push(chunk));
  221. res.on('end', () => {
  222. const raw = Buffer.concat(chunks).toString('utf8');
  223. resolve({
  224. status: res.statusCode,
  225. headers: res.headers,
  226. json: raw ? JSON.parse(raw) : null
  227. });
  228. });
  229. });
  230. req.once('error', reject);
  231. req.end(rawBody);
  232. });
  233. }