dovecot-auth-server.test.js 16 KB

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