haraka/haraka-plugin-spf

View on GitHub
lib/spf.js

Summary

Maintainability
F
1 wk
Test Coverage
'use strict'
// spf

const dns = require('node:dns/promises')
const net = require('node:net')
const ipaddr = require('ipaddr.js')
const net_utils = require('haraka-net-utils')

class SPF {
  constructor(count, been_there) {
    // For macro expansion
    // This should be set before check_host() is called
    this.helo = 'unknown'
    this.spf_record = ''

    // RFC 4408 Section 10.1
    // Limit the number of mechanisms/modifiers that require DNS lookups to complete.
    this.count = 0

    // If we have recursed we are supplied the count
    if (count) this.count = count

    // Prevent circular references, this isn't covered in the RFC
    this.been_there = {}
    if (been_there) this.been_there = been_there

    // RFC 4408 Section 10.1
    this.LIMIT = 10

    // Constants
    this.SPF_NONE = 1
    this.SPF_PASS = 2
    this.SPF_FAIL = 3
    this.SPF_SOFTFAIL = 4
    this.SPF_NEUTRAL = 5
    this.SPF_TEMPERROR = 6
    this.SPF_PERMERROR = 7

    this.mech_ip4 = this.mech_ip
    this.mech_ip6 = this.mech_ip
  }

  const_translate(value) {
    const t = {}
    for (const k in this) {
      if (typeof this[k] === 'number') {
        t[this[k]] = k.toUpperCase()
      }
    }
    if (t[value]) return t[value]
    return 'UNKNOWN'
  }

  result(value) {
    switch (value) {
      case this.SPF_NONE:
        return 'None'
      case this.SPF_PASS:
        return 'Pass'
      case this.SPF_FAIL:
        return 'Fail'
      case this.SPF_SOFTFAIL:
        return 'SoftFail'
      case this.SPF_NEUTRAL:
        return 'Neutral'
      case this.SPF_TEMPERROR:
        return 'TempError'
      case this.SPF_PERMERROR:
        return 'PermError'
      default:
        return `Unknown (${value})`
    }
  }

  return_const(qualifier) {
    switch (qualifier) {
      case '+':
        return this.SPF_PASS
      case '-':
        return this.SPF_FAIL
      case '~':
        return this.SPF_SOFTFAIL
      case '?':
        return this.SPF_NEUTRAL
      default:
        return this.SPF_PERMERROR
    }
  }

  expand_macros(str) {
    const macro = /%{([slodipvh])((?:(?:\d+)?r?)?)?([-.+,/_=])?}/gi
    let match
    while ((match = macro.exec(str))) {
      // match[1] = macro-letter
      // match[2] = transformers
      // match[3] = delimiter
      if (!match[3]) match[3] = '.'
      let strip = /(\d+)/.exec(match[2])
      if (strip) strip = strip[1]

      const reverse = `${match[2]}`.indexOf('r') !== -1
      let replace
      let kind
      switch (match[1]) {
        case 's': // sender
          replace = this.mail_from
          break
        case 'l': // local-part of sender
          replace = this.mail_from.split('@')[0]
          break
        case 'o': // domain of sender
          replace = this.mail_from.split('@')[1]
          break
        case 'd': // domain
          replace = this.domain
          break
        case 'i': // IP
          replace = this.ip
          break
        case 'p': // validated domain name of IP
          // NOT IMPLEMENTED
          replace = 'unknown'
          break
        case 'v': // IP version
          try {
            if (this.ip_ver === 'ipv4') kind = 'in-addr'
            if (this.ip_ver === 'ipv6') kind = 'ip6'
            replace = kind
          } catch (e) {}
          break
        case 'h': // EHLO/HELO domain
          replace = this.helo
          break
      }
      // Process any transformers
      if (replace) {
        if (reverse || strip) {
          replace = replace.split(match[3])
          if (strip) {
            strip = strip > replace.length ? replace.length : strip
            replace = replace.slice(0, strip)
          }
          if (reverse) replace = replace.reverse()
          replace = replace.join('.')
        }
        str = str.replace(match[0], replace)
      }
    }
    // Process any other expansions
    return str.replace(/%%/g, '%').replace(/%_/g, ' ').replace(/%-/g, '%20')
  }

