UNPKG

mailauth

Version:

Email authentication library for Node.js

511 lines (440 loc) 20.6 kB
'use strict'; const punycode = require('punycode.js'); const net = require('net'); const macro = require('./macro'); const dns = require('node:dns').promises; const ipaddr = require('ipaddr.js'); const { getPtrHostname, formatDomain } = require('../tools'); const LIMIT_PTR_RESOLVE_RECORDS = 10; const matchIp = (addr, range) => { if (/\/\d+$/.test(range)) { // seems CIDR return addr.match(ipaddr.parseCIDR(range)); } else { return addr.toNormalizedString() === ipaddr.parse(range).toNormalizedString(); } }; const parseCidrValue = (val, defaultValue, type) => { val = val || ''; let domain = ''; let cidr4 = ''; let cidr6 = ''; if (val) { let cidrMatch = val.match(/^(.*?)(\/\d+)?(\/\/\d+)?$/); if (!cidrMatch || /^\/0+[1-9]/.test(cidrMatch[2]) || /^\/\/0+[1-9]/.test(cidrMatch[3])) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `invalid address definition: ${val}` }; throw err; } domain = cidrMatch[1] || ''; cidr4 = cidrMatch[2] ? Number(cidrMatch[2].substr(1)) : ''; cidr6 = cidrMatch[3] ? Number(cidrMatch[3].substr(2)) : ''; if (type === 'ip6' && cidr4 && !cidr6) { // there is no dual cidr for IP addresses cidr6 = cidr4; cidr4 = ''; } } domain = domain.toLowerCase().trim() || defaultValue; if ((typeof cidr4 === 'number' && cidr4 > 32 && !net.isIPv6(domain)) || (typeof cidr6 === 'number' && cidr6 > 128)) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `invalid cidr definition: ${val}` }; throw err; } return { domain, cidr4: typeof cidr4 === 'number' ? `/${cidr4}` : '', cidr6: typeof cidr6 === 'number' ? `/${cidr6}` : '' }; }; const spfVerify = async (domain, opts) => { opts = opts || {}; if (!opts.ip || !net.isIP(opts.ip)) { return false; } try { domain = punycode.toASCII(domain); } catch (err) { // ignore punycode conversion errors } let addr = ipaddr.parse(opts.ip); let resolver = opts.resolver || dns.resolve; let responses; try { responses = await resolver(domain, 'TXT'); } catch (err) { if (err.code !== 'ENOTFOUND' && err.code !== 'ENODATA') { throw err; } responses = []; } let spfRecord; let spfRr; for (let row of responses) { row = row.join(''); let parts = row.trim().split(/\s+/); if (parts[0].toLowerCase() === 'v=spf1') { if (spfRecord) { // multiple records, return permerror let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `multiple SPF records found for ${domain}` }; throw err; } spfRr = row; spfRecord = parts.slice(1); if (spfRr && /[^\x20-\x7E]/.test(spfRr)) { let err = new Error('Invalid characters in DNS response'); err.spfResult = { error: 'permerror', text: 'DNS response includes invalid characters' }; throw err; } } } if (!spfRecord) { let err = new Error('SPF failure'); err.spfResult = { error: 'none', text: `no SPF records found for ${domain}` }; throw err; } let getResult = async () => { // this check is only for passing test suite for (let i = spfRecord.length - 1; i >= 0; i--) { let part = spfRecord[i]; if (/^[^:/]+=/.test(part)) { //modifier, not mechanism if (!/^[a-z](a-z0-9-_\.)*/i.test(part)) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `invalid modifier ${part}` }; throw err; } let splitPos = part.indexOf('='); let modifier = part.substr(0, splitPos).toLowerCase(); let value = part.substr(splitPos + 1); value = macro(value, opts) // remove trailing dot .replace(/\.$/, ''); if (!value) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `Empty modifier value for ${modifier}` }; throw err; } else if (modifier === 'redirect' && !/^([\x21-\x2D\x2f-\x7e]+\.)+[a-z]+[a-z\-0-9]*$/.test(value)) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `Invalid redirect target ${value}` }; throw err; } spfRecord.splice(i, 1); spfRecord.push({ modifier, value }); continue; } let mechanism = part .split(/[:/=]/) .shift() .toLowerCase() .replace(/^[?\-~+]/, ''); if (!['all', 'include', 'a', 'mx', 'ip4', 'ip6', 'exists', 'ptr'].includes(mechanism)) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `Unknown mechanism ${mechanism}` }; throw err; } } if (spfRecord.filter(p => p && p.modifier === 'redirect').length > 1) { // too many redirects let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `more than 1 redirect found` }; throw err; } for (let i = 0; i < spfRecord.length; i++) { let part = spfRecord[i]; if (typeof part === 'object' && part.modifier) { let { modifier, value } = part; switch (modifier) { case 'redirect': { if (spfRecord.some(p => /^[?\-~+]?all$/i.test(p))) { // ignore redirect if "all" condition is set continue; } try { let subResult = await spfVerify(value, opts); if (subResult) { return subResult; } } catch (err) { // kind of ignore if (err.spfResult) { if (err.spfResult.error === 'none') { err.spfResult.error = 'permerror'; } throw err; } } } break; case 'exp': default: // do nothing } continue; } let key = ''; let val = ''; let qualifier = '+'; // default is pass let splitterPos = part.indexOf(':'); if (splitterPos === part.length - 1) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `unexpected empty value` }; throw err; } if (splitterPos >= 0) { key = part.substr(0, splitterPos); val = part.substr(splitterPos + 1); } else { let splitterPos = part.indexOf('/'); if (splitterPos >= 0) { key = part.substr(0, splitterPos); val = part.substr(splitterPos); // keep the / for CIDR } else { key = part; } } if (/^[?\-~+]/.test(key)) { qualifier = key.charAt(0); key = key.substr(1); } let type = key.toLowerCase(); switch (type) { case 'all': if (val) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `unexpected extension for all` }; throw err; } return { type, qualifier }; case 'include': { try { let redirect = macro(val, opts) // remove trailing dot .replace(/\.$/, ''); let sub = await spfVerify(redirect, opts); if (sub && sub.qualifier === '+') { // ignore other valid responses return { type, val, include: sub, qualifier }; } if (sub && sub.error) { return sub; } } catch (err) { // kind of ignore if (err.spfResult) { if (err.spfResult.error === 'none') { err.spfResult.error = 'permerror'; } return err.spfResult; } } } break; case 'ip4': case 'ip6': { let { domain: range, cidr4, cidr6 } = parseCidrValue(val, false, type); if (!range) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `bare IP address` }; throw err; } let originalRange = range; let mappingMatch = (range || '').toString().match(/^[:A-F]+:((\d+\.){3}\d+)$/i); if (mappingMatch) { range = mappingMatch[1]; } if (net.isIP(range)) { if (type === 'ip6' && net.isIPv6(opts.ip) && net.isIPv6(originalRange) && net.isIPv4(range) && cidr4 === '/0') { // map all IPv6 addresses return { type, val, qualifier }; } // validate ipv4 range only, skip ipv6 if (cidr6 && net.isIPv4(range)) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `invalid CIDR for IP` }; throw err; } if (net.isIP(range) !== net.isIP(opts.ip) || net.isIP(range) !== Number(type.charAt(2))) { // nothing to do here break; } let cidr = net.isIPv6(range) ? cidr6 : cidr4; if (matchIp(addr, range + cidr)) { return { type, val, qualifier }; } } else { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `invalid IP address` }; throw err; } } break; case 'a': { let { domain: a, cidr4, cidr6 } = parseCidrValue(val, domain, type); let cidr = net.isIPv6(opts.ip) ? cidr6 : cidr4; a = macro(a, opts); try { a = punycode.toASCII(a); } catch (err) { // ignore punycode conversion errors } // Query A or AAAA based on client IP type, with dual-stack void optimization // Pass clientIpType to enable smart void counting (see dualStackResolver in index.js) let responses = await resolver(a, net.isIPv6(opts.ip) ? 'AAAA' : 'A', { clientIpType: net.isIPv6(opts.ip) ? 6 : 4 }); if (responses) { for (let ip of responses) { if (matchIp(addr, ip + cidr)) { return { type, val: domain, qualifier }; } } } } break; case 'mx': { let { domain: mxDomain, cidr4, cidr6 } = parseCidrValue(val, domain, type); let cidr = net.isIPv6(opts.ip) ? cidr6 : cidr4; mxDomain = macro(mxDomain, opts); try { mxDomain = punycode.toASCII(mxDomain); } catch (err) { // ignore punycode conversion errors } let mxList = await resolver(mxDomain, 'MX'); if (mxList) { // MX mechanism uses a separate resolver with independent DNS lookup counter // This prevents MX A/AAAA lookups from consuming the main query limit let subResolver = typeof opts.createSubResolver === 'function' ? opts.createSubResolver() : resolver; try { mxList = mxList.sort((a, b) => a.priority - b.priority); for (let mx of mxList) { if (mx.exchange) { // Query A or AAAA for each MX host, with dual-stack void optimization // Pass clientIpType to enable smart void counting (see dualStackResolver in index.js) let responses = await subResolver(mx.exchange, net.isIPv6(opts.ip) ? 'AAAA' : 'A', { clientIpType: net.isIPv6(opts.ip) ? 6 : 4 }); if (responses) { for (let a of responses) { if (matchIp(addr, a + cidr)) { return { type, val: mx.exchange, qualifier }; } } } } } } finally { if (typeof resolver.updateSubQueries === 'function') { resolver.updateSubQueries('mx', subResolver.getResolveCount()); resolver.updateSubQueries('mx:void', subResolver.getVoidCount()); } } } } break; case 'exists': { let existDomain = macro(val, opts); try { existDomain = punycode.toASCII(existDomain); } catch (err) { // ignore punycode conversion errors } let responses = await resolver(existDomain, 'A'); if (responses && responses.length) { return { type, val: existDomain, qualifier }; } } break; case 'ptr': { let { cidr4, cidr6 } = parseCidrValue(val, false, type); if (cidr4 || cidr6) { let err = new Error('SPF failure'); err.spfResult = { error: 'permerror', text: `invalid domain-spec definition: ${val}` }; throw err; } let ptrDomain; if (val) { ptrDomain = macro(val, opts); } else { ptrDomain = macro('%{d}', opts); } ptrDomain = formatDomain(ptrDomain); // Step 1. Resolve PTR hostnames let ptrValues; if (opts._resolvedPtr) { ptrValues = opts._resolvedPtr; } else { let responses = await resolver(getPtrHostname(addr), 'PTR'); opts._resolvedPtr = ptrValues = responses && responses.length ? responses : []; } // PTR resolver has separate counter let subResolver = typeof opts.createSubResolver === 'function' ? opts.createSubResolver() : resolver; let resolvers = []; for (let ptrValue of ptrValues) { if (resolvers.length < LIMIT_PTR_RESOLVE_RECORDS) { // resolve up to 10 PTR A/AAAA records // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4 resolvers.push(subResolver(ptrValue, net.isIPv6(opts.ip) ? 'AAAA' : 'A')); } } // Step 2. Validate PTR hostnames by reverse resolving these let validatedPtrRecords = []; let results = await Promise.allSettled(resolvers); if (typeof resolver.updateSubQueries === 'function') { resolver.updateSubQueries('ptr', subResolver.getResolveCount()); resolver.updateSubQueries('ptr:void', subResolver.getVoidCount()); } for (let i = 0; i < results.length; i++) { let result = results[i]; let ptrHostname = ptrValues[i]; if ( result.status === 'fulfilled' && Array.isArray(result.value) && result.value.map(val => ipaddr.parse(val).toNormalizedString()).includes(addr.toNormalizedString()) ) { validatedPtrRecords.push(ptrHostname); } } // Step 3. Check subdomain alignment for (let ptrRecord of validatedPtrRecords) { let formattedPtrRecord = formatDomain(ptrRecord); if (formattedPtrRecord === ptrDomain || formattedPtrRecord.substr(-(ptrDomain.length + 1)) === `.${ptrDomain}`) { return { type, val: ptrRecord, qualifier }; } } } break; } } return false; }; try { let res = await getResult(); if (res && spfRr) { res.rr = spfRr; } else if (spfRr) { res = { // default is neutral qualifier: '?', rr: spfRr }; } return res; } catch (err) { if (spfRr && err.spfResult) { err.spfResult.rr = spfRr; } throw err; } }; module.exports = { spfVerify };