| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- 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 caches only successful credential checks', async () => {
- let verifierCalls = 0;
- const server = createDovecotAuthServer({
- secretFile: writeSecret(sharedSecret),
- authCacheTtlMs: 60_000,
- verifyCredential(_username, password) {
- verifierCalls += 1;
- if (password !== 'correct-password') return null;
- return { mailbox: { address: 'Alice@Example.com' } };
- }
- });
- await listen(server);
- try {
- const first = await request(server, { body: authBody({ password: 'correct-password' }) });
- assert.deepEqual(first.json, { authenticated: true, user: 'alice@example.com' });
- const cached = await request(server, { body: authBody({ password: 'correct-password' }) });
- assert.deepEqual(cached.json, { authenticated: true, user: 'alice@example.com' });
- assert.equal(verifierCalls, 1);
- const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
- assert.deepEqual(failed.json, { authenticated: false });
- const failedAgain = await request(server, { body: authBody({ password: 'wrong-password' }) });
- assert.deepEqual(failedAgain.json, { authenticated: false });
- assert.equal(verifierCalls, 3);
- } finally {
- await close(server);
- }
- });
- test('Dovecot authentication bridge coalesces concurrent credential checks', async () => {
- let verifierCalls = 0;
- let releaseVerifier;
- const verifierGate = new Promise((resolve) => {
- releaseVerifier = resolve;
- });
- const server = createDovecotAuthServer({
- secretFile: writeSecret(sharedSecret),
- verifyCredential: async (_username, password) => {
- verifierCalls += 1;
- await verifierGate;
- return password === 'correct-password'
- ? { mailbox: { address: 'Alice@Example.com' } }
- : null;
- }
- });
- await listen(server);
- try {
- const responses = Promise.all([
- request(server, { body: authBody({ password: 'correct-password' }) }),
- request(server, { body: authBody({ password: 'correct-password' }) }),
- request(server, { body: authBody({ password: 'correct-password' }) })
- ]);
- await waitFor(() => verifierCalls === 1);
- releaseVerifier();
- for (const response of await responses) {
- assert.equal(response.status, 200);
- assert.deepEqual(response.json, { authenticated: true, user: 'alice@example.com' });
- }
- assert.equal(verifierCalls, 1);
- } 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());
- });
- }
- async function waitFor(predicate) {
- for (let attempt = 0; attempt < 50; attempt += 1) {
- if (predicate()) return;
- await new Promise((resolve) => setTimeout(resolve, 10));
- }
- assert.fail('Timed out waiting for condition');
- }
- 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);
- });
- }
|