class SPF::Server

Constants

DEFAULT_DEFAULT_AUTHORITY_EXPLANATION
DEFAULT_MAX_DNS_INTERACTIVE_TERMS
DEFAULT_MAX_NAME_LOOKUPS_PER_MX_MECH
DEFAULT_MAX_NAME_LOOKUPS_PER_PTR_MECH
DEFAULT_MAX_NAME_LOOKUPS_PER_TERM
DEFAULT_MAX_VOID_DNS_LOOKUPS
DEFAULT_QUERY_RR_TYPES
LOOSE_SPF_MATCH_PATTERN
QUERY_RR_TYPE_ALL
QUERY_RR_TYPE_SPF
QUERY_RR_TYPE_TXT
RECORD_CLASSES_BY_VERSION
RESULT_BASE_CLASS

Attributes

default_authority_explanation[RW]
dns_resolver[RW]
hostname[RW]
max_dns_interactive_terms[RW]
max_name_lookups_per_mx_mech[RW]
max_name_lookups_per_ptr_mech[RW]
max_name_lookups_per_term[RW]
max_void_dns_lookups[RW]
query_rr_types[RW]

Public Class Methods

new(options = {}) click to toggle source
# File lib/spf/eval.rb, line 50
def initialize(options = {})
  @default_authority_explanation = options[:default_authority_explanation] ||
    DEFAULT_DEFAULT_AUTHORITY_EXPLANATION
  unless SPF::MacroString === @default_authority_explanation
    @default_authority_explanation = SPF::MacroString.new({
      :text           => @default_authority_explanation,
      :server         => self,
      :is_explanation => true
    })
  end
  @hostname                      = options[:hostname]     || SPF::Util.hostname
  @dns_resolver                  = options[:dns_resolver] || Resolv::DNS.new
  @query_rr_types                = options[:query_rr_types]                ||
    DEFAULT_QUERY_RR_TYPES
  @max_dns_interactive_terms     = options[:max_dns_interactive_terms]     ||
    DEFAULT_MAX_DNS_INTERACTIVE_TERMS
  @max_name_lookups_per_term     = options[:max_name_lookups_per_term]     ||
    DEFAULT_MAX_NAME_LOOKUPS_PER_TERM
  @max_name_lookups_per_mx_mech  = options[:max_name_lookups_per_mx_mech]  ||
    DEFAULT_MAX_NAME_LOOKUPS_PER_MX_MECH
  @max_name_lookups_per_ptr_mech = options[:max_name_lookups_per_ptr_mech] ||
    DEFAULT_MAX_NAME_LOOKUPS_PER_PTR_MECH

  # TODO: We should probably do this for the above maximums.
  @max_void_dns_lookups          = options.has_key?(:max_void_dns_lookups) ? options[:max_void_dns_lookups] : DEFAULT_MAX_VOID_DNS_LOOKUPS

  @raise_exceptions = options.has_key?(:raise_exceptions) ? options[:raise_exceptions] : true

end

Public Instance Methods

count_dns_interactive_term(request) click to toggle source
# File lib/spf/eval.rb, line 290
def count_dns_interactive_term(request)
  dns_interactive_terms_count = request.root_request.state(:dns_interactive_terms_count, 1)
  if (@max_dns_interactive_terms and
      dns_interactive_terms_count > @max_dns_interactive_terms)
    raise SPF::ProcessingLimitExceededError.new(
      "Maximum DNS-interactive terms limit (#{@max_dns_interactive_terms}) exceeded")
  end
  return dns_interactive_terms_count
end
count_void_dns_lookup(request) click to toggle source
# File lib/spf/eval.rb, line 300
def count_void_dns_lookup(request)
  void_dns_lookups_count = request.root_request.state(:void_dns_lookups_count, 1)
  if (@max_void_dns_lookups and
      void_dns_lookups_count > @max_void_dns_lookups)
    raise SPF::ProcessingLimitExceededError.new(
      "Maximum void DNS look-ups limit (#{@max_void_dns_lookups}) exceeded")
  end
  return void_dns_lookups_count
