| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- import crypto from 'node:crypto';
- import { readFileSync } from 'node:fs';
- import http from 'node:http';
- import { isIP } from 'node:net';
- import { authenticateWithRateLimitAsync, authenticationRateLimiter } from './auth-rate-limit.js';
- import { verifyInboundMailboxCredentialAsync } from './db.js';
- const authPath = '/internal/dovecot/auth';
- const defaultBodyLimit = 8 * 1024;
- const defaultRequestTimeoutMs = 30_000;
- const defaultAuthCacheTtlMs = 600_000;
- const defaultAuthCacheMaxEntries = 4096;
- export function createDovecotAuthServer(options = {}) {
- const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
- const limiter = options.authRateLimiter || authenticationRateLimiter;
- const verifyCredential = options.verifyCredential || verifyInboundMailboxCredentialAsync;
- const logger = options.logger || console;
- const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
- const authCache = options.authCache || new SuccessfulAuthCache({
- ttlMs: options.authCacheTtlMs,
- maxEntries: options.authCacheMaxEntries,
- secretDigest: sharedSecretDigest
- });
- const inFlightAuth = options.inFlightAuth || new InFlightAuthChecks({
- maxEntries: options.inFlightAuthMaxEntries,
- secretDigest: sharedSecretDigest
- });
- const server = http.createServer((req, res) => {
- void handleRequest(req, res, {
- sharedSecretDigest,
- limiter,
- verifyCredential,
- logger,
- bodyLimit: defaultBodyLimit,
- authCache,
- inFlightAuth
- });
- });
- server.requestTimeout = requestTimeoutMs;
- server.headersTimeout = requestTimeoutMs;
- server.keepAliveTimeout = 1_000;
- return server;
- }
- export function startDovecotAuthServer(options = {}) {
- const server = createDovecotAuthServer(options);
- const host = String(options.host || '0.0.0.0');
- const port = Number(options.port ?? 3001);
- server.listen(port, host, () => {
- options.onListening?.(server);
- });
- return server;
- }
- async function handleRequest(req, res, context) {
- setPrivateHeaders(res);
- const pathname = requestPathname(req);
- if (pathname !== authPath) return sendJson(res, 404, { error: 'Not found.' });
- if (req.method !== 'POST') {
- res.setHeader('Allow', 'POST');
- return sendJson(res, 405, { error: 'Method not allowed.' });
- }
- if (!validBearerSecret(req.headers.authorization, context.sharedSecretDigest)) {
- return sendJson(res, 401, { error: 'Unauthorized.' });
- }
- if (requestContentType(req) !== 'application/json') {
- return sendJson(res, 415, { error: 'Unsupported media type.' });
- }
- const contentLength = parseContentLength(req.headers['content-length']);
- if (contentLength === null) return sendJson(res, 400, { error: 'Invalid request.' });
- if (contentLength > context.bodyLimit) return sendTooLarge(req, res);
- let body;
- try {
- body = await readJson(req, context.bodyLimit);
- } catch (error) {
- if (error instanceof RequestTooLargeError) return sendTooLarge(req, res);
- return sendJson(res, 400, { error: 'Invalid request.' });
- }
- const request = normalizeAuthRequest(body);
- if (!request) return sendJson(res, 400, { error: 'Invalid request.' });
- try {
- const authenticated = await authenticateWithRateLimitAsync({
- limiter: context.limiter,
- ip: request.remoteIp,
- account: request.username,
- authenticate: () => verifyCachedCredential(request, context)
- });
- if (!authenticated) return sendJson(res, 200, { authenticated: false });
- return sendJson(res, 200, { authenticated: true, user: authenticated.user });
- } catch {
- context.logger.error?.('Dovecot authentication bridge request failed.');
- return sendJson(res, 503, { error: 'Service unavailable.' });
- }
- }
- async function verifyCachedCredential(request, context) {
- const cachedUser = context.authCache?.get(request.username, request.password);
- if (cachedUser) return { user: cachedUser };
- return context.inFlightAuth.run(request.username, request.password, async () => {
- const recheckedUser = context.authCache?.get(request.username, request.password);
- if (recheckedUser) return { user: recheckedUser };
- const authenticated = await context.verifyCredential(request.username, request.password);
- if (!authenticated) return null;
- const user = canonicalMailboxAddress(authenticated);
- if (!user) throw new Error('Credential verifier returned an invalid mailbox');
- context.authCache?.set(request.username, request.password, user);
- return { user };
- });
- }
- function readSharedSecret(filePath) {
- if (!filePath || typeof filePath !== 'string') {
- throw new Error('Dovecot authentication secret file is required');
- }
- const secret = readFileSync(filePath, 'utf8').trim();
- const bytes = Buffer.byteLength(secret, 'utf8');
- if (bytes < 32 || bytes > 512 || /\s/.test(secret)) {
- throw new Error('Dovecot authentication secret must be a 32-512 byte token');
- }
- return secret;
- }
- function validBearerSecret(header, expectedDigest) {
- const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i);
- const actualDigest = digestSecret(match?.[1] || '');
- return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest);
- }
- function digestSecret(value) {
- return crypto.createHash('sha256').update(value).digest();
- }
- class SuccessfulAuthCache {
- constructor({
- ttlMs = defaultAuthCacheTtlMs,
- maxEntries = defaultAuthCacheMaxEntries,
- secretDigest = crypto.randomBytes(32),
- now = () => Date.now()
- } = {}) {
- this.ttlMs = Math.max(0, Number(ttlMs ?? defaultAuthCacheTtlMs) || 0);
- this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
- this.secretDigest = Buffer.from(secretDigest);
- this.now = now;
- this.entries = new Map();
- }
- get(username, password) {
- if (!this.enabled()) return '';
- const key = this.key(username, password);
- const entry = this.entries.get(key);
- if (!entry) return '';
- if (entry.expiresAt <= this.now()) {
- this.entries.delete(key);
- return '';
- }
- this.entries.delete(key);
- this.entries.set(key, entry);
- return entry.user;
- }
- set(username, password, user) {
- if (!this.enabled()) return;
- const cleanUser = String(user || '').trim().toLowerCase();
- if (!cleanUser) return;
- const key = this.key(username, password);
- this.entries.set(key, {
- user: cleanUser,
- expiresAt: this.now() + this.ttlMs
- });
- while (this.entries.size > this.maxEntries) {
- const oldestKey = this.entries.keys().next().value;
- if (oldestKey === undefined) break;
- this.entries.delete(oldestKey);
- }
- }
- enabled() {
- return this.ttlMs > 0 && this.maxEntries > 0;
- }
- key(username, password) {
- return crypto
- .createHmac('sha256', this.secretDigest)
- .update(String(username || '').trim().toLowerCase())
- .update('\0')
- .update(String(password || ''))
- .digest('hex');
- }
- }
- class InFlightAuthChecks {
- constructor({
- maxEntries = defaultAuthCacheMaxEntries,
- secretDigest = crypto.randomBytes(32)
- } = {}) {
- this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
- this.secretDigest = Buffer.from(secretDigest);
- this.entries = new Map();
- }
- run(username, password, authenticate) {
- if (!this.enabled()) return authenticate();
- const key = this.key(username, password);
- const existing = this.entries.get(key);
- if (existing) return existing;
- const pending = Promise.resolve()
- .then(authenticate)
- .finally(() => {
- this.entries.delete(key);
- });
- this.entries.set(key, pending);
- while (this.entries.size > this.maxEntries) {
- const oldestKey = this.entries.keys().next().value;
- if (oldestKey === undefined || oldestKey === key) break;
- this.entries.delete(oldestKey);
- }
- return pending;
- }
- enabled() {
- return this.maxEntries > 0;
- }
- key(username, password) {
- return crypto
- .createHmac('sha256', this.secretDigest)
- .update(String(username || '').trim().toLowerCase())
- .update('\0')
- .update(String(password || ''))
- .digest('hex');
- }
- }
- function requestPathname(req) {
- try {
- return new URL(req.url || '/', 'http://mailhub.internal').pathname;
- } catch {
- return '';
- }
- }
- function requestContentType(req) {
- return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
- }
- function parseContentLength(value) {
- if (value === undefined) return 0;
- const raw = String(value);
- if (!/^\d+$/.test(raw)) return null;
- const parsed = Number(raw);
- return Number.isSafeInteger(parsed) ? parsed : null;
- }
- async function readJson(req, limit) {
- const chunks = [];
- let bytes = 0;
- for await (const chunk of req) {
- bytes += chunk.length;
- if (bytes > limit) throw new RequestTooLargeError();
- chunks.push(chunk);
- }
- if (!chunks.length) throw new Error('Request body is required');
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
- if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required');
- return body;
- }
- function normalizeAuthRequest(body) {
- if (typeof body.username !== 'string' || typeof body.password !== 'string') return null;
- if (typeof body.remoteIp !== 'string') return null;
- const username = body.username.trim();
- const remoteIp = body.remoteIp.trim();
- const service = String(body.service || 'imap').trim().toLowerCase();
- if (!username || Buffer.byteLength(username, 'utf8') > 320 || /[\r\n\u0000]/.test(username)) return null;
- if (Buffer.byteLength(body.password, 'utf8') > defaultBodyLimit) return null;
- if (!isIP(remoteIp) || !['imap', 'pop3'].includes(service)) return null;
- return { username, password: body.password, remoteIp };
- }
- function canonicalMailboxAddress(authenticated) {
- const rawAddress = String(authenticated?.mailbox?.address || '');
- const address = rawAddress.toLowerCase();
- if (
- rawAddress !== rawAddress.trim()
- || Buffer.byteLength(address, 'utf8') > 320
- || /[\/\\\u0000\s]/.test(address)
- || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
- ) return '';
- return address;
- }
- function setPrivateHeaders(res) {
- res.setHeader('Cache-Control', 'no-store');
- res.setHeader('Pragma', 'no-cache');
- res.setHeader('X-Content-Type-Options', 'nosniff');
- }
- function sendJson(res, status, payload, headers = {}) {
- if (res.writableEnded) return;
- const body = JSON.stringify(payload);
- res.writeHead(status, {
- 'Content-Type': 'application/json; charset=utf-8',
- 'Content-Length': String(Buffer.byteLength(body)),
- ...headers
- });
- res.end(body);
- }
- function sendTooLarge(req, res) {
- req.resume();
- return sendJson(res, 413, { error: 'Request too large.' }, { Connection: 'close' });
- }
- function positiveInteger(value, fallback) {
- const parsed = Number(value);
- return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
- }
- class RequestTooLargeError extends Error {}
|