dovecot-auth-server.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import crypto from 'node:crypto';
  2. import { readFileSync } from 'node:fs';
  3. import http from 'node:http';
  4. import { isIP } from 'node:net';
  5. import { authenticateWithRateLimitAsync, authenticationRateLimiter } from './auth-rate-limit.js';
  6. import { verifyInboundMailboxCredentialAsync } from './db.js';
  7. const authPath = '/internal/dovecot/auth';
  8. const defaultBodyLimit = 8 * 1024;
  9. const defaultRequestTimeoutMs = 30_000;
  10. const defaultAuthCacheTtlMs = 600_000;
  11. const defaultAuthCacheMaxEntries = 4096;
  12. export function createDovecotAuthServer(options = {}) {
  13. const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
  14. const limiter = options.authRateLimiter || authenticationRateLimiter;
  15. const verifyCredential = options.verifyCredential || verifyInboundMailboxCredentialAsync;
  16. const logger = options.logger || console;
  17. const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
  18. const authCache = options.authCache || new SuccessfulAuthCache({
  19. ttlMs: options.authCacheTtlMs,
  20. maxEntries: options.authCacheMaxEntries,
  21. secretDigest: sharedSecretDigest
  22. });
  23. const inFlightAuth = options.inFlightAuth || new InFlightAuthChecks({
  24. maxEntries: options.inFlightAuthMaxEntries,
  25. secretDigest: sharedSecretDigest
  26. });
  27. const server = http.createServer((req, res) => {
  28. void handleRequest(req, res, {
  29. sharedSecretDigest,
  30. limiter,
  31. verifyCredential,
  32. logger,
  33. bodyLimit: defaultBodyLimit,
  34. authCache,
  35. inFlightAuth
  36. });
  37. });
  38. server.requestTimeout = requestTimeoutMs;
  39. server.headersTimeout = requestTimeoutMs;
  40. server.keepAliveTimeout = 1_000;
  41. return server;
  42. }
  43. export function startDovecotAuthServer(options = {}) {
  44. const server = createDovecotAuthServer(options);
  45. const host = String(options.host || '0.0.0.0');
  46. const port = Number(options.port ?? 3001);
  47. server.listen(port, host, () => {
  48. options.onListening?.(server);
  49. });
  50. return server;
  51. }
  52. async function handleRequest(req, res, context) {
  53. setPrivateHeaders(res);
  54. const pathname = requestPathname(req);
  55. if (pathname !== authPath) return sendJson(res, 404, { error: 'Not found.' });
  56. if (req.method !== 'POST') {
  57. res.setHeader('Allow', 'POST');
  58. return sendJson(res, 405, { error: 'Method not allowed.' });
  59. }
  60. if (!validBearerSecret(req.headers.authorization, context.sharedSecretDigest)) {
  61. return sendJson(res, 401, { error: 'Unauthorized.' });
  62. }
  63. if (requestContentType(req) !== 'application/json') {
  64. return sendJson(res, 415, { error: 'Unsupported media type.' });
  65. }
  66. const contentLength = parseContentLength(req.headers['content-length']);
  67. if (contentLength === null) return sendJson(res, 400, { error: 'Invalid request.' });
  68. if (contentLength > context.bodyLimit) return sendTooLarge(req, res);
  69. let body;
  70. try {
  71. body = await readJson(req, context.bodyLimit);
  72. } catch (error) {
  73. if (error instanceof RequestTooLargeError) return sendTooLarge(req, res);
  74. return sendJson(res, 400, { error: 'Invalid request.' });
  75. }
  76. const request = normalizeAuthRequest(body);
  77. if (!request) return sendJson(res, 400, { error: 'Invalid request.' });
  78. try {
  79. const authenticated = await authenticateWithRateLimitAsync({
  80. limiter: context.limiter,
  81. ip: request.remoteIp,
  82. account: request.username,
  83. authenticate: () => verifyCachedCredential(request, context)
  84. });
  85. if (!authenticated) return sendJson(res, 200, { authenticated: false });
  86. return sendJson(res, 200, { authenticated: true, user: authenticated.user });
  87. } catch {
  88. context.logger.error?.('Dovecot authentication bridge request failed.');
  89. return sendJson(res, 503, { error: 'Service unavailable.' });
  90. }
  91. }
  92. async function verifyCachedCredential(request, context) {
  93. const cachedUser = context.authCache?.get(request.username, request.password);
  94. if (cachedUser) return { user: cachedUser };
  95. return context.inFlightAuth.run(request.username, request.password, async () => {
  96. const recheckedUser = context.authCache?.get(request.username, request.password);
  97. if (recheckedUser) return { user: recheckedUser };
  98. const authenticated = await context.verifyCredential(request.username, request.password);
  99. if (!authenticated) return null;
  100. const user = canonicalMailboxAddress(authenticated);
  101. if (!user) throw new Error('Credential verifier returned an invalid mailbox');
  102. context.authCache?.set(request.username, request.password, user);
  103. return { user };
  104. });
  105. }
  106. function readSharedSecret(filePath) {
  107. if (!filePath || typeof filePath !== 'string') {
  108. throw new Error('Dovecot authentication secret file is required');
  109. }
  110. const secret = readFileSync(filePath, 'utf8').trim();
  111. const bytes = Buffer.byteLength(secret, 'utf8');
  112. if (bytes < 32 || bytes > 512 || /\s/.test(secret)) {
  113. throw new Error('Dovecot authentication secret must be a 32-512 byte token');
  114. }
  115. return secret;
  116. }
  117. function validBearerSecret(header, expectedDigest) {
  118. const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i);
  119. const actualDigest = digestSecret(match?.[1] || '');
  120. return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest);
  121. }
  122. function digestSecret(value) {
  123. return crypto.createHash('sha256').update(value).digest();
  124. }
  125. class SuccessfulAuthCache {
  126. constructor({
  127. ttlMs = defaultAuthCacheTtlMs,
  128. maxEntries = defaultAuthCacheMaxEntries,
  129. secretDigest = crypto.randomBytes(32),
  130. now = () => Date.now()
  131. } = {}) {
  132. this.ttlMs = Math.max(0, Number(ttlMs ?? defaultAuthCacheTtlMs) || 0);
  133. this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
  134. this.secretDigest = Buffer.from(secretDigest);
  135. this.now = now;
  136. this.entries = new Map();
  137. }
  138. get(username, password) {
  139. if (!this.enabled()) return '';
  140. const key = this.key(username, password);
  141. const entry = this.entries.get(key);
  142. if (!entry) return '';
  143. if (entry.expiresAt <= this.now()) {
  144. this.entries.delete(key);
  145. return '';
  146. }
  147. this.entries.delete(key);
  148. this.entries.set(key, entry);
  149. return entry.user;
  150. }
  151. set(username, password, user) {
  152. if (!this.enabled()) return;
  153. const cleanUser = String(user || '').trim().toLowerCase();
  154. if (!cleanUser) return;
  155. const key = this.key(username, password);
  156. this.entries.set(key, {
  157. user: cleanUser,
  158. expiresAt: this.now() + this.ttlMs
  159. });
  160. while (this.entries.size > this.maxEntries) {
  161. const oldestKey = this.entries.keys().next().value;
  162. if (oldestKey === undefined) break;
  163. this.entries.delete(oldestKey);
  164. }
  165. }
  166. enabled() {
  167. return this.ttlMs > 0 && this.maxEntries > 0;
  168. }
  169. key(username, password) {
  170. return crypto
  171. .createHmac('sha256', this.secretDigest)
  172. .update(String(username || '').trim().toLowerCase())
  173. .update('\0')
  174. .update(String(password || ''))
  175. .digest('hex');
  176. }
  177. }
  178. class InFlightAuthChecks {
  179. constructor({
  180. maxEntries = defaultAuthCacheMaxEntries,
  181. secretDigest = crypto.randomBytes(32)
  182. } = {}) {
  183. this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
  184. this.secretDigest = Buffer.from(secretDigest);
  185. this.entries = new Map();
  186. }
  187. run(username, password, authenticate) {
  188. if (!this.enabled()) return authenticate();
  189. const key = this.key(username, password);
  190. const existing = this.entries.get(key);
  191. if (existing) return existing;
  192. const pending = Promise.resolve()
  193. .then(authenticate)
  194. .finally(() => {
  195. this.entries.delete(key);
  196. });
  197. this.entries.set(key, pending);
  198. while (this.entries.size > this.maxEntries) {
  199. const oldestKey = this.entries.keys().next().value;
  200. if (oldestKey === undefined || oldestKey === key) break;
  201. this.entries.delete(oldestKey);
  202. }
  203. return pending;
  204. }
  205. enabled() {
  206. return this.maxEntries > 0;
  207. }
  208. key(username, password) {
  209. return crypto
  210. .createHmac('sha256', this.secretDigest)
  211. .update(String(username || '').trim().toLowerCase())
  212. .update('\0')
  213. .update(String(password || ''))
  214. .digest('hex');
  215. }
  216. }
  217. function requestPathname(req) {
  218. try {
  219. return new URL(req.url || '/', 'http://mailhub.internal').pathname;
  220. } catch {
  221. return '';
  222. }
  223. }
  224. function requestContentType(req) {
  225. return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
  226. }
  227. function parseContentLength(value) {
  228. if (value === undefined) return 0;
  229. const raw = String(value);
  230. if (!/^\d+$/.test(raw)) return null;
  231. const parsed = Number(raw);
  232. return Number.isSafeInteger(parsed) ? parsed : null;
  233. }
  234. async function readJson(req, limit) {
  235. const chunks = [];
  236. let bytes = 0;
  237. for await (const chunk of req) {
  238. bytes += chunk.length;
  239. if (bytes > limit) throw new RequestTooLargeError();
  240. chunks.push(chunk);
  241. }
  242. if (!chunks.length) throw new Error('Request body is required');
  243. const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
  244. if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required');
  245. return body;
  246. }
  247. function normalizeAuthRequest(body) {
  248. if (typeof body.username !== 'string' || typeof body.password !== 'string') return null;
  249. if (typeof body.remoteIp !== 'string') return null;
  250. const username = body.username.trim();
  251. const remoteIp = body.remoteIp.trim();
  252. const service = String(body.service || 'imap').trim().toLowerCase();
  253. if (!username || Buffer.byteLength(username, 'utf8') > 320 || /[\r\n\u0000]/.test(username)) return null;
  254. if (Buffer.byteLength(body.password, 'utf8') > defaultBodyLimit) return null;
  255. if (!isIP(remoteIp) || !['imap', 'pop3'].includes(service)) return null;
  256. return { username, password: body.password, remoteIp };
  257. }
  258. function canonicalMailboxAddress(authenticated) {
  259. const rawAddress = String(authenticated?.mailbox?.address || '');
  260. const address = rawAddress.toLowerCase();
  261. if (
  262. rawAddress !== rawAddress.trim()
  263. || Buffer.byteLength(address, 'utf8') > 320
  264. || /[\/\\\u0000\s]/.test(address)
  265. || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
  266. ) return '';
  267. return address;
  268. }
  269. function setPrivateHeaders(res) {
  270. res.setHeader('Cache-Control', 'no-store');
  271. res.setHeader('Pragma', 'no-cache');
  272. res.setHeader('X-Content-Type-Options', 'nosniff');
  273. }
  274. function sendJson(res, status, payload, headers = {}) {
  275. if (res.writableEnded) return;
  276. const body = JSON.stringify(payload);
  277. res.writeHead(status, {
  278. 'Content-Type': 'application/json; charset=utf-8',
  279. 'Content-Length': String(Buffer.byteLength(body)),
  280. ...headers
  281. });
  282. res.end(body);
  283. }
  284. function sendTooLarge(req, res) {
  285. req.resume();
  286. return sendJson(res, 413, { error: 'Request too large.' }, { Connection: 'close' });
  287. }
  288. function positiveInteger(value, fallback) {
  289. const parsed = Number(value);
  290. return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
  291. }
  292. class RequestTooLargeError extends Error {}