| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- import crypto from 'node:crypto';
- import { readFileSync } from 'node:fs';
- import {
- exchangeWebmailLoginTicket,
- revokeWebmailCredential
- } from './db.js';
- const internalPrefix = '/internal/webmail-sso/';
- const bodyLimit = 8 * 1024;
- export function loadWebmailSsoConfig(env = process.env) {
- const url = normalizeWebmailUrl(env.WEBMAIL_SSO_URL);
- const secretFile = String(env.WEBMAIL_SSO_SECRET_FILE || '').trim();
- if (!url) {
- return {
- enabled: false,
- url: '',
- audience: '',
- ticketTtlSeconds: 60,
- credentialTtlSeconds: 43_200,
- secretDigest: null
- };
- }
- if (!secretFile) {
- throw new Error('Webmail SSO URL and secret file must be configured together');
- }
- return {
- enabled: true,
- url,
- audience: new URL(url).origin,
- ticketTtlSeconds: boundedInteger(env.WEBMAIL_SSO_TICKET_TTL_SECONDS, 60, 10, 300),
- credentialTtlSeconds: boundedInteger(env.WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS, 43_200, 300, 86_400),
- secretDigest: digestSecret(readSharedSecret(secretFile))
- };
- }
- export async function handleInternalWebmailSso(req, res, url, config, dependencies = {}) {
- if (!url.pathname.startsWith(internalPrefix)) return false;
- setPrivateHeaders(res);
- if (!config.enabled) {
- sendJson(res, 503, { error: 'Webmail SSO is unavailable.' });
- return true;
- }
- if (req.method !== 'POST') {
- res.setHeader('Allow', 'POST');
- sendJson(res, 405, { error: 'Method not allowed.' });
- return true;
- }
- if (!validBearerSecret(req.headers.authorization, config.secretDigest)) {
- sendJson(res, 401, { error: 'Unauthorized.' });
- return true;
- }
- if (requestContentType(req) !== 'application/json') {
- sendJson(res, 415, { error: 'Unsupported media type.' });
- return true;
- }
- let body;
- try {
- body = await readJson(req, bodyLimit);
- } catch (error) {
- sendJson(res, error instanceof RequestTooLargeError ? 413 : 400, {
- error: error instanceof RequestTooLargeError ? 'Request too large.' : 'Invalid request.'
- });
- return true;
- }
- if (body.audience !== config.audience) {
- sendJson(res, 400, { error: 'Invalid request.' });
- return true;
- }
- const exchange = dependencies.exchangeWebmailLoginTicket || exchangeWebmailLoginTicket;
- const revoke = dependencies.revokeWebmailCredential || revokeWebmailCredential;
- if (url.pathname === `${internalPrefix}exchange`) {
- const ticket = normalizeOpaqueCredential(body.ticket, 'mht_');
- if (!ticket) {
- sendJson(res, 400, { error: 'Invalid or expired login.' });
- return true;
- }
- const result = exchange(ticket, { audience: config.audience });
- if (!result) {
- sendJson(res, 400, { error: 'Invalid or expired login.' });
- return true;
- }
- sendJson(res, 200, {
- username: result.mailbox.address,
- credential: result.credential,
- expiresAt: result.expiresAt
- });
- return true;
- }
- if (url.pathname === `${internalPrefix}revoke`) {
- const credential = normalizeOpaqueCredential(body.credential, 'mhw_');
- if (!credential) {
- sendJson(res, 400, { error: 'Invalid request.' });
- return true;
- }
- revoke(credential, { audience: config.audience });
- sendJson(res, 200, { revoked: true });
- return true;
- }
- sendJson(res, 404, { error: 'Not found.' });
- return true;
- }
- export function isSameOriginRequest(req, appBaseUrl, { trustProxy = false } = {}) {
- const suppliedOrigin = normalizeOrigin(req.headers.origin);
- if (!suppliedOrigin) return false;
- const allowed = new Set();
- const configuredOrigin = normalizeOrigin(appBaseUrl);
- if (configuredOrigin) allowed.add(configuredOrigin);
- const requestOrigin = externalRequestOrigin(req, trustProxy);
- if (requestOrigin) allowed.add(requestOrigin);
- return allowed.has(suppliedOrigin);
- }
- function externalRequestOrigin(req, trustProxy) {
- const forwardedHost = trustProxy ? firstForwardedValue(req.headers['x-forwarded-host']) : '';
- const host = String(forwardedHost || req.headers.host || '').trim();
- if (!host || /[\s/\\]/.test(host)) return '';
- const forwardedProto = trustProxy ? firstForwardedValue(req.headers['x-forwarded-proto']) : '';
- const protocol = String(forwardedProto || (req.socket?.encrypted ? 'https' : 'http')).toLowerCase();
- if (!['http', 'https'].includes(protocol)) return '';
- return normalizeOrigin(`${protocol}://${host}`);
- }
- function firstForwardedValue(value) {
- return String(value || '').split(',', 1)[0].trim();
- }
- function normalizeWebmailUrl(value) {
- const raw = String(value || '').trim();
- if (!raw) return '';
- let parsed;
- try {
- parsed = new URL(raw);
- } catch {
- throw new Error('Webmail SSO URL is invalid');
- }
- const localDevelopment = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
- if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && localDevelopment)) {
- throw new Error('Webmail SSO URL must use HTTPS');
- }
- if (parsed.username || parsed.password) throw new Error('Webmail SSO URL must not contain credentials');
- parsed.search = '';
- parsed.hash = '';
- return parsed.toString();
- }
- function normalizeOrigin(value) {
- try {
- const parsed = new URL(String(value || '').trim());
- if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return '';
- return parsed.origin;
- } catch {
- return '';
- }
- }
- function normalizeOpaqueCredential(value, prefix) {
- const clean = String(value || '');
- if (!clean.startsWith(prefix) || !/^[A-Za-z0-9_-]+$/.test(clean)) return '';
- if (clean.length < 32 || clean.length > 256) return '';
- return clean;
- }
- function readSharedSecret(filePath) {
- const secret = readFileSync(filePath, 'utf8').trim();
- if (!/^[0-9a-fA-F]{64,512}$/.test(secret)) {
- throw new Error('Webmail SSO secret must contain 64-512 hexadecimal characters');
- }
- return secret;
- }
- function validBearerSecret(header, expectedDigest) {
- if (!expectedDigest) return false;
- 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();
- }
- function requestContentType(req) {
- return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
- }
- 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 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) {
- const body = JSON.stringify(payload);
- res.writeHead(status, {
- 'Content-Type': 'application/json; charset=utf-8',
- 'Content-Length': String(Buffer.byteLength(body))
- });
- res.end(body);
- }
- function boundedInteger(value, fallback, minimum, maximum) {
- const parsed = Number(value ?? fallback);
- if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
- throw new Error(`Webmail SSO TTL must be between ${minimum} and ${maximum} seconds`);
- }
- return parsed;
- }
- class RequestTooLargeError extends Error {}
|