end
dns_lookup(domain, rr_type) click to toggle source
# File lib/spf/eval.rb, line 132
def dns_lookup(domain, rr_type)
  if SPF::MacroString === domain
    domain = domain.expand
    # Truncate overlong labels at 63 bytes (RFC 4408, 8.1/27)
    domain.gsub!(/([^.]{63})[^.]+/, "#{$1}")
    # Drop labels from the head of domain if longer than 253 bytes (RFC 4408, 8.1/25):
    domain.sub!(/^[^.]+\.(.*)$/, "#{$1}") while domain.length > 253
  end

  rr_type = self.resource_typeclass_for_rr_type(rr_type)

  domain = domain.sub(/\.$/, '').downcase

  packet = nil
  begin
    packet = @dns_resolver.getresources(domain, rr_type)
  rescue Resolv::TimeoutError => e
    raise SPF::DNSTimeoutError.new(
      "Time-out on DNS '#{rr_type}' lookup of '#{domain}'")
  rescue Resolv::NXDomainError => e
    raise SPF::DNSNXDomainError.new("NXDomain for '#{domain}'")
  rescue Resolv::ResolvError => e
    raise SPF::DNSError.new("Error on DNS lookup of '#{domain}'")
  end

  # Raise DNS exception unless an answer packet with RCODE 0 or 3 (NXDOMAIN)
  # was received (thereby treating NXDOMAIN as an acceptable but empty answer packet):
  #if @dns_resolver.errorstring =~ /^(timeout|query timed out)$/
  #  raise SPF::DNSTimeoutError.new(
  #    "Time-out on DNS '#{rr_type}' lookup of '#{domain}'")
  #end

  unless packet
    raise SPF::DNSError.new(
      "Unknown error on DNS '#{rr_type}' lookup of '#{domain}'")
  end

  #unless packet.header.rcode =~ /^(NOERROR|NXDOMAIN)$/
  #  raise SPF::DNSError.new(
  #    "'#{packet.header.rcode}' error on DNS '#{rr_type}' lookup of '#{domain}'")
  #end
  return packet
end
get_acceptable_records_from_packet(packet, rr_type, versions, scope, domain, loose_match) click to toggle source
# File lib/spf/eval.rb, line 249
def get_acceptable_records_from_packet(packet, rr_type, versions, scope, domain, loose_match)

  # Try higher record versions first.
  # (This may be too simplistic for future revisions of SPF.)
  versions = versions.sort { |x, y| y <=> x }

  rr_type = resource_typeclass_for_rr_type(rr_type)
  records = []
  possible_matches = []
  packet.each do |rr|
    next unless rr_type === rr
    text = rr.strings.join('')
    record = false
    versions.each do |version|
      klass = RECORD_CLASSES_BY_VERSION[version]
      begin
        options = {:raise_exceptions => @raise_exceptions}
        # A MacroString object for domain indicates this is a nested record.
        # Storing the domain.text maintains an association to the include domain.
        if domain.class == SPF::MacroString
          options[:record_domain] = domain.text
        end
        record = klass.new_from_string(text, options)
      rescue SPF::InvalidRecordVersionError => error
        if text =~ /#{LOOSE_SPF_MATCH_PATTERN}/
          possible_matches << text
        end
        # Ignore non-SPF and unknown-version records.
        # Propagate other errors (including syntax errors), though.
      end
    end
    if record
      if record.scopes.select{|x| scope == x}.any?
        # Record covers requested scope.
        records << record
      end
    end
  end
  return records, possible_matches
end
process(request) click to toggle source
# File lib/spf/eval.rb, line 92
def process(request)
  request.state(:authority_explanation,       nil)
  request.state(:dns_interactive_terms_count, 0)
  request.state(:void_dns_lookups_count,      0)

  result = nil

  begin
    record = self.select_record(request)
    request.record = record
    record.eval(self, request)
  rescue SPF::Result => r
    result = r
  rescue SPF::DNSError => e
    result = self.result_class(:temperror).new([self, request, e.message])
  rescue SPF::NoAcceptableRecordError => e
    result = self.result_class(:none     ).new([self, request, e.message])
  rescue SPF::RedundantAcceptableRecordsError, SPF::SyntaxError, SPF::ProcessingLimitExceededError => e
    result = self.result_class(:permerror).new([self, request, e.message])
  end
  # Propagate other, unknown errors.
  # This should not happen, but if it does, it helps exposing the bug!

  return result