  log_debug(str) {
    console.error(str)
  }

  valid_ip(ip) {
    const ip_split = /^:([^/ ]+)(?:\/([^ ]+))?$/.exec(ip)
    if (!ip_split) {
      this.log_debug(`invalid IP address: ${ip}`)
      return false
    }
    if (!ipaddr.isValid(ip_split[1])) {
      this.log_debug(`invalid IP address: ${ip_split[1]}`)
      return false
    }
    return true
  }

  async check_host(ip, domain, mail_from) {
    domain = domain.toLowerCase()
    mail_from = mail_from ? mail_from.toLowerCase() : `postmaster@${domain}`
    this.ipaddr = ipaddr.parse(ip)
    this.ip_ver = this.ipaddr.kind()
    if (this.ip_ver === 'ipv6') {
      this.ip = this.ipaddr.toString()
    } else {
      this.ip = ip
    }
    this.domain = domain
    this.mail_from = mail_from

    this.log_debug(`ip=${ip} domain=${domain} mail_from=${mail_from}`)

    const mech_array = []
    const mod_array = []

    // Get the SPF record for domain
    let txt_rrs
    try {
      txt_rrs = await dns.resolveTxt(domain)
    } catch (err) {
      this.log_debug(`error looking up TXT record: ${err.message}`)
      switch (err.code) {
        case dns.NOTFOUND:
        case dns.NODATA:
        case dns.NXDOMAIN:
          return this.SPF_NONE
        default:
          return this.SPF_TEMPERROR
      }
    }

    let spf_record
    let match
    for (let txt_rr of txt_rrs) {
      // txt_rr might be an array, so handle that case
      if (Array.isArray(txt_rr)) {
        txt_rr = txt_rr.join('')
      }

      match = /^(v=spf1(?:$|\s.+$))/i.exec(txt_rr)
      if (!match) {
        this.log_debug(`discarding TXT record: ${txt_rr}`)
        continue
      }

      if (!spf_record) {
        this.log_debug(`found SPF record for domain ${domain}: ${match[1]}`)
        spf_record = match[1].replace(/\s+/, ' ').toLowerCase()
      } else {
        this.log_debug(
          `found additional SPF record for domain ${domain}: ${match[1]}`,
        )
        return this.SPF_PERMERROR
      }
    }

    if (!spf_record) return this.SPF_NONE // No SPF record?

    // Store the SPF record used in the object
    this.spf_record = spf_record

    // Validate SPF record and build call chain
    const mech_regexp1 = /^([-+~?])?(all|a|mx|ptr)$/
    const mech_regexp2 =
      /^([-+~?])?(a|mx|ptr|ip4|ip6|include|exists)((?::[^/ ]+(?:\/\d+(?:\/\/\d+)?)?)|\/\d+(?:\/\/\d+)?)$/
    const mod_regexp = /^([^ =]+)=([a-z0-9:/._-]+)$/
    const split = spf_record.split(' ')

    for (const mechanism of split) {
      if (!mechanism) continue // Skip blanks

      const obj = {}
      if (
        (match = mech_regexp1.exec(mechanism) || mech_regexp2.exec(mechanism))
      ) {
        // match:  1=qualifier, 2=mechanism, 3=optional args
        if (!match[1]) match[1] = '+'
        this.log_debug(`found mechanism: ${match}`)

        if (match[2] === 'ip4' || match[2] === 'ip6') {
          if (!this.valid_ip(match[3])) return this.SPF_PERMERROR
        } else {
          // Validate macro strings
          if (match[3] && /%[^{%+-]/.exec(match[3])) {
            this.log_debug('invalid macro string')
            return this.SPF_PERMERROR
          }
          if (match[3]) {
            // Expand macros
            match[3] = this.expand_macros(match[3])
          }
        }

        obj[match[2]] = [match[1], match[3]]
        mech_array.push(obj)
        // console.log(mech_array)
      } else if ((match = mod_regexp.exec(mechanism))) {
        this.log_debug(`found modifier: ${match}`)
        // match[1] = modifier
        // match[2] = name
        // Make sure we have a method
        if (!this[`mod_${match[1]}`]) {
          this.log_debug(`skipping unknown modifier: ${match[1]}`)
        } else {
          obj[match[1]] = match[2]
          mod_array.push(obj)
          // console.log(mod_array)
        }
      } else {
        // Syntax error
        this.log_debug(`syntax error: ${mechanism}`)
        return this.SPF_PERMERROR
      }
    }

    this.log_debug(`SPF record for '${this.domain}' validated OK`)

    // Run all the mechanisms first
    for (const mech of mech_array) {
      const func = Object.keys(mech)
      const args = mech[func]
      // console.log(`running mechanism: ${func} args=${args} domain=${this.domain}`);
      this.log_debug(
        `running mechanism: ${func} args=${args} domain=${this.domain}`,
      )

      if (this.count > this.LIMIT) {
        this.log_debug('lookup limit reached')
        return this.SPF_PERMERROR
      }

      const result = await this[`mech_${func}`](
        args && args.length ? args[0] : null,
        args && args.length ? args[1] : null,
      )
      // console.log(result)

      // If we have a result other than SPF_NONE
      if (result && result !== this.SPF_NONE) return result
    }

    // run any modifiers
    for (const mod of mod_array) {
      const func = Object.keys(mod)
      const args = mod[func]
      this.log_debug(
        `running modifier: ${func} args=${args} domain=${this.domain}`,
      )
      const result = await this[`mod_${func}`](args)

      // Check limits
      if (this.count > this.LIMIT) {
        this.log_debug('lookup limit reached')
        return this.SPF_PERMERROR
      }

      // Return any result that is not SPF_NONE
      if (result && result !== this.SPF_NONE) return result
    }

    return this.SPF_NEUTRAL // default if no more mechanisms
  }

  async mech_all(qualifier) {
    return this.return_const(qualifier)
  }

  async mech_include(qualifier, args) {
    const domain = args.substr(1)
    // Avoid circular references
    if (this.been_there[domain]) {
      this.log_debug(`circular reference detected: ${domain}`)
      return this.SPF_NONE
    }
    this.count++
    this.been_there[domain] = true
    // Recurse
    const recurse = new SPF(this.count, this.been_there)
    try {
      const result = await recurse.check_host(this.ip, domain, this.mail_from)
      this.log_debug(
        `mech_include: domain=${domain} returned=${this.const_translate(result)}`,
      )
      switch (result) {
        case this.SPF_PASS:
          return this.SPF_PASS
        case this.SPF_FAIL:
        case this.SPF_SOFTFAIL:
        case this.SPF_NEUTRAL:
          return this.SPF_NONE
        case this.SPF_TEMPERROR:
          return this.SPF_TEMPERROR
        default:
          return this.SPF_PERMERROR
      }
    } catch (err) {
      // ignore
    }
  }

  async mech_exists(qualifier, args) {
    this.count++
    const exists = args.substr(1)

    try {
      const addrs = await dns.resolve(exists)
      this.log_debug(`mech_exists: ${exists} result=${addrs.join(',')}`)
      return this.return_const(qualifier)
    } catch (err) {
      this.log_debug(`mech_exists: ${err}`)
      switch (err.code) {
        case dns.NOTFOUND:
        case dns.NODATA:
        case dns.NXDOMAIN:
          return this.SPF_NONE
        default:
          return this.SPF_TEMPERROR
      }
    }
  }

  async mech_a(qualifier, args) {
    this.count++
    // Parse any arguments
    let cm
    let cidr4
    let cidr6
    if (args && (cm = /\/(\d+)(?:\/\/(\d+))?$/.exec(args))) {
      cidr4 = cm[1]
      cidr6 = cm[2]
    }
    let dm
    let domain = this.domain
    if (args && (dm = /^:([^/ ]+)/.exec(args))) {
      domain = dm[1]
    }
    // Calculate with IP method to use
    let resolve_method
    let cidr
    if (this.ip_ver === 'ipv4') {
      cidr = cidr4
      resolve_method = 'resolve4'
    } else if (this.ip_ver === 'ipv6') {
      cidr = cidr6
      resolve_method = 'resolve6'
    }
    // Use current domain
    let addrs
    try {
      addrs = await dns[resolve_method](domain)
    } catch (err) {
      this.log_debug(`mech_a: ${err}`)
      switch (err.code) {
        case dns.NOTFOUND:
        case dns.NODATA:
        case dns.NXDOMAIN:
          return this.SPF_NONE
        default:
          return this.SPF_TEMPERROR
      }
    }

    if (!addrs) return this.SPF_NONE

    for (const addr of addrs) {
      if (cidr) {
        // CIDR
        const range = ipaddr.parse(addr)
        if (this.ipaddr.match(range, cidr)) {
          this.log_debug(`mech_a: ${this.ip} => ${addr}/${cidr}: MATCH!`)
          return this.return_const(qualifier)
        } else {
          this.log_debug(`mech_a: ${this.ip} => ${addr}/${cidr}: NO MATCH`)
        }
      } else {
        if (addr === this.ip) {
          return this.return_const(qualifier)
        } else {
          this.log_debug(`mech_a: ${this.ip} => ${addr}: NO MATCH`)
        }
      }
    }
    return this.SPF_NONE
  }

  async mech_mx(qualifier, args) {
    this.count++
    // Parse any arguments
    let cm
    let cidr4
    let cidr6
    if (args && (cm = /\/(\d+)((?:\/\/(\d+))?)$/.exec(args))) {
      cidr4 = cm[1]
      cidr6 = cm[2]
    }
    let dm
    let domain = this.domain
    if (args && (dm = /^:([^/ ]+)/.exec(args))) {
      domain = dm[1]
    }
    // Fetch the MX records for the specified domain
    let mxes
    try {
      mxes = await net_utils.get_mx(domain)
      mxes = mxes.filter((mx) => !net.isIP(mx.exchange)) // remove implicit MX
    } catch (err) {
      switch (err.code) {
        case dns.NOTFOUND:
        case dns.NODATA:
        case dns.NXDOMAIN:
          return this.SPF_NONE
        default:
          return this.SPF_TEMPERROR
      }
    }

    let pending = 0
    let addresses = []
    // RFC 4408 Section 10.1
    if (mxes.length > this.LIMIT) return this.SPF_PERMERROR

    for (const element of mxes) {
      pending++
      const mx = element.exchange
      // Calculate which IP method to use
      let resolve_method
      let cidr
      if (this.ip_ver === 'ipv4') {
        cidr = cidr4
        resolve_method = 'resolve4'
      } else if (this.ip_ver === 'ipv6') {
        cidr = cidr6
        resolve_method = 'resolve6'
      }

      let addrs
      try {
        addrs = await dns[resolve_method](mx)
      } catch (err) {
        switch (err.code) {
          case dns.NOTFOUND:
          case dns.NODATA:
          case dns.NXDOMAIN:
            break
          default:
            return this.SPF_TEMPERROR
        }
      }

      pending--
      if (addrs) {
        this.log_debug(`mech_mx: mx=${mx} addresses=${addrs.join(',')}`)
        addresses = addrs.concat(addresses)
      }
      if (pending === 0) {
        if (!addresses.length) return this.SPF_NONE
        // All queries run; see if our IP matches
        if (cidr) {
          // CIDR match type
          for (const address of addresses) {
            const range = ipaddr.parse(address)
            if (this.ipaddr.match(range, cidr)) {
              this.log_debug(
                `mech_mx: ${this.ip} => ${address}/${cidr}: MATCH!`,
              )
              return this.return_const(qualifier)
            } else {
              this.log_debug(
                `mech_mx: ${this.ip} => ${address}/${cidr}: NO MATCH`,
              )
            }
          }
          // No matches
          return this.SPF_NONE
        } else {
          if (addresses.includes(this.ip)) {
            this.log_debug(
              `mech_mx: ${this.ip} => ${addresses.join(',')}: MATCH!`,
            )
            return this.return_const(qualifier)
          } else {
            this.log_debug(
              `mech_mx: ${this.ip} => ${addresses.join(',')}: NO MATCH`,
            )
            return this.SPF_NONE
          }
        }
      }
      // In case we didn't run any queries...
      if (pending === 0) return this.SPF_NONE
    }
    if (pending === 0) this.SPF_NONE
  }

  async mech_ptr(qualifier, args) {
    this.count++
    let dm
    let domain = this.domain
    if (args && (dm = /^:([^/ ]+)/.exec(args))) {
      domain = dm[1]
    }
    // First do a PTR lookup for the connecting IP
    let ptrs
    try {
      ptrs = await dns.reverse(this.ip)
    } catch (err) {
      this.log_debug(`mech_ptr: lookup=${this.ip} => ${err}`)
      return this.SPF_NONE
    }

    let resolve_method
    if (this.ip_ver === 'ipv4') resolve_method = 'resolve4'
    if (this.ip_ver === 'ipv6') resolve_method = 'resolve6'
    const names = []
    // RFC 4408 Section 10.1
    if (ptrs.length > this.LIMIT) return this.SPF_PERMERROR

    for (const ptr of ptrs) {
      try {
        const addrs = await dns[resolve_method](ptr)
        for (const addr of addrs) {
          if (addr === this.ip) {
            this.log_debug(`mech_ptr: ${this.ip} => ${ptr} => ${addr}: MATCH!`)
            names.push(ptr.toLowerCase())
          } else {
            this.log_debug(
              `mech_ptr: ${this.ip} => ${ptr} => ${addr}: NO MATCH`,
            )
          }
        }
      } catch (err) {
        // Skip on error
        this.log_debug(`mech_ptr: lookup=${ptr} => ${err}`)
        continue
      }
    }

    // Finished
    // Catch bogus PTR matches e.g. ptr:*.bahnhof.se (should be ptr:bahnhof.se)
    // These will cause a regexp error, so we can catch them.
    try {
      const re = new RegExp(`${domain.replace('.', '\\.')}$`, 'i')
      for (const name of names) {
        if (re.test(name)) {
          this.log_debug(`mech_ptr: ${name} => ${domain}: MATCH!`)
          return this.return_const(qualifier)
        } else {
          this.log_debug(`mech_ptr: ${name} => ${domain}: NO MATCH`)
        }
      }
      return this.SPF_NONE
    } catch (e) {
      this.log_debug('mech_ptr', { domain: this.domain, err: e.message })
      return this.SPF_PERMERROR
    }
  }

  async mech_ip(qualifier, args) {
    const cidr = args.substr(1)
    const match = /^([^/ ]+)(?:\/(\d+))?$/.exec(cidr)
    if (!match) return this.SPF_NONE

    // match[1] == ip
    // match[2] == mask
    try {
      if (!match[2]) {
        // Default masks for each IP version
        if (this.ip_ver === 'ipv4') match[2] = '32'
        if (this.ip_ver === 'ipv6') match[2] = '128'
      }
      const range = ipaddr.parse(match[1])
      const rtype = range.kind()
      if (this.ip_ver !== rtype) {
        this.log_debug(`mech_ip: ${this.ip} => ${cidr}: SKIP`)
        return this.SPF_NONE
      }
      if (this.ipaddr.match(range, match[2])) {
        this.log_debug(`mech_ip: ${this.ip} => ${cidr}: MATCH!`)
        return this.return_const(qualifier)
      } else {
        this.log_debug(`mech_ip: ${this.ip} => ${cidr}: NO MATCH`)
      }
    } catch (e) {
      this.log_debug(e.message)
      return this.SPF_PERMERROR
    }
    return this.SPF_NONE
  }

  async mod_redirect(domain) {
    // Avoid circular references
    if (this.been_there[domain]) {
      this.log_debug(`circular reference detected: ${domain}`)
      return this.SPF_NONE
    }
    this.count++
    this.been_there[domain] = 1
    return await this.check_host(this.ip, domain, this.mail_from)
  }

  async mod_exp() {
    // NOT IMPLEMENTED
    return this.SPF_NONE
  }

  async mod_v() {
    return this.SPF_NONE
  }
}

exports.SPF = SPF