dovecot-auth-server.test.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  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 applies the shared limiter to the supplied remote IP', async () => {
  132. let verifierCalls = 0;
  133. const limiter = new AuthenticationRateLimiter({
  134. combinationLimit: 1,
  135. accountLimit: 10,
  136. ipLimit: 10
  137. });
  138. const server = createDovecotAuthServer({
  139. secretFile: writeSecret(sharedSecret),
  140. authRateLimiter: limiter,
  141. verifyCredential(_username, password) {
  142. verifierCalls += 1;
  143. return password === 'correct-password'
  144. ? { mailbox: { address: 'user@example.com' } }
  145. : null;
  146. }
  147. });
  148. await listen(server);
  149. try {
  150. const failure = await request(server, {
  151. body: authBody({ password: 'wrong-password', remoteIp: '203.0.113.10' })
  152. });
  153. assert.deepEqual(failure.json, { authenticated: false });
  154. const blocked = await request(server, {
  155. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.10' })
  156. });
  157. assert.deepEqual(blocked.json, { authenticated: false });
  158. assert.equal(verifierCalls, 1);
  159. const otherIp = await request(server, {
  160. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.11' })
  161. });
  162. assert.deepEqual(otherIp.json, { authenticated: true, user: 'user@example.com' });
  163. assert.equal(verifierCalls, 2);
  164. } finally {
  165. await close(server);
  166. }
  167. });
  168. test('Dovecot authentication bridge rejects mailbox addresses that could escape a home path', async () => {
  169. const unsafeAddresses = [
  170. '../escape@example.com',
  171. 'escape\\child@example.com',
  172. 'nul\u0000byte@example.com',
  173. ' leading@example.com',
  174. 'space user@example.com'
  175. ];
  176. for (const address of unsafeAddresses) {
  177. const server = createDovecotAuthServer({
  178. secretFile: writeSecret(sharedSecret),
  179. verifyCredential() {
  180. return { mailbox: { address } };
  181. },
  182. logger: { error() {} }
  183. });
  184. await listen(server);
  185. try {
  186. const response = await request(server, {
  187. body: authBody({ password: 'correct-password' })
  188. });
  189. assert.equal(response.status, 503);
  190. assert.deepEqual(response.json, { error: 'Service unavailable.' });
  191. } finally {
  192. await close(server);
  193. }
  194. }
  195. });
  196. function writeSecret(value) {
  197. const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-auth-'));
  198. const file = path.join(directory, 'secret');
  199. writeFileSync(file, `${value}\n`, { mode: 0o600 });
  200. return file;
  201. }
  202. function authBody(patch = {}) {
  203. return {
  204. username: 'alice@example.com',
  205. password: 'wrong-password',
  206. service: 'imap',
  207. remoteIp: '203.0.113.10',
  208. ...patch
  209. };
  210. }
  211. function listen(server) {
  212. server.listen(0, '127.0.0.1');
  213. return new Promise((resolve, reject) => {
  214. server.once('listening', resolve);
  215. server.once('error', reject);
  216. });
  217. }
  218. function close(server) {
  219. return new Promise((resolve, reject) => {
  220. server.close((error) => error ? reject(error) : resolve());
  221. });
  222. }
  223. function request(server, {
  224. method = 'POST',
  225. requestPath = '/internal/dovecot/auth',
  226. secret = sharedSecret,
  227. contentType = 'application/json',
  228. body = authBody(),
  229. rawBody: suppliedRawBody,
  230. includeContentLength = true
  231. } = {}) {
  232. const rawBody = suppliedRawBody ?? (body === undefined ? '' : JSON.stringify(body));
  233. return new Promise((resolve, reject) => {
  234. const headers = {
  235. Authorization: `Bearer ${secret}`,
  236. 'Content-Type': contentType
  237. };
  238. if (includeContentLength) headers['Content-Length'] = String(Buffer.byteLength(rawBody));
  239. const req = http.request({
  240. host: '127.0.0.1',
  241. port: server.address().port,
  242. path: requestPath,
  243. method,
  244. headers
  245. }, (res) => {
  246. const chunks = [];
  247. res.on('data', (chunk) => chunks.push(chunk));
  248. res.on('end', () => {
  249. const raw = Buffer.concat(chunks).toString('utf8');
  250. resolve({
  251. status: res.statusCode,
  252. headers: res.headers,
  253. json: raw ? JSON.parse(raw) : null
  254. });
  255. });
  256. });
  257. req.once('error', reject);
  258. req.end(rawBody);
  259. });
  260. }