end
resource_typeclass_for_rr_type(rr_type) click to toggle source
# File lib/spf/eval.rb, line 118
def resource_typeclass_for_rr_type(rr_type)
  return case rr_type
    when 'TXT'  then Resolv::DNS::Resource::IN::TXT
    when 'SPF'  then Resolv::DNS::Resource::IN::SPF
    when 'ANY'  then Resolv::DNS::Resource::IN::ANY
    when 'A'    then Resolv::DNS::Resource::IN::A
    when 'AAAA' then Resolv::DNS::Resource::IN::AAAA
    when 'PTR'  then Resolv::DNS::Resource::IN::PTR
    when 'MX'   then Resolv::DNS::Resource::IN::MX
    else
      raise ArgumentError, "Uknown RR type: #{rr_type}"
    end
end
result_class(name = nil) click to toggle source
# File lib/spf/eval.rb, line 80
def result_class(name = nil)
  if name
    return RESULT_BASE_CLASS::RESULT_CLASSES[name]
  else
    return RESULT_BASE_CLASS
  end
end
select_record(request, loose_match = false) click to toggle source
# File lib/spf/eval.rb, line 176
def select_record(request, loose_match = false)
  domain   = request.authority_domain
  versions = request.versions
  scope    = request.scope

  # Employ identical behavior for 'v=spf1' and 'spf2.0' records, both of
  # which support SPF (code 99) and TXT type records (this may be different
  # in future revisions of SPF):
  # Query for SPF type records first, then fall back to TXT type records.

  records       = []
  loose_records = []
  query_count   = 0
  dns_errors    = []

  # Query for TXT-type RRs first:
  if @query_rr_types != QUERY_RR_TYPE_SPF
    begin
      query_count += 1
      packet = self.dns_lookup(domain, 'TXT')
      matches = self.get_acceptable_records_from_packet(
        packet, 'TXT', versions, scope, domain, loose_match)
      records << matches[0]
      loose_records << matches[1]
    rescue SPF::DNSError => e
      dns_errors << e
    end
  end

  if records.flatten.empty? && @query_rr_types != QUERY_RR_TYPE_TXT
    begin
      query_count += 1
      packet = self.dns_lookup(domain, 'SPF')
      matches = self.get_acceptable_records_from_packet(
        packet, 'SPF', versions, scope, domain, loose_match)
      records << matches[0]
      loose_records << matches[1]
    rescue SPF::DNSError => e
      dns_errors << e
    #rescue SPF::DNSTimeout => e
    #  # FIXME: Ignore DNS timeouts on SPF type lookups?
    #  # Apparently some brain-dead DNS servers time out on SPF-type queries.
    end
  end

  # Unless at least one query succeeded, re-raise the first DNS error that occured.
  raise dns_errors[0] unless dns_errors.length < query_count

  records.flatten!
  loose_records.flatten!

  if records.empty?
    # RFC 4408, 4.5/7
    raise SPF::NoAcceptableRecordError.new('No applicable sender policy available',
                                            loose_records)
  end

  # Discard all records but the highest acceptable version:
  preferred_record_class = records[0].class

  records = records.select { |record| preferred_record_class === record }

  if records.length != 1
    # RFC 4408, 4.5/6
    raise SPF::RedundantAcceptableRecordsError.new(
      "Redundant applicable '#{preferred_record_class.version_tag}' sender policies found",
      records
    )
  end

  return records[0]
end
throw_result(name, request, text) click to toggle source
# File lib/spf/eval.rb, line 88
def throw_result(name, request, text)
  raise self.result_class(name).new([self, request, text])
end