UNPKG

spf-check

Version:

SPF validator

395 lines (309 loc) 14.2 kB
'use strict'; const dns = require('dns'); const util = require('util'); const spfParse = require('spf-parse'); const ipaddr = require('ipaddr.js'); const tlsjs = require('tldjs'); const _ = require('lodash'); /** Result messages. */ const messages = { None: 'Cannot assert whether or not the client host is authorized', Neutral: 'Domain owner has explicitly stated that he cannot or does not want to assert whether or not the IP address is authorized', Pass: 'Client is authorized to inject mail with the given identity', Fail: 'Client is *not* authorized to use the domain in the given identity', SoftFail: 'Domain believes the host is not authorized but is not willing to make that strong of a statement', TempError: 'Encountered a transient error while performing the check', PermError: 'Domain\'s published records could not be correctly interpreted', }; /** Result values (ex. {None: 'None', Neutral: 'Neutral', ...}). */ const results = _.mapValues(messages, _.nthArg(1)); class SPFResult { constructor(result, message) { if (!_.has(results, result)) { throw TypeError('Result "' + result + '" not found'); } if (!_.isString(message) || _.isEmpty(message)) { message = messages[result]; } /** An string value of results constant. */ this.result = result; /** Description text. */ this.message = message; /** Last matched mechanism or "default" if none. Used in Received-SPF * header field. */ this.mechanism = "default"; /** List of all matched mechanisms (order from last to first). */ this.matched = []; } } class SPF { constructor(domain, sender, options) { if (_.isObject(domain) && _.isUndefined(sender) && _.isUndefined(options)) { options = domain; domain = undefined; } else if (_.isObject(sender) && _.isUndefined(options)) { options = sender; sender = undefined; } this.domain = _.isString(domain) ? domain : _.result(options, 'domain', () => { throw new Error('Undefined domain'); }); this.sender = _.isString(sender) ? sender : _.get(options, 'sender', 'postmaster@' + this.domain); // If the sender has no localpart, substitute the string "postmaster" // for the localpart. if (!_.includes(this.sender, '@')) { this.sender = 'postmaster@' + this.sender; } this.warnings = []; this.queryDNSCount = 0; this.options = { /** Conforms to https://tools.ietf.org/html/rfc4408 */ version: 1, /** Resolve all mechanisms before evaluating them. This will cause * many DNS queries to be made and possible hit the 10 queries hard * limit. Note that "redirect" mechanisms always resolves first no * matter the value of this option. */ prefetch: false, /** Hard limit on the number of DNS lookups, including any lookups * caused by the use of the "include" mechanism or the "redirect" * modifier. */ maxDNS: 10, ...options, }; } async resolveMX(hostname, rrtype) { // First performs an MX lookup. const exchanges = await this.resolveDNS(hostname, 'MX'); // Check the number of exchanges to retrieve A records and limit before // doing any DNS lookup. if (exchanges.length >= this.options.maxDNS) { throw new SPFResult(results.PermError, 'Limit of DNS lookups reached when processing MX mechanism'); } // Then it performs an address lookup on each MX name returned. for (let e = 0; e < exchanges.length; e++) { exchanges[e].records = await this.resolveDNS(exchanges[e].exchange, rrtype, /*lookupLimit=*/false); }; return _.sortBy(exchanges, 'priority'); } async resolveDNS(hostname, rrtype, lookupLimit) { // Default behaviour is to throw PermError when limit is reached. lookupLimit = _.isNil(lookupLimit) || lookupLimit === true; if (lookupLimit && this.queryDNSCount >= this.options.maxDNS) { throw new SPFResult(results.PermError, 'Limit of DNS lookups reached'); } return new Promise((resolve, reject) => { dns.resolve(hostname, rrtype, (err, records) => { lookupLimit && this.queryDNSCount++; if (err) { // If the DNS lookup returns "domain does not exist", // immediately returns the result "None". if ((new RegExp('queryTxt (ENOTFOUND|ENODATA) ' + hostname)).test(err.message)) { reject(new SPFResult(results.None, 'Domain does not exists')); } else { reject(new SPFResult(results.TempError, err.message)); } } else { if (rrtype === 'TXT') { resolve(_.map(records, record => { return _.join(record, ''); })); } else { resolve(records); } } }); }); } async resolveSPF(hostname, rrtype) { // TODO resolve SPF record and use them instead of TXT if exists const records = _.filter(await this.resolveDNS(hostname, 'TXT'), record => { // Records that do not begin with a version section are discarded. return _.startsWith(record, 'v=spf' + this.options.version + ' '); }); if (records.length === 0) { throw new SPFResult(results.None, 'Assume that the domain makes no SPF declarations'); } if (records.length > 1) { throw new SPFResult(results.PermError, 'There should be exactly one record remaining'); } const record = records.pop(); if (/[^\x00-\x7f]/.test(record)) { throw new SPFResult(results.PermError, 'Character content of the record should be encoded as US-ASCII'); } const parsed = spfParse(record); if (parsed.valid === false) { throw new SPFResult(results.PermError, 'There shouldn\'t be any syntax errors'); } if (_.has(parsed, 'messages')) { const errors = _.filter(parsed.messages, ['type', 'error']); if (errors.length > 0) { // When multiple parse errors are found, return the first one so // they can be fixed one at the time. throw new SPFResult(results.PermError, errors.shift().message); } this.warnings = _.concat(this.warnings, _.map(_.filter(parsed.messages, ['type', 'warning']), 'message')); } // True when there is an "all" mechanism. const catchAll = _.some(parsed.mechanisms, ['type', 'all']); // List of parsed/resolved mechanisms to be returned. let resolved = []; for (let i = 0; i < parsed.mechanisms.length; i++) { // Parsed mechanisms to be resolved recursively. const mechanism = parsed.mechanisms[i]; if (mechanism.type === 'redirect') { if (!catchAll) { // Any "redirect" modifier has effect only when there is // not an "all" mechanism. resolved = _.concat(resolved, await this.resolveSPF(mechanism.value, rrtype)); } continue; } if (mechanism.type === 'a') { mechanism.resolve = async () => { return { records: await this.resolveDNS(mechanism.value || hostname, rrtype) }; }; } if (mechanism.type === 'mx') { mechanism.resolve = async () => { return { exchanges: await this.resolveMX(mechanism.value || hostname, rrtype) }; }; } if (mechanism.type === 'ip4' || mechanism.type === 'ip6') { // If ip4-cidr-length is omitted, it is taken to be "/32". // If ip6-cidr-length is omitted, it is taken to be "/128". if (mechanism.value.indexOf('/') === -1) { mechanism.value += mechanism.type === 'ip4' ? '/32' : '/128'; } try { mechanism.address = ipaddr.parseCIDR(mechanism.value); } catch (err) { throw new SPFResult(results.PermError, 'Malformed "' + mechanism.type + '" address'); } } if (mechanism.type === 'include') { mechanism.resolve = async () => { return { includes: await this.resolveSPF(mechanism.value, rrtype) }; }; } if (this.options.prefetch && mechanism.resolve) { // Early fetch all DNS records before any evaluation. Will fail // fast in some cases that will pass without. _.assign(mechanism, await mechanism.resolve()); } resolved.push(mechanism); if (mechanism.type === 'all') { break; // Mechanisms after "all" will never be resolved. } } return resolved; } async check(ip) { if (!ipaddr.isValid(ip)) { return new SPFResult(results.None, 'Malformed IP for comparison'); } if (!tlsjs.isValid(this.domain)) { return new SPFResult(results.None, 'No SPF record can be found on malformed domain'); } // List of parsed mechanisms done by `spf-parse` module. Each value is // an object that contains type and value. let mechanisms; // Parsed IP address. const addr = ipaddr.parse(ip); try { mechanisms = await this.resolveSPF(this.domain, // When any mechanism fetches host addresses to compare with // given IP, when it is an IPv4 address, A records are fetched, // when it is an IPv6 address, AAAA records are fetched instead. addr.kind() === 'ipv4' ? 'A' : 'AAAA' ); } catch (err) { if (err instanceof SPFResult) { return err; } return new SPFResult(results.TempError, err.message); } if (mechanisms.length === 0) { // This is a last minute check that may never get called because // there should always be the version mechanism. return new SPFResult(results.TempError); } try { return await this.evaluate(mechanisms, addr); } catch (err) { if (err instanceof SPFResult) { return err; } return new SPFResult(results.PermError, err.message); } } async evaluate(mechanisms, addr) { // TODO implement exp for (let i = 0; i < mechanisms.length; i++) { const mechanism = mechanisms[i]; if (!this.options.prefetch && mechanism.resolve) { _.assign(mechanism, await mechanism.resolve()); } if (mechanism.type === 'include') { mechanism.evaluated = await this.evaluate(mechanism.includes, addr); } if (this.match(mechanism, addr)) { const result = new SPFResult(mechanism.prefixdesc); result.mechanism = mechanism.type; result.matched = [mechanism.type]; if (mechanism.type === 'include') { result.matched = _.merge(result.matched, mechanism.evaluated.matched); } return result; } } // If none of the mechanisms match, then returns a result of "Neutral", // just as if "?all" were specified as the last directive. return new SPFResult(results.Neutral); } match(mechanism, addr) { switch (mechanism.type) { case 'version': if (mechanism.value !== 'spf' + this.options.version) { throw new SPFResult(results.PermError, 'Version "' + mechanism.value + '" not supported'); } return false; case 'a': return _.includes(mechanism.records, addr.toString()); case 'mx': for (let i = 0; i < mechanism.exchanges.length; i++) { if (_.includes(mechanism.exchanges[i].records, addr.toString())) { return true; } } return false; case 'ip4': case 'ip6': // The parsed CIDR is stored in `mechanism.address` as a tuple // with the IPv4/IPv6 instance as first element and the bits as // second. if (addr.kind() !== mechanism.address[0].kind()) { return false; } return addr.match(mechanism.address); case 'include': if (mechanism.evaluated.result === results.None) { throw new SPFResult(results.PermError, 'Validation for "include:' + mechanism.value + '" missed'); } return mechanism.evaluated.result === results.Pass; // TODO implement ptr // TODO implement exists case 'all': return true; } throw new SPFResult(results.PermError, 'Mechanism "' + mechanism.type + '" not supported'); } } module.exports = async function(ip, domain, sender, options) { let spf = new SPF(domain, sender, options); let res = await spf.check(ip); return res.result; }; module.exports = _.merge(module.exports, results); module.exports.SPFResult = SPFResult; module.exports.SPF = SPF;