dovecot-auth-server.test.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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 caches only successful credential checks', async () => {
  105. let verifierCalls = 0;
  106. const server = createDovecotAuthServer({
  107. secretFile: writeSecret(sharedSecret),
  108. authCacheTtlMs: 60_000,
  109. verifyCredential(_username, password) {
  110. verifierCalls += 1;
  111. if (password !== 'correct-password') return null;
  112. return { mailbox: { address: 'Alice@Example.com' } };
  113. }
  114. });
  115. await listen(server);
  116. try {
  117. const first = await request(server, { body: authBody({ password: 'correct-password' }) });
  118. assert.deepEqual(first.json, { authenticated: true, user: 'alice@example.com' });
  119. const cached = await request(server, { body: authBody({ password: 'correct-password' }) });
  120. assert.deepEqual(cached.json, { authenticated: true, user: 'alice@example.com' });
  121. assert.equal(verifierCalls, 1);
  122. const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
  123. assert.deepEqual(failed.json, { authenticated: false });
  124. const failedAgain = await request(server, { body: authBody({ password: 'wrong-password' }) });
  125. assert.deepEqual(failedAgain.json, { authenticated: false });
  126. assert.equal(verifierCalls, 3);
  127. } finally {
  128. await close(server);
  129. }
  130. });
  131. test('Dovecot authentication bridge coalesces concurrent credential checks', async () => {
  132. let verifierCalls = 0;
  133. let releaseVerifier;
  134. const verifierGate = new Promise((resolve) => {
  135. releaseVerifier = resolve;
  136. });
  137. const server = createDovecotAuthServer({
  138. secretFile: writeSecret(sharedSecret),
  139. verifyCredential: async (_username, password) => {
  140. verifierCalls += 1;
  141. await verifierGate;
  142. return password === 'correct-password'
  143. ? { mailbox: { address: 'Alice@Example.com' } }
  144. : null;
  145. }
  146. });
  147. await listen(server);
  148. try {
  149. const responses = Promise.all([
  150. request(server, { body: authBody({ password: 'correct-password' }) }),
  151. request(server, { body: authBody({ password: 'correct-password' }) }),
  152. request(server, { body: authBody({ password: 'correct-password' }) })
  153. ]);
  154. await waitFor(() => verifierCalls === 1);
  155. releaseVerifier();
  156. for (const response of await responses) {
  157. assert.equal(response.status, 200);
  158. assert.deepEqual(response.json, { authenticated: true, user: 'alice@example.com' });
  159. }
  160. assert.equal(verifierCalls, 1);
  161. } finally {
  162. await close(server);
  163. }
  164. });
  165. test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => {
  166. let verifierCalls = 0;
  167. const limiter = new AuthenticationRateLimiter({
  168. combinationLimit: 1,
  169. accountLimit: 10,
  170. ipLimit: 10
  171. });
  172. const server = createDovecotAuthServer({
  173. secretFile: writeSecret(sharedSecret),
  174. authRateLimiter: limiter,
  175. verifyCredential(_username, password) {
  176. verifierCalls += 1;
  177. return password === 'correct-password'
  178. ? { mailbox: { address: 'user@example.com' } }
  179. : null;
  180. }
  181. });
  182. await listen(server);
  183. try {
  184. const failure = await request(server, {
  185. body: authBody({ password: 'wrong-password', remoteIp: '203.0.113.10' })
  186. });
  187. assert.deepEqual(failure.json, { authenticated: false });
  188. const blocked = await request(server, {
  189. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.10' })
  190. });
  191. assert.deepEqual(blocked.json, { authenticated: false });
  192. assert.equal(verifierCalls, 1);
  193. const otherIp = await request(server, {
  194. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.11' })
  195. });
  196. assert.deepEqual(otherIp.json, { authenticated: true, user: 'user@example.com' });
  197. assert.equal(verifierCalls, 2);
  198. } finally {
  199. await close(server);
  200. }
  201. });
  202. test('Dovecot authentication bridge rejects mailbox addresses that could escape a home path', async () => {
  203. const unsafeAddresses = [
  204. '../escape@example.com',
  205. 'escape\\child@example.com',
  206. 'nul\u0000byte@example.com',
  207. ' leading@example.com',
  208. 'space user@example.com'
  209. ];
  210. for (const address of unsafeAddresses) {
  211. const server = createDovecotAuthServer({
  212. secretFile: writeSecret(sharedSecret),
  213. verifyCredential() {
  214. return { mailbox: { address } };
  215. },
  216. logger: { error() {} }
  217. });
  218. await listen(server);
  219. try {
  220. const response = await request(server, {
  221. body: authBody({ password: 'correct-password' })
  222. });
  223. assert.equal(response.status, 503);
  224. assert.deepEqual(response.json, { error: 'Service unavailable.' });
  225. } finally {
  226. await close(server);
  227. }
  228. }
  229. });
  230. function writeSecret(value) {
  231. const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-auth-'));
  232. const file = path.join(directory, 'secret');
  233. writeFileSync(file, `${value}\n`, { mode: 0o600 });
  234. return file;
  235. }
  236. function authBody(patch = {}) {
  237. return {
  238. username: 'alice@example.com',
  239. password: 'wrong-password',
  240. service: 'imap',
  241. remoteIp: '203.0.113.10',
  242. ...patch
  243. };
  244. }
  245. function listen(server) {
  246. server.listen(0, '127.0.0.1');
  247. return new Promise((resolve, reject) => {
  248. server.once('listening', resolve);
  249. server.once('error', reject);
  250. });
  251. }
  252. function close(server) {
  253. return new Promise((resolve, reject) => {
  254. server.close((error) => error ? reject(error) : resolve());
  255. });
  256. }
  257. async function waitFor(predicate) {
  258. for (let attempt = 0; attempt < 50; attempt += 1) {
  259. if (predicate()) return;
  260. await new Promise((resolve) => setTimeout(resolve, 10));
  261. }
  262. assert.fail('Timed out waiting for condition');
  263. }
  264. function request(server, {
  265. method = 'POST',
  266. requestPath = '/internal/dovecot/auth',
  267. secret = sharedSecret,
  268. contentType = 'application/json',
  269. body = authBody(),
  270. rawBody: suppliedRawBody,
  271. includeContentLength = true
  272. } = {}) {
  273. const rawBody = suppliedRawBody ?? (body === undefined ? '' : JSON.stringify(body));
  274. return new Promise((resolve, reject) => {
  275. const headers = {
  276. Authorization: `Bearer ${secret}`,
  277. 'Content-Type': contentType
  278. };
  279. if (includeContentLength) headers['Content-Length'] = String(Buffer.byteLength(rawBody));
  280. const req = http.request({
  281. host: '127.0.0.1',
  282. port: server.address().port,
  283. path: requestPath,
  284. method,
  285. headers
  286. }, (res) => {
  287. const chunks = [];
  288. res.on('data', (chunk) => chunks.push(chunk));
  289. res.on('end', () => {
  290. const raw = Buffer.concat(chunks).toString('utf8');
  291. resolve({
  292. status: res.statusCode,
  293. headers: res.headers,
  294. json: raw ? JSON.parse(raw) : null
  295. });
  296. });
  297. });
  298. req.once('error', reject);
  299. req.end(rawBody);
  300. });
  301. }