UNPKG

mailauth

Version:

Email authentication library for Node.js

372 lines (333 loc) 10.7 kB
'use strict'; const { Buffer } = require('node:buffer'); const punycode = require('punycode.js'); const dns = require('node:dns'); const parseDkimHeaders = require('./parse-dkim-headers'); const https = require('node:https'); const HTTP_REQUEST_TIMEOUT = 15 * 1000; /** * Resolve MTA-STS policy ID * @param {String} address Either email address or a domain name * @param {Object} opts * @param {Function} [opts.resolver] Optional async DNS resolver function * @returns {String|Boolean} Either string ID or false if policy was not defined in DNS */ const resolvePolicy = async (address, opts) => { opts = opts || {}; let { resolver } = opts; resolver = resolver || dns.promises.resolve; address = (address || '').toString(); let atPos = address.indexOf('@'); let domain = atPos < 0 ? address : atPos.substr(atPos + 1); if (/[\x7e-\xff]/.test(domain)) { // high bytes, probably U-label try { domain = punycode.toASCII(domain); } catch (err) { // ignore } } let record; try { let txt = await resolver(`_mta-sts.${domain}`, 'TXT'); txt = (txt || []).map(row => row?.join('').trim()).filter(row => /^v=STSv1\b/i.test(row)); if (txt.length > 1) { let err = new Error(''); err.code = 'multi_sts_records'; throw err; } if (txt.length === 1) { record = parseDkimHeaders(txt[0]); } } catch (err) { if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') { return false; } throw err; } if (!record || !/^STSv1$/i.test(record?.parsed?.v?.value) || !record?.parsed?.id?.value) { return false; } return record?.parsed?.id?.value; }; /** * Parses a MTA-STS policy file * @param {Buffer|String} file MTA-STS policy * @returns {Object} parsed policy */ const parsePolicy = file => { let policy = { // default mode: 'none' }; (file || '') .toString() .split(/\r?\n/) .map(l => { let colonPos = l.indexOf(':'); if (colonPos < 0) { return false; } return { key: l.substr(0, colonPos).toLowerCase().trim(), value: l.substr(colonPos + 1).trim() }; }) .filter(l => l) .forEach(l => { switch (l.key) { case 'version': policy[l.key] = l.value; break; case 'mode': policy[l.key] = l.value.toLowerCase(); break; case 'max_age': policy.maxAge = Number(l.value); break; case 'mx': { if (!policy.mx) { policy.mx = []; } let mx = l.value.toLowerCase(); if (!policy.mx.includes(mx)) { policy.mx.push(mx); } break; } } }); if (!/^STSv1$/.test(policy.version)) { let err = new Error('Invalid version field'); err.code = 'invalid_sts_version'; throw err; } if (!['testing', 'enforce', 'none'].includes(policy.mode)) { let err = new Error('Invalid mode field'); err.code = 'invalid_sts_mode'; throw err; } if (isNaN(policy.maxAge) || policy.maxAge < 0 || policy.maxAge > 31557600) { let err = new Error('Invalid max_age field'); err.code = 'invalid_sts_max_age'; throw err; } if (policy.mode !== 'none' && (!policy.mx || !policy.mx.length)) { let err = new Error('Missing mx field'); err.code = 'invalid_sts_mx'; throw err; } return policy; }; /** * Validate mx hostname against MTA-STS policy * @param {String} mx MX hostname * @param {Object} policy Policy structure from `parsePolicy` * @returns {Boolean} true if validation succeeded */ const validateMx = (mx, policy) => { policy = policy || { mode: 'none' }; if (policy.mode === 'none' || !policy.mode) { // nothing to check for return { valid: true, mode: policy.mode || 'none', testing: policy.mode === 'testing' }; } mx = (mx || '').toString().trim().toLowerCase(); if (/[\x7e-\xff]/.test(mx)) { // high bytes, probably U-label try { mx = punycode.toASCII(mx); } catch (err) { // ignore } } for (let allowed of policy.mx) { allowed = (allowed || '').toString().trim().toLowerCase(); if (/^\*\./.test(allowed)) { // remove wildcard allowed = allowed.substr(1); if (mx.substr(-allowed.length) === allowed) { return { valid: true, mode: policy.mode || 'none', match: allowed, testing: policy.mode === 'testing' }; } } else if (allowed === mx) { return { valid: true, mode: policy.mode || 'none', match: allowed, testing: policy.mode === 'testing' }; } } // no match found return { valid: false, mode: policy.mode || 'none', testing: policy.mode === 'testing' }; }; /** * Fetches and parses MTA-STS policy file for a domain * @param {String} domain * @param {Object} opts * @param {Function} [opts.resolver] Optional async DNS resolver function * @returns {Object|Boolean} false if policy file was not found or structured policy */ const fetchPolicy = async (domain, opts) => { opts = opts || {}; let { resolver } = opts; resolver = resolver || dns.promises.resolve; domain = (domain || '').toString().toLowerCase(); if (/[\x7e-\xff]/.test(domain)) { // high bytes, probably U-label try { domain = punycode.toASCII(domain); } catch (err) { // ignore } } const servername = `mta-sts.${domain}`; const path = `/.well-known/mta-sts.txt`; let addr; try { addr = await resolver(servername, 'A'); } catch (err) { if (err.code !== 'ENOTFOUND' && err.code !== 'ENODATA') { throw err; } } if (!addr?.length) { try { addr = await resolver(servername, 'AAAA'); } catch (err) { if (err.code !== 'ENOTFOUND' && err.code !== 'ENODATA') { throw err; } } } if (!addr?.length) { return false; } const options = { protocol: 'https:', host: addr[0], headers: { host: servername }, servername, port: 443, path, method: 'GET', rejectUnauthorized: true, timeout: HTTP_REQUEST_TIMEOUT }; let data = await new Promise((resolve, reject) => { const req = https.request(options, res => { let chunks = [], chunklen = 0; res.on('readable', () => { let chunk; while ((chunk = res.read()) !== null) { chunks.push(chunk); chunklen += chunk.length; } }); res.on('end', () => { let data = Buffer.concat(chunks, chunklen); if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { let err = new Error(`Invalid response code ${res.statusCode || '-'}`); err.code = 'http_status_' + (res.statusCode || 'na'); return reject(err); } resolve(data); }); res.on('error', err => reject(err)); }); req.on('timeout', () => { req.destroy(); // cancel request let error = new Error(`Request timeout for https://${servername}${path}`); error.code = 'HTTP_SOCKET_TIMEOUT'; reject(error); }); req.on('error', err => { reject(err); }); req.end(); }); if (!data) { return false; } return parsePolicy(data, opts); }; /** * Resolves and fetches MTA-STS policy for a domain name * @param {String} domain Domain name to fetch the policy for * @param {Object} [knownPolicy] currenlty known MTA-STS policy * @param {Object} [opts] * @param {Function} [opts.resolver] Optional async DNS resolver function * @returns {Object|Boolean} Policy information or false */ const getPolicy = async (domain, knownPolicy, opts) => { let policyId; try { policyId = await resolvePolicy(domain, opts); } catch (err) { return { policy: { id: false, mode: 'none', error: err }, status: 'errored' }; } try { if (!policyId) { return { policy: { id: false, mode: 'none' }, status: 'not_found' }; } if (knownPolicy && knownPolicy.id === policyId && !(knownPolicy?.expires && new Date(knownPolicy?.expires) > new Date())) { // no changes, not expired return { policy: Object.assign({}, knownPolicy, { expires: new Date(Date.now() + knownPolicy.maxAge * 1000).toISOString() }), status: 'renewed' }; } let policy = await fetchPolicy(domain, opts); if (!policy) { return { policy: { id: false, mode: 'none' }, status: 'not_found' }; } return { policy: Object.assign({ id: policyId }, policy, { expires: new Date(Date.now() + policy.maxAge * 1000).toISOString() }), status: 'found' }; } catch (err) { if (knownPolicy) { // re-use existing policy on error return { policy: Object.assign({ error: err }, knownPolicy), status: 'errored' }; } // prevent loading the policy for the next 1 hour and default to "none" return { policy: { id: policyId, mode: 'none', expires: new Date(Date.now() + 1 * 3600 * 1000).toISOString(), error: err }, status: 'errored' }; } }; module.exports = { resolvePolicy, fetchPolicy, parsePolicy, validateMx, getPolicy };