dovecot-auth-server.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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 never caches short-lived Webmail credentials', async () => {
  132. let verifierCalls = 0;
  133. let allowed = true;
  134. const server = createDovecotAuthServer({
  135. secretFile: writeSecret(sharedSecret),
  136. authCacheTtlMs: 60_000,
  137. verifyCredential(_username, password) {
  138. verifierCalls += 1;
  139. if (!allowed || password !== 'mhw_short-lived-session') return null;
  140. return {
  141. mailbox: { address: 'Alice@Example.com', ownerUserId: 42 },
  142. webmailSession: { actorUserId: 42 }
  143. };
  144. }
  145. });
  146. await listen(server);
  147. try {
  148. const first = await request(server, {
  149. body: authBody({ password: 'mhw_short-lived-session' })
  150. });
  151. assert.deepEqual(first.json, { authenticated: true, user: 'alice@example.com' });
  152. allowed = false;
  153. const revoked = await request(server, {
  154. body: authBody({ password: 'mhw_short-lived-session' })
  155. });
  156. assert.deepEqual(revoked.json, { authenticated: false });
  157. assert.equal(verifierCalls, 2);
  158. } finally {
  159. await close(server);
  160. }
  161. });
  162. test('Dovecot authentication bridge restricts delegated Webmail sessions with a fixed ACL group', async () => {
  163. const server = createDovecotAuthServer({
  164. secretFile: writeSecret(sharedSecret),
  165. verifyCredential(_username, password) {
  166. if (password === 'mhw_owner-session') {
  167. return {
  168. mailbox: { address: 'Alice@Example.com', ownerUserId: 42 },
  169. webmailSession: { actorUserId: 42 }
  170. };
  171. }
  172. if (password === 'mhw_delegate-session') {
  173. return {
  174. mailbox: { address: 'Alice@Example.com', ownerUserId: 42 },
  175. webmailSession: { actorUserId: 84 }
  176. };
  177. }
  178. if (password === 'mhw_untrusted-groups') {
  179. return {
  180. mailbox: {
  181. address: 'Alice@Example.com',
  182. ownerUserId: 42,
  183. aclGroups: 'mailhub_webmail_full_access'
  184. },
  185. webmailSession: {
  186. actorUserId: 84,
  187. aclGroups: 'mailhub_webmail_full_access'
  188. }
  189. };
  190. }
  191. return null;
  192. }
  193. });
  194. await listen(server);
  195. try {
  196. const owner = await request(server, {
  197. body: authBody({ password: 'mhw_owner-session' })
  198. });
  199. assert.deepEqual(owner.json, {
  200. authenticated: true,
  201. user: 'alice@example.com'
  202. });
  203. const delegate = await request(server, {
  204. body: authBody({ password: 'mhw_delegate-session' })
  205. });
  206. assert.deepEqual(delegate.json, {
  207. authenticated: true,
  208. user: 'alice@example.com',
  209. aclGroups: 'mailhub_webmail_readonly'
  210. });
  211. const ignoresVerifierGroups = await request(server, {
  212. body: authBody({ password: 'mhw_untrusted-groups' })
  213. });
  214. assert.deepEqual(ignoresVerifierGroups.json, {
  215. authenticated: true,
  216. user: 'alice@example.com',
  217. aclGroups: 'mailhub_webmail_readonly'
  218. });
  219. } finally {
  220. await close(server);
  221. }
  222. });
  223. test('Dovecot authentication bridge fails closed when Webmail ownership metadata is missing', async () => {
  224. const errors = [];
  225. const server = createDovecotAuthServer({
  226. secretFile: writeSecret(sharedSecret),
  227. verifyCredential() {
  228. return { mailbox: { address: 'Alice@Example.com' } };
  229. },
  230. logger: {
  231. error(message) {
  232. errors.push(message);
  233. }
  234. }
  235. });
  236. await listen(server);
  237. try {
  238. const response = await request(server, {
  239. body: authBody({ password: 'mhw_missing-ownership' })
  240. });
  241. assert.equal(response.status, 503);
  242. assert.deepEqual(response.json, { error: 'Service unavailable.' });
  243. assert.deepEqual(errors, ['Dovecot authentication bridge request failed.']);
  244. } finally {
  245. await close(server);
  246. }
  247. });
  248. test('Dovecot authentication bridge coalesces concurrent credential checks', async () => {
  249. let verifierCalls = 0;
  250. let releaseVerifier;
  251. const verifierGate = new Promise((resolve) => {
  252. releaseVerifier = resolve;
  253. });
  254. const server = createDovecotAuthServer({
  255. secretFile: writeSecret(sharedSecret),
  256. verifyCredential: async (_username, password) => {
  257. verifierCalls += 1;
  258. await verifierGate;
  259. return password === 'correct-password'
  260. ? { mailbox: { address: 'Alice@Example.com' } }
  261. : null;
  262. }
  263. });
  264. await listen(server);
  265. try {
  266. const responses = Promise.all([
  267. request(server, { body: authBody({ password: 'correct-password' }) }),
  268. request(server, { body: authBody({ password: 'correct-password' }) }),
  269. request(server, { body: authBody({ password: 'correct-password' }) })
  270. ]);
  271. await waitFor(() => verifierCalls === 1);
  272. releaseVerifier();
  273. for (const response of await responses) {
  274. assert.equal(response.status, 200);
  275. assert.deepEqual(response.json, { authenticated: true, user: 'alice@example.com' });
  276. }
  277. assert.equal(verifierCalls, 1);
  278. } finally {
  279. await close(server);
  280. }
  281. });
  282. test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => {
  283. let verifierCalls = 0;
  284. const limiter = new AuthenticationRateLimiter({
  285. combinationLimit: 1,
  286. accountLimit: 10,
  287. ipLimit: 10
  288. });
  289. const server = createDovecotAuthServer({
  290. secretFile: writeSecret(sharedSecret),
  291. authRateLimiter: limiter,
  292. verifyCredential(_username, password) {
  293. verifierCalls += 1;
  294. return password === 'correct-password'
  295. ? { mailbox: { address: 'user@example.com' } }
  296. : null;
  297. }
  298. });
  299. await listen(server);
  300. try {
  301. const failure = await request(server, {
  302. body: authBody({ password: 'wrong-password', remoteIp: '203.0.113.10' })
  303. });
  304. assert.deepEqual(failure.json, { authenticated: false });
  305. const blocked = await request(server, {
  306. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.10' })
  307. });
  308. assert.deepEqual(blocked.json, { authenticated: false });
  309. assert.equal(verifierCalls, 1);
  310. const otherIp = await request(server, {
  311. body: authBody({ password: 'correct-password', remoteIp: '203.0.113.11' })
  312. });
  313. assert.deepEqual(otherIp.json, { authenticated: true, user: 'user@example.com' });
  314. assert.equal(verifierCalls, 2);
  315. } finally {
  316. await close(server);
  317. }
  318. });
  319. test('Dovecot authentication bridge rejects mailbox addresses that could escape a home path', async () => {
  320. const unsafeAddresses = [
  321. '../escape@example.com',
  322. 'escape\\child@example.com',
  323. 'nul\u0000byte@example.com',
  324. ' leading@example.com',
  325. 'space user@example.com'
  326. ];
  327. for (const address of unsafeAddresses) {
  328. const server = createDovecotAuthServer({
  329. secretFile: writeSecret(sharedSecret),
  330. verifyCredential() {
  331. return { mailbox: { address } };
  332. },
  333. logger: { error() {} }
  334. });
  335. await listen(server);
  336. try {
  337. const response = await request(server, {
  338. body: authBody({ password: 'correct-password' })
  339. });
  340. assert.equal(response.status, 503);
  341. assert.deepEqual(response.json, { error: 'Service unavailable.' });
  342. } finally {
  343. await close(server);
  344. }
  345. }
  346. });
  347. function writeSecret(value) {
  348. const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-auth-'));
  349. const file = path.join(directory, 'secret');
  350. writeFileSync(file, `${value}\n`, { mode: 0o600 });
  351. return file;
  352. }
  353. function authBody(patch = {}) {
  354. return {
  355. username: 'alice@example.com',
  356. password: 'wrong-password',
  357. service: 'imap',
  358. remoteIp: '203.0.113.10',
  359. ...patch
  360. };
  361. }
  362. function listen(server) {
  363. server.listen(0, '127.0.0.1');
  364. return new Promise((resolve, reject) => {
  365. server.once('listening', resolve);
  366. server.once('error', reject);
  367. });
  368. }
  369. function close(server) {
  370. return new Promise((resolve, reject) => {
  371. server.close((error) => error ? reject(error) : resolve());
  372. });
  373. }
  374. async function waitFor(predicate) {
  375. for (let attempt = 0; attempt < 50; attempt += 1) {
  376. if (predicate()) return;
  377. await new Promise((resolve) => setTimeout(resolve, 10));
  378. }
  379. assert.fail('Timed out waiting for condition');
  380. }
  381. function request(server, {
  382. method = 'POST',
  383. requestPath = '/internal/dovecot/auth',
  384. secret = sharedSecret,
  385. contentType = 'application/json',
  386. body = authBody(),
  387. rawBody: suppliedRawBody,
  388. includeContentLength = true
  389. } = {}) {
  390. const rawBody = suppliedRawBody ?? (body === undefined ? '' : JSON.stringify(body));
  391. return new Promise((resolve, reject) => {
  392. const headers = {
  393. Authorization: `Bearer ${secret}`,
  394. 'Content-Type': contentType
  395. };
  396. if (includeContentLength) headers['Content-Length'] = String(Buffer.byteLength(rawBody));
  397. const req = http.request({
  398. host: '127.0.0.1',
  399. port: server.address().port,
  400. path: requestPath,
  401. method,
  402. headers
  403. }, (res) => {
  404. const chunks = [];
  405. res.on('data', (chunk) => chunks.push(chunk));
  406. res.on('end', () => {
  407. const raw = Buffer.concat(chunks).toString('utf8');
  408. resolve({
  409. status: res.statusCode,
  410. headers: res.headers,
  411. json: raw ? JSON.parse(raw) : null
  412. });
  413. });
  414. });
  415. req.once('error', reject);
  416. req.end(rawBody);
  417. });
  418. }