import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { AuthenticationRateLimiter } from '../src/auth-rate-limit.js'; import { createDovecotAuthServer } from '../src/dovecot-auth-server.js'; const sharedSecret = 'mailhub-dovecot-auth-test-secret-0123456789'; test('Dovecot authentication bridge requires a strong file-backed secret', () => { assert.throws( () => createDovecotAuthServer({ secret: sharedSecret }), /secret file is required/ ); assert.throws( () => createDovecotAuthServer({ secretFile: writeSecret('short') }), /32-512 byte token/ ); }); test('Dovecot authentication bridge validates transport and returns fixed DTOs', async () => { const verifierCalls = []; const errors = []; const server = createDovecotAuthServer({ secretFile: writeSecret(sharedSecret), verifyCredential(username, password) { verifierCalls.push({ username, password }); if (password === 'throw-error') throw new Error(`sensitive ${password}`); if (password !== 'correct-password') return null; return { user: { id: 42, role: 'admin' }, mailbox: { id: 7, address: 'Alice@Example.com', passwordHash: 'must-not-leak', forwardTo: ['private@example.net'] } }; }, logger: { error(message) { errors.push(message); } } }); await listen(server); try { const unauthorized = await request(server, { secret: 'wrong-secret' }); assert.equal(unauthorized.status, 401); assert.equal(unauthorized.headers['cache-control'], 'no-store'); assert.deepEqual(unauthorized.json, { error: 'Unauthorized.' }); assert.equal(verifierCalls.length, 0); const wrongMethod = await request(server, { method: 'GET', body: undefined }); assert.equal(wrongMethod.status, 405); assert.equal(wrongMethod.headers.allow, 'POST'); const wrongContentType = await request(server, { contentType: 'text/plain' }); assert.equal(wrongContentType.status, 415); const invalidIp = await request(server, { body: authBody({ remoteIp: 'not-an-ip' }) }); assert.equal(invalidIp.status, 400); assert.equal(verifierCalls.length, 0); const malformed = await request(server, { rawBody: '{"username":' }); assert.equal(malformed.status, 400); assert.equal(verifierCalls.length, 0); const oversized = await request(server, { body: authBody({ password: 'x'.repeat(9 * 1024) }) }); assert.equal(oversized.status, 413); assert.equal(verifierCalls.length, 0); const streamedOversized = await request(server, { body: authBody({ password: 'x'.repeat(9 * 1024) }), includeContentLength: false }); assert.equal(streamedOversized.status, 413); assert.equal(verifierCalls.length, 0); const failed = await request(server, { body: authBody({ password: 'wrong-password' }) }); assert.equal(failed.status, 200); assert.deepEqual(failed.json, { authenticated: false }); assert.equal(failed.headers['cache-control'], 'no-store'); const succeeded = await request(server, { body: authBody({ password: 'correct-password' }) }); assert.equal(succeeded.status, 200); assert.deepEqual(succeeded.json, { authenticated: true, user: 'alice@example.com' }); assert.equal(JSON.stringify(succeeded.json).includes('must-not-leak'), false); assert.equal(JSON.stringify(succeeded.json).includes('private@example.net'), false); const pop3Succeeded = await request(server, { body: authBody({ password: 'correct-password', service: 'pop3' }) }); assert.equal(pop3Succeeded.status, 200); assert.deepEqual(pop3Succeeded.json, { authenticated: true, user: 'alice@example.com' }); const unavailable = await request(server, { body: authBody({ password: 'throw-error' }) }); assert.equal(unavailable.status, 503); assert.deepEqual(unavailable.json, { error: 'Service unavailable.' }); assert.deepEqual(errors, ['Dovecot authentication bridge request failed.']); assert.equal(errors.join(' ').includes('throw-error'), false); assert.equal(errors.join(' ').includes(sharedSecret), false); } finally { await close(server); } }); test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => { let verifierCalls = 0; const limiter = new AuthenticationRateLimiter({ combinationLimit: 1, accountLimit: 10, ipLimit: 10 }); const server = createDovecotAuthServer({ secretFile: writeSecret(sharedSecret), authRateLimiter: limiter, verifyCredential(_username, password) { verifierCalls += 1; return password === 'correct-password' ? { mailbox: { address: 'user@example.com' } } : null; } }); await listen(server); try { const failure = await request(server, { body: authBody({ password: 'wrong-password', remoteIp: '203.0.113.10' }) }); assert.deepEqual(failure.json, { authenticated: false }); const blocked = await request(server, { body: authBody({ password: 'correct-password', remoteIp: '203.0.113.10' }) }); assert.deepEqual(blocked.json, { authenticated: false }); assert.equal(verifierCalls, 1); const otherIp = await request(server, { body: authBody({ password: 'correct-password', remoteIp: '203.0.113.11' }) }); assert.deepEqual(otherIp.json, { authenticated: true, user: 'user@example.com' }); assert.equal(verifierCalls, 2); } finally { await close(server); } }); test('Dovecot authentication bridge rejects mailbox addresses that could escape a home path', async () => { const unsafeAddresses = [ '../escape@example.com', 'escape\\child@example.com', 'nul\u0000byte@example.com', ' leading@example.com', 'space user@example.com' ]; for (const address of unsafeAddresses) { const server = createDovecotAuthServer({ secretFile: writeSecret(sharedSecret), verifyCredential() { return { mailbox: { address } }; }, logger: { error() {} } }); await listen(server); try { const response = await request(server, { body: authBody({ password: 'correct-password' }) }); assert.equal(response.status, 503); assert.deepEqual(response.json, { error: 'Service unavailable.' }); } finally { await close(server); } } }); function writeSecret(value) { const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-auth-')); const file = path.join(directory, 'secret'); writeFileSync(file, `${value}\n`, { mode: 0o600 }); return file; } function authBody(patch = {}) { return { username: 'alice@example.com', password: 'wrong-password', service: 'imap', remoteIp: '203.0.113.10', ...patch }; } function listen(server) { server.listen(0, '127.0.0.1'); return new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); }); } function close(server) { return new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); } function request(server, { method = 'POST', requestPath = '/internal/dovecot/auth', secret = sharedSecret, contentType = 'application/json', body = authBody(), rawBody: suppliedRawBody, includeContentLength = true } = {}) { const rawBody = suppliedRawBody ?? (body === undefined ? '' : JSON.stringify(body)); return new Promise((resolve, reject) => { const headers = { Authorization: `Bearer ${secret}`, 'Content-Type': contentType }; if (includeContentLength) headers['Content-Length'] = String(Buffer.byteLength(rawBody)); const req = http.request({ host: '127.0.0.1', port: server.address().port, path: requestPath, method, headers }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, headers: res.headers, json: raw ? JSON.parse(raw) : null }); }); }); req.once('error', reject); req.end(rawBody); }); }