webmail-sso.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import crypto from 'node:crypto';
  2. import { readFileSync } from 'node:fs';
  3. import {
  4. exchangeWebmailLoginTicket,
  5. revokeWebmailCredential
  6. } from './db.js';
  7. const internalPrefix = '/internal/webmail-sso/';
  8. const bodyLimit = 8 * 1024;
  9. export function loadWebmailSsoConfig(env = process.env) {
  10. const url = normalizeWebmailUrl(env.WEBMAIL_SSO_URL);
  11. const secretFile = String(env.WEBMAIL_SSO_SECRET_FILE || '').trim();
  12. if (!url) {
  13. return {
  14. enabled: false,
  15. url: '',
  16. audience: '',
  17. ticketTtlSeconds: 60,
  18. credentialTtlSeconds: 43_200,
  19. secretDigest: null
  20. };
  21. }
  22. if (!secretFile) {
  23. throw new Error('Webmail SSO URL and secret file must be configured together');
  24. }
  25. return {
  26. enabled: true,
  27. url,
  28. audience: new URL(url).origin,
  29. ticketTtlSeconds: boundedInteger(env.WEBMAIL_SSO_TICKET_TTL_SECONDS, 60, 10, 300),
  30. credentialTtlSeconds: boundedInteger(env.WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS, 43_200, 300, 86_400),
  31. secretDigest: digestSecret(readSharedSecret(secretFile))
  32. };
  33. }
  34. export async function handleInternalWebmailSso(req, res, url, config, dependencies = {}) {
  35. if (!url.pathname.startsWith(internalPrefix)) return false;
  36. setPrivateHeaders(res);
  37. if (!config.enabled) {
  38. sendJson(res, 503, { error: 'Webmail SSO is unavailable.' });
  39. return true;
  40. }
  41. if (req.method !== 'POST') {
  42. res.setHeader('Allow', 'POST');
  43. sendJson(res, 405, { error: 'Method not allowed.' });
  44. return true;
  45. }
  46. if (!validBearerSecret(req.headers.authorization, config.secretDigest)) {
  47. sendJson(res, 401, { error: 'Unauthorized.' });
  48. return true;
  49. }
  50. if (requestContentType(req) !== 'application/json') {
  51. sendJson(res, 415, { error: 'Unsupported media type.' });
  52. return true;
  53. }
  54. let body;
  55. try {
  56. body = await readJson(req, bodyLimit);
  57. } catch (error) {
  58. sendJson(res, error instanceof RequestTooLargeError ? 413 : 400, {
  59. error: error instanceof RequestTooLargeError ? 'Request too large.' : 'Invalid request.'
  60. });
  61. return true;
  62. }
  63. if (body.audience !== config.audience) {
  64. sendJson(res, 400, { error: 'Invalid request.' });
  65. return true;
  66. }
  67. const exchange = dependencies.exchangeWebmailLoginTicket || exchangeWebmailLoginTicket;
  68. const revoke = dependencies.revokeWebmailCredential || revokeWebmailCredential;
  69. if (url.pathname === `${internalPrefix}exchange`) {
  70. const ticket = normalizeOpaqueCredential(body.ticket, 'mht_');
  71. if (!ticket) {
  72. sendJson(res, 400, { error: 'Invalid or expired login.' });
  73. return true;
  74. }
  75. const result = exchange(ticket, { audience: config.audience });
  76. if (!result) {
  77. sendJson(res, 400, { error: 'Invalid or expired login.' });
  78. return true;
  79. }
  80. sendJson(res, 200, {
  81. username: result.mailbox.address,
  82. credential: result.credential,
  83. expiresAt: result.expiresAt
  84. });
  85. return true;
  86. }
  87. if (url.pathname === `${internalPrefix}revoke`) {
  88. const credential = normalizeOpaqueCredential(body.credential, 'mhw_');
  89. if (!credential) {
  90. sendJson(res, 400, { error: 'Invalid request.' });
  91. return true;
  92. }
  93. revoke(credential, { audience: config.audience });
  94. sendJson(res, 200, { revoked: true });
  95. return true;
  96. }
  97. sendJson(res, 404, { error: 'Not found.' });
  98. return true;
  99. }
  100. export function isSameOriginRequest(req, appBaseUrl, { trustProxy = false } = {}) {
  101. const suppliedOrigin = normalizeOrigin(req.headers.origin);
  102. if (!suppliedOrigin) return false;
  103. const allowed = new Set();
  104. const configuredOrigin = normalizeOrigin(appBaseUrl);
  105. if (configuredOrigin) allowed.add(configuredOrigin);
  106. const requestOrigin = externalRequestOrigin(req, trustProxy);
  107. if (requestOrigin) allowed.add(requestOrigin);
  108. return allowed.has(suppliedOrigin);
  109. }
  110. function externalRequestOrigin(req, trustProxy) {
  111. const forwardedHost = trustProxy ? firstForwardedValue(req.headers['x-forwarded-host']) : '';
  112. const host = String(forwardedHost || req.headers.host || '').trim();
  113. if (!host || /[\s/\\]/.test(host)) return '';
  114. const forwardedProto = trustProxy ? firstForwardedValue(req.headers['x-forwarded-proto']) : '';
  115. const protocol = String(forwardedProto || (req.socket?.encrypted ? 'https' : 'http')).toLowerCase();
  116. if (!['http', 'https'].includes(protocol)) return '';
  117. return normalizeOrigin(`${protocol}://${host}`);
  118. }
  119. function firstForwardedValue(value) {
  120. return String(value || '').split(',', 1)[0].trim();
  121. }
  122. function normalizeWebmailUrl(value) {
  123. const raw = String(value || '').trim();
  124. if (!raw) return '';
  125. let parsed;
  126. try {
  127. parsed = new URL(raw);
  128. } catch {
  129. throw new Error('Webmail SSO URL is invalid');
  130. }
  131. const localDevelopment = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
  132. if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && localDevelopment)) {
  133. throw new Error('Webmail SSO URL must use HTTPS');
  134. }
  135. if (parsed.username || parsed.password) throw new Error('Webmail SSO URL must not contain credentials');
  136. parsed.search = '';
  137. parsed.hash = '';
  138. return parsed.toString();
  139. }
  140. function normalizeOrigin(value) {
  141. try {
  142. const parsed = new URL(String(value || '').trim());
  143. if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return '';
  144. return parsed.origin;
  145. } catch {
  146. return '';
  147. }
  148. }
  149. function normalizeOpaqueCredential(value, prefix) {
  150. const clean = String(value || '');
  151. if (!clean.startsWith(prefix) || !/^[A-Za-z0-9_-]+$/.test(clean)) return '';
  152. if (clean.length < 32 || clean.length > 256) return '';
  153. return clean;
  154. }
  155. function readSharedSecret(filePath) {
  156. const secret = readFileSync(filePath, 'utf8').trim();
  157. if (!/^[0-9a-fA-F]{64,512}$/.test(secret)) {
  158. throw new Error('Webmail SSO secret must contain 64-512 hexadecimal characters');
  159. }
  160. return secret;
  161. }
  162. function validBearerSecret(header, expectedDigest) {
  163. if (!expectedDigest) return false;
  164. const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i);
  165. const actualDigest = digestSecret(match?.[1] || '');
  166. return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest);
  167. }
  168. function digestSecret(value) {
  169. return crypto.createHash('sha256').update(value).digest();
  170. }
  171. function requestContentType(req) {
  172. return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
  173. }
  174. async function readJson(req, limit) {
  175. const chunks = [];
  176. let bytes = 0;
  177. for await (const chunk of req) {
  178. bytes += chunk.length;
  179. if (bytes > limit) throw new RequestTooLargeError();
  180. chunks.push(chunk);
  181. }
  182. if (!chunks.length) throw new Error('Request body is required');
  183. const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
  184. if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required');
  185. return body;
  186. }
  187. function setPrivateHeaders(res) {
  188. res.setHeader('Cache-Control', 'no-store');
  189. res.setHeader('Pragma', 'no-cache');
  190. res.setHeader('X-Content-Type-Options', 'nosniff');
  191. }
  192. function sendJson(res, status, payload) {
  193. const body = JSON.stringify(payload);
  194. res.writeHead(status, {
  195. 'Content-Type': 'application/json; charset=utf-8',
  196. 'Content-Length': String(Buffer.byteLength(body))
  197. });
  198. res.end(body);
  199. }
  200. function boundedInteger(value, fallback, minimum, maximum) {
  201. const parsed = Number(value ?? fallback);
  202. if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
  203. throw new Error(`Webmail SSO TTL must be between ${minimum} and ${maximum} seconds`);
  204. }
  205. return parsed;
  206. }
  207. class RequestTooLargeError extends Error {}