├── .document ├── .gitignore ├── .rspec ├── Gemfile ├── Gemfile.lock ├── README.rdoc ├── Rakefile ├── lib ├── spf.rb └── spf │ ├── error.rb │ ├── eval.rb │ ├── ext │ └── resolv.rb │ ├── macro_string.rb │ ├── model.rb │ ├── request.rb │ ├── result.rb │ ├── test.rb │ ├── test │ ├── case.rb │ └── scenario.rb │ ├── util.rb │ └── version.rb ├── spec ├── macrostring_spec.rb ├── mech_spec.rb ├── request_spec.rb ├── resolv_programmable.rb ├── result_spec.rb ├── rfc4406-tests.yml ├── rfc4408-tests.yml ├── rfc4408_spec.rb ├── server_spec.rb ├── spec_helper.rb ├── spf_spec.rb ├── spf_test_lib.rb └── util_spec.rb └── spf.gemspec /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | lib/*.rb 3 | bin/* 4 | - 5 | features/**/*.feature 6 | LICENSE.txt 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | coverage.data 4 | 5 | # rdoc generated 6 | rdoc 7 | 8 | # yard generated 9 | doc 10 | .yardoc 11 | 12 | # bundler 13 | .bundle 14 | 15 | # jeweler generated 16 | pkg 17 | 18 | # rvm 19 | .rvmrc 20 | 21 | # For vim: 22 | *.swp 23 | 24 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | # Add dependencies required to use your gem here. 3 | # Example: 4 | # gem "activesupport", ">= 2.3.5" 5 | 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | spf (0.1) 5 | ruby-ip (~> 0.9.1) 6 | 7 | GEM 8 | remote: http://rubygems.org/ 9 | specs: 10 | diff-lcs (1.5.0) 11 | docile (1.4.0) 12 | rake (13.0.6) 13 | rdoc (6.3.3) 14 | rspec (3.10.0) 15 | rspec-core (~> 3.10.0) 16 | rspec-expectations (~> 3.10.0) 17 | rspec-mocks (~> 3.10.0) 18 | rspec-core (3.10.1) 19 | rspec-support (~> 3.10.0) 20 | rspec-expectations (3.10.1) 21 | diff-lcs (>= 1.2.0, < 2.0) 22 | rspec-support (~> 3.10.0) 23 | rspec-mocks (3.10.2) 24 | diff-lcs (>= 1.2.0, < 2.0) 25 | rspec-support (~> 3.10.0) 26 | rspec-support (3.10.3) 27 | ruby-ip (0.9.3) 28 | simplecov (0.21.2) 29 | docile (~> 1.1) 30 | simplecov-html (~> 0.11) 31 | simplecov_json_formatter (~> 0.1) 32 | simplecov-html (0.12.3) 33 | simplecov_json_formatter (0.1.3) 34 | 35 | PLATFORMS 36 | ruby 37 | 38 | DEPENDENCIES 39 | bundler (>= 2.4.13) 40 | rake (>= 10) 41 | rdoc (>= 6.3.0) 42 | rspec (~> 3.10) 43 | simplecov 44 | spf! 45 | 46 | BUNDLED WITH 47 | 2.4.13 48 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = SPF 2 | 3 | The +spf+ Ruby gem, also known as +spf-ruby+, is an implementation of the Sender Policy Framework (SPF) e-mail sender 4 | authentication system. It is closely based on the Mail::SPF Perl library at , so most of Mail::SPF's documentation is applicable. 5 | 6 | See for more information about SPF. 7 | 8 | Note: This gem is currently very early in its lifecycle. The API is *not* guaranteed to be stable. 9 | 10 | == Usage 11 | 12 | require 'spf' 13 | 14 | spf_server = SPF::Server.new 15 | 16 | request = SPF::Request.new( 17 | versions: [1, 2], # optional 18 | scope: 'mfrom', # or 'helo', 'pra' 19 | identity: 'fred@example.com', 20 | ip_address: '192.168.0.1', 21 | helo_identity: 'mta.example.com' # optional 22 | ) 23 | 24 | result = spf_server.process(request) 25 | 26 | puts result 27 | 28 | result_code = result.code # :pass, :fail, etc. 29 | 30 | == Copyright 31 | 32 | Copyright 2016 Agari Data, Inc. 33 | 34 | Licensed under the Apache License, Version 2.0 (the "License"); 35 | you may not use this software except in compliance with the License. 36 | You may obtain a copy of the License at 37 | 38 | http://www.apache.org/licenses/LICENSE-2.0 39 | 40 | Unless required by applicable law or agreed to in writing, software 41 | distributed under the License is distributed on an "AS IS" BASIS, 42 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 43 | See the License for the specific language governing permissions and 44 | limitations under the License. 45 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | 13 | require 'rspec/core' 14 | require 'rspec/core/rake_task' 15 | RSpec::Core::RakeTask.new(:spec) do |spec| 16 | spec.pattern = FileList['spec/**/*_spec.rb'] 17 | end 18 | 19 | RSpec::Core::RakeTask.new(:rcov) do |spec| 20 | spec.pattern = 'spec/**/*_spec.rb' 21 | spec.rcov = true 22 | end 23 | 24 | task :default => :spec 25 | 26 | require 'rdoc/task' 27 | Rake::RDocTask.new do |rdoc| 28 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 29 | 30 | rdoc.rdoc_dir = 'rdoc' 31 | rdoc.title = "spf-ruby #{version}" 32 | rdoc.rdoc_files.include('README*') 33 | rdoc.rdoc_files.include('lib/**/*.rb') 34 | end 35 | 36 | # vim:sw=2 sts=2 37 | -------------------------------------------------------------------------------- /lib/spf.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | require 'spf/ext/resolv' 3 | require 'spf/version' 4 | require 'spf/error' 5 | require 'spf/model' 6 | require 'spf/request' 7 | require 'spf/eval' 8 | require 'spf/macro_string' 9 | require 'spf/util' 10 | 11 | # 12 | # == SPF - An object-oriented implementation of Sender Policy Framework 13 | # 14 | # == SYNOPSIS 15 | # 16 | # 17 | # 18 | # require 'spf' 19 | # 20 | # spf_server = SPF::Server.new 21 | # 22 | # request = SPF::Request.new({ 23 | # :versions => [1, 2], # optional 24 | # :scope => 'mfrom', # or 'helo', 'pra' 25 | # :identity => 'fred@example.com', 26 | # :ip_address => '192.168.0.1', 27 | # :helo_identity => 'mta.example.com' # optional, 28 | # # for %{h} macro expansion 29 | # }) 30 | # 31 | # result = spf_server.process(request) 32 | # puts result 33 | # result_code = result.code 34 | # local_exp = result.local_explanation 35 | # authority_exp = result.authority_explanation 36 | # if result.is_code(:fail) 37 | # spf_header = result.received_spf_header 38 | # 39 | # 40 | # 41 | # == DESCRIPTION 42 | # 43 | # SPF is an object-oriented implementation of Sender Policy Framework 44 | # (SPF). See http://www.openspf.org for more information about SPF. 45 | # 46 | # This class collection aims to fully conform to the SPF specification (RFC 47 | # 4408 so as to serve both as a production quality SPF implementation and as a 48 | # reference for other developers of SPF implementations. 49 | # 50 | # 51 | # vim:sw=2 sts=2 52 | -------------------------------------------------------------------------------- /lib/spf/error.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | module SPF 3 | 4 | # Generic Exceptions 5 | ############################################################################## 6 | 7 | class Error < StandardError; end 8 | class OptionRequiredError < Error; end # Missing required method option 9 | # XXX Replace with ArgumentError? 10 | class InvalidOptionValueError < Error; end # Invalid value for method option 11 | # XXX Replace with ArgumentError! 12 | 13 | # Miscellaneous Errors 14 | ############################################################################## 15 | 16 | class DNSError < Error; end # DNS error 17 | class DNSTimeoutError < DNSError; end # DNS timeout 18 | class DNSNXDomainError < DNSError; end # DNS NXDomain 19 | class RecordSelectionError < Error # Record selection error 20 | attr_accessor :records 21 | def initialize(message, records=[]) 22 | @records = records 23 | super(message) 24 | end 25 | end 26 | class NoAcceptableRecordError < RecordSelectionError; end # No acceptable record found 27 | class RedundantAcceptableRecordsError < RecordSelectionError; end # Redundant acceptable records found 28 | class NoUnparsedTextError < Error; end # No unparsed text available 29 | class UnexpectedTermObjectError < Error; end # Unexpected term object encountered 30 | class ProcessingLimitExceededError < Error; end # Processing limit exceeded 31 | class MacroExpansionCtxRequiredError < OptionRequiredError; end # Missing required context for macro expansion 32 | 33 | # Parser Errors 34 | ############################################################################## 35 | 36 | class NothingToParseError < Error; end # Nothing to parse 37 | class SyntaxError < Error # Generic syntax error 38 | attr_accessor :text, :parse_text, :domain, :hint 39 | def initialize(message, text=nil, parse_text=nil, hint=nil) 40 | @text = text 41 | @parse_text = parse_text 42 | @hint = hint 43 | super(message) 44 | end 45 | end 46 | 47 | class InvalidRecordVersionError < SyntaxError; end # Invalid record version 48 | class InvalidScopeError < SyntaxError; end # Invalid scope 49 | class JunkInRecordError < SyntaxError; end # Junk encountered in record 50 | class InvalidModError < SyntaxError; end # Invalid modifier 51 | class InvalidTermError < SyntaxError; end # Invalid term 52 | class JunkInTermError < SyntaxError; end # Junk encountered in term 53 | class DuplicateGlobalModError < InvalidModError; end # Duplicate global modifier 54 | class InvalidMechError < InvalidTermError; end # Invalid mechanism 55 | class InvalidMechQualifierError < InvalidMechError; end # Invalid mechanism qualifier 56 | class InvalidMechCIDRError < InvalidMechError; end # Invalid CIDR netblock in mech 57 | class TermDomainSpecExpectedError < SyntaxError; end # Missing required in term 58 | class TermIPv4AddressExpectedError < SyntaxError; end # Missing required in term 59 | class TermIPv4PrefixLengthExpectedError < SyntaxError; end # Missing required in term 60 | class TermIPv6AddressExpectedError < SyntaxError; end # Missing required in term 61 | class TermIPv6PrefixLengthExpectedError < SyntaxError; end # Missing required in term 62 | class InvalidMacroStringError < SyntaxError; end # Invalid macro string 63 | class InvalidMacroError < InvalidMacroStringError 64 | end # Invalid macro 65 | 66 | end 67 | 68 | # vim:sw=2 sts=2 69 | -------------------------------------------------------------------------------- /lib/spf/eval.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | require 'ip' 3 | require 'resolv' 4 | 5 | require 'spf/error' 6 | require 'spf/model' 7 | require 'spf/result' 8 | 9 | class Resolv::DNS::Resource::IN::SPF < Resolv::DNS::Resource::IN::TXT 10 | # resolv.rb doesn't define an SPF resource type. 11 | TypeValue = 99 12 | end 13 | 14 | class SPF::Server 15 | 16 | attr_accessor \ 17 | :default_authority_explanation, 18 | :hostname, 19 | :dns_resolver, 20 | :query_rr_types, 21 | :max_dns_interactive_terms, 22 | :max_name_lookups_per_term, 23 | :max_name_lookups_per_mx_mech, 24 | :max_name_lookups_per_ptr_mech, 25 | :max_void_dns_lookups 26 | 27 | RECORD_CLASSES_BY_VERSION = { 28 | 1 => SPF::Record::V1, 29 | 2 => SPF::Record::V2 30 | } 31 | 32 | RESULT_BASE_CLASS = SPF::Result 33 | 34 | QUERY_RR_TYPE_ALL = 0 35 | QUERY_RR_TYPE_TXT = 1 36 | QUERY_RR_TYPE_SPF = 2 37 | 38 | DEFAULT_DEFAULT_AUTHORITY_EXPLANATION = 39 | 'Please see http://www.openspf.org/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}' 40 | 41 | DEFAULT_MAX_DNS_INTERACTIVE_TERMS = 10 # RFC 4408, 10.1/6 42 | DEFAULT_MAX_NAME_LOOKUPS_PER_TERM = 10 # RFC 4408, 10.1/7 43 | DEFAULT_QUERY_RR_TYPES = QUERY_RR_TYPE_TXT 44 | DEFAULT_MAX_NAME_LOOKUPS_PER_MX_MECH = DEFAULT_MAX_NAME_LOOKUPS_PER_TERM 45 | DEFAULT_MAX_NAME_LOOKUPS_PER_PTR_MECH = DEFAULT_MAX_NAME_LOOKUPS_PER_TERM 46 | DEFAULT_MAX_VOID_DNS_LOOKUPS = 2 47 | 48 | LOOSE_SPF_MATCH_PATTERN = 'v=spf' 49 | 50 | def initialize(options = {}) 51 | @default_authority_explanation = options[:default_authority_explanation] || 52 | DEFAULT_DEFAULT_AUTHORITY_EXPLANATION 53 | unless SPF::MacroString === @default_authority_explanation 54 | @default_authority_explanation = SPF::MacroString.new({ 55 | :text => @default_authority_explanation, 56 | :server => self, 57 | :is_explanation => true 58 | }) 59 | end 60 | @hostname = options[:hostname] || SPF::Util.hostname 61 | @dns_resolver = options[:dns_resolver] || Resolv::DNS.new 62 | @query_rr_types = options[:query_rr_types] || 63 | DEFAULT_QUERY_RR_TYPES 64 | @max_dns_interactive_terms = options[:max_dns_interactive_terms] || 65 | DEFAULT_MAX_DNS_INTERACTIVE_TERMS 66 | @max_name_lookups_per_term = options[:max_name_lookups_per_term] || 67 | DEFAULT_MAX_NAME_LOOKUPS_PER_TERM 68 | @max_name_lookups_per_mx_mech = options[:max_name_lookups_per_mx_mech] || 69 | DEFAULT_MAX_NAME_LOOKUPS_PER_MX_MECH 70 | @max_name_lookups_per_ptr_mech = options[:max_name_lookups_per_ptr_mech] || 71 | DEFAULT_MAX_NAME_LOOKUPS_PER_PTR_MECH 72 | 73 | # TODO: We should probably do this for the above maximums. 74 | @max_void_dns_lookups = options.has_key?(:max_void_dns_lookups) ? options[:max_void_dns_lookups] : DEFAULT_MAX_VOID_DNS_LOOKUPS 75 | 76 | @raise_exceptions = options.has_key?(:raise_exceptions) ? options[:raise_exceptions] : true 77 | 78 | end 79 | 80 | def result_class(name = nil) 81 | if name 82 | return RESULT_BASE_CLASS::RESULT_CLASSES[name] 83 | else 84 | return RESULT_BASE_CLASS 85 | end 86 | end 87 | 88 | def throw_result(name, request, text) 89 | raise self.result_class(name).new([self, request, text]) 90 | end 91 | 92 | def process(request) 93 | request.state(:authority_explanation, nil) 94 | request.state(:dns_interactive_terms_count, 0) 95 | request.state(:void_dns_lookups_count, 0) 96 | 97 | result = nil 98 | 99 | begin 100 | record = self.select_record(request) 101 | request.record = record 102 | record.eval(self, request) 103 | rescue SPF::Result => r 104 | result = r 105 | rescue SPF::DNSError => e 106 | result = self.result_class(:temperror).new([self, request, e.message]) 107 | rescue SPF::NoAcceptableRecordError => e 108 | result = self.result_class(:none ).new([self, request, e.message]) 109 | rescue SPF::RedundantAcceptableRecordsError, SPF::SyntaxError, SPF::ProcessingLimitExceededError => e 110 | result = self.result_class(:permerror).new([self, request, e.message]) 111 | end 112 | # Propagate other, unknown errors. 113 | # This should not happen, but if it does, it helps exposing the bug! 114 | 115 | return result 116 | end 117 | 118 | def resource_typeclass_for_rr_type(rr_type) 119 | return case rr_type 120 | when 'TXT' then Resolv::DNS::Resource::IN::TXT 121 | when 'SPF' then Resolv::DNS::Resource::IN::SPF 122 | when 'ANY' then Resolv::DNS::Resource::IN::ANY 123 | when 'A' then Resolv::DNS::Resource::IN::A 124 | when 'AAAA' then Resolv::DNS::Resource::IN::AAAA 125 | when 'PTR' then Resolv::DNS::Resource::IN::PTR 126 | when 'MX' then Resolv::DNS::Resource::IN::MX 127 | else 128 | raise ArgumentError, "Uknown RR type: #{rr_type}" 129 | end 130 | end 131 | 132 | def dns_lookup(domain, rr_type) 133 | if SPF::MacroString === domain 134 | domain = domain.expand 135 | # Truncate overlong labels at 63 bytes (RFC 4408, 8.1/27) 136 | domain.gsub!(/([^.]{63})[^.]+/, "#{$1}") 137 | # Drop labels from the head of domain if longer than 253 bytes (RFC 4408, 8.1/25): 138 | domain.sub!(/^[^.]+\.(.*)$/, "#{$1}") while domain.length > 253 139 | end 140 | 141 | rr_type = self.resource_typeclass_for_rr_type(rr_type) 142 | 143 | domain = domain.sub(/\.$/, '').downcase 144 | 145 | packet = nil 146 | begin 147 | packet = @dns_resolver.getresources(domain, rr_type) 148 | rescue Resolv::TimeoutError => e 149 | raise SPF::DNSTimeoutError.new( 150 | "Time-out on DNS '#{rr_type}' lookup of '#{domain}'") 151 | rescue Resolv::NXDomainError => e 152 | raise SPF::DNSNXDomainError.new("NXDomain for '#{domain}'") 153 | rescue Resolv::ResolvError => e 154 | raise SPF::DNSError.new("Error on DNS lookup of '#{domain}'") 155 | end 156 | 157 | # Raise DNS exception unless an answer packet with RCODE 0 or 3 (NXDOMAIN) 158 | # was received (thereby treating NXDOMAIN as an acceptable but empty answer packet): 159 | #if @dns_resolver.errorstring =~ /^(timeout|query timed out)$/ 160 | # raise SPF::DNSTimeoutError.new( 161 | # "Time-out on DNS '#{rr_type}' lookup of '#{domain}'") 162 | #end 163 | 164 | unless packet 165 | raise SPF::DNSError.new( 166 | "Unknown error on DNS '#{rr_type}' lookup of '#{domain}'") 167 | end 168 | 169 | #unless packet.header.rcode =~ /^(NOERROR|NXDOMAIN)$/ 170 | # raise SPF::DNSError.new( 171 | # "'#{packet.header.rcode}' error on DNS '#{rr_type}' lookup of '#{domain}'") 172 | #end 173 | return packet 174 | end 175 | 176 | def select_record(request, loose_match = false) 177 | domain = request.authority_domain 178 | versions = request.versions 179 | scope = request.scope 180 | 181 | # Employ identical behavior for 'v=spf1' and 'spf2.0' records, both of 182 | # which support SPF (code 99) and TXT type records (this may be different 183 | # in future revisions of SPF): 184 | # Query for SPF type records first, then fall back to TXT type records. 185 | 186 | records = [] 187 | loose_records = [] 188 | query_count = 0 189 | dns_errors = [] 190 | 191 | # Query for TXT-type RRs first: 192 | if @query_rr_types != QUERY_RR_TYPE_SPF 193 | begin 194 | query_count += 1 195 | packet = self.dns_lookup(domain, 'TXT') 196 | matches = self.get_acceptable_records_from_packet( 197 | packet, 'TXT', versions, scope, domain, loose_match) 198 | records << matches[0] 199 | loose_records << matches[1] 200 | rescue SPF::DNSError => e 201 | dns_errors << e 202 | end 203 | end 204 | 205 | if records.flatten.empty? && @query_rr_types != QUERY_RR_TYPE_TXT 206 | begin 207 | query_count += 1 208 | packet = self.dns_lookup(domain, 'SPF') 209 | matches = self.get_acceptable_records_from_packet( 210 | packet, 'SPF', versions, scope, domain, loose_match) 211 | records << matches[0] 212 | loose_records << matches[1] 213 | rescue SPF::DNSError => e 214 | dns_errors << e 215 | #rescue SPF::DNSTimeout => e 216 | # # FIXME: Ignore DNS timeouts on SPF type lookups? 217 | # # Apparently some brain-dead DNS servers time out on SPF-type queries. 218 | end 219 | end 220 | 221 | # Unless at least one query succeeded, re-raise the first DNS error that occured. 222 | raise dns_errors[0] unless dns_errors.length < query_count 223 | 224 | records.flatten! 225 | loose_records.flatten! 226 | 227 | if records.empty? 228 | # RFC 4408, 4.5/7 229 | raise SPF::NoAcceptableRecordError.new('No applicable sender policy available', 230 | loose_records) 231 | end 232 | 233 | # Discard all records but the highest acceptable version: 234 | preferred_record_class = records[0].class 235 | 236 | records = records.select { |record| preferred_record_class === record } 237 | 238 | if records.length != 1 239 | # RFC 4408, 4.5/6 240 | raise SPF::RedundantAcceptableRecordsError.new( 241 | "Redundant applicable '#{preferred_record_class.version_tag}' sender policies found", 242 | records 243 | ) 244 | end 245 | 246 | return records[0] 247 | end 248 | 249 | def get_acceptable_records_from_packet(packet, rr_type, versions, scope, domain, loose_match) 250 | 251 | # Try higher record versions first. 252 | # (This may be too simplistic for future revisions of SPF.) 253 | versions = versions.sort { |x, y| y <=> x } 254 | 255 | rr_type = resource_typeclass_for_rr_type(rr_type) 256 | records = [] 257 | possible_matches = [] 258 | packet.each do |rr| 259 | next unless rr_type === rr 260 | text = rr.strings.join('') 261 | record = false 262 | versions.each do |version| 263 | klass = RECORD_CLASSES_BY_VERSION[version] 264 | begin 265 | options = {:raise_exceptions => @raise_exceptions} 266 | # A MacroString object for domain indicates this is a nested record. 267 | # Storing the domain.text maintains an association to the include domain. 268 | if domain.class == SPF::MacroString 269 | options[:record_domain] = domain.text 270 | end 271 | record = klass.new_from_string(text, options) 272 | rescue SPF::InvalidRecordVersionError => error 273 | if text =~ /#{LOOSE_SPF_MATCH_PATTERN}/ 274 | possible_matches << text 275 | end 276 | # Ignore non-SPF and unknown-version records. 277 | # Propagate other errors (including syntax errors), though. 278 | end 279 | end 280 | if record 281 | if record.scopes.select{|x| scope == x}.any? 282 | # Record covers requested scope. 283 | records << record 284 | end 285 | end 286 | end 287 | return records, possible_matches 288 | end 289 | 290 | def count_dns_interactive_term(request) 291 | dns_interactive_terms_count = request.root_request.state(:dns_interactive_terms_count, 1) 292 | if (@max_dns_interactive_terms and 293 | dns_interactive_terms_count > @max_dns_interactive_terms) 294 | raise SPF::ProcessingLimitExceededError.new( 295 | "Maximum DNS-interactive terms limit (#{@max_dns_interactive_terms}) exceeded") 296 | end 297 | return dns_interactive_terms_count 298 | end 299 | 300 | def count_void_dns_lookup(request) 301 | void_dns_lookups_count = request.root_request.state(:void_dns_lookups_count, 1) 302 | if (@max_void_dns_lookups and 303 | void_dns_lookups_count > @max_void_dns_lookups) 304 | raise SPF::ProcessingLimitExceededError.new( 305 | "Maximum void DNS look-ups limit (#{@max_void_dns_lookups}) exceeded") 306 | end 307 | return void_dns_lookups_count 308 | end 309 | end 310 | 311 | # vim:sw=2 sts=2 312 | -------------------------------------------------------------------------------- /lib/spf/ext/resolv.rb: -------------------------------------------------------------------------------- 1 | require 'resolv' 2 | 3 | require 'rubygems' # Gem.ruby_version / Gem::Version 4 | 5 | 6 | # TCP fallback support, redux. 7 | # A broken version of this made it into Ruby 1.9.2 in October 2010. 8 | # That version would fail when trying 9 | # a second TCP nameserver. This improved version fixes that. 10 | # Filed upstream as . 11 | ############################################################################### 12 | 13 | class Resolv 14 | class DNS 15 | def each_resource(name, typeclass, &proc) 16 | lazy_initialize 17 | protocols = {} # PATCH 18 | requesters = {} # PATCH 19 | senders = {} 20 | #begin # PATCH 21 | @config.resolv(name) {|candidate, tout, nameserver, port| 22 | msg = Message.new 23 | msg.rd = 1 24 | msg.add_question(candidate, typeclass) 25 | protocol = protocols[candidate] ||= :udp # PATCH 26 | requester = requesters[[protocol, nameserver]] ||= case protocol # PATCH 27 | when :udp then make_udp_requester # PATCH 28 | when :tcp then make_tcp_requester(nameserver, port) # PATCH 29 | end # PATCH 30 | sender = senders[[candidate, requester, nameserver, port]] ||= # PATCH 31 | requester.sender(msg, candidate, nameserver, port) # PATCH 32 | reply, reply_name = requester.request(sender, tout) 33 | case reply.rcode 34 | when RCode::NoError 35 | if protocol == :udp and reply.tc == 1 # PATCH 36 | # Retry via TCP: # PATCH 37 | protocols[candidate] = :tcp # PATCH 38 | redo # PATCH 39 | else # PATCH 40 | extract_resources(reply, reply_name, typeclass, &proc) 41 | end # PATCH 42 | return 43 | when RCode::NXDomain 44 | raise Config::NXDomain.new(reply_name.to_s) 45 | else 46 | raise Config::OtherResolvError.new(reply_name.to_s) 47 | end 48 | } 49 | ensure 50 | requesters.each_value { |requester| requester.close } # PATCH 51 | #end # PATCH 52 | end 53 | 54 | #alias_method :make_udp_requester, :make_requester 55 | 56 | def make_tcp_requester(host, port) 57 | return Requester::TCP.new(host, port) 58 | rescue Errno::ECONNREFUSED 59 | # Treat a refused TCP connection attempt to a nameserver like a timeout, 60 | # as Resolv::DNS::Config#resolv considers ResolvTimeout exceptions as a 61 | # hint to try the next nameserver: 62 | raise ResolvTimeout 63 | end 64 | end 65 | end 66 | 67 | 68 | # Fix for (unreported) "nil can't be coerced into Integer" TypeError exception 69 | # caused by truncated (or otherwise malformed) answer packets. 70 | ############################################################################### 71 | 72 | class Resolv 73 | class DNS 74 | class Message 75 | class MessageDecoder 76 | 77 | def get_labels(limit=nil) 78 | limit = @index if !limit || @index < limit 79 | d = [] 80 | while true 81 | case @data[@index] && @data[@index].ord # PATCH 82 | when nil # PATCH 83 | raise DecodeError.new("truncated or malformed packet") # PATCH 84 | when 0 85 | @index += 1 86 | return d 87 | when 192..255 88 | idx = self.get_unpack('n')[0] & 0x3fff 89 | if limit <= idx 90 | raise DecodeError.new("non-backward name pointer") 91 | end 92 | save_index = @index 93 | @index = idx 94 | d += self.get_labels(limit) 95 | @index = save_index 96 | return d 97 | else 98 | d << self.get_label 99 | end 100 | end 101 | return d 102 | end 103 | 104 | end 105 | end 106 | end 107 | end 108 | 109 | 110 | # Patch to expose timeout and NXDOMAIN errors to the ultimate caller of 111 | # Resolv::DNS rather than swallowing them silently and returning an empty 112 | # result set. 113 | ############################################################################### 114 | 115 | class Resolv 116 | class TimeoutError < ResolvError; end 117 | class NXDomainError < ResolvError; end 118 | 119 | class DNS 120 | class Config 121 | attr_accessor :raise_errors # PATCH 122 | def resolv(name) 123 | candidates = generate_candidates(name) 124 | timeouts = generate_timeouts 125 | # Collect errors while making the various lookup attempts: # PATCH 126 | errors = [] # PATCH 127 | begin 128 | candidates.each {|candidate| 129 | begin 130 | timeouts.each {|tout| 131 | @nameserver_port.each {|nameserver, port| 132 | begin 133 | yield candidate, tout, nameserver, port 134 | rescue ResolvTimeout 135 | end 136 | } 137 | } 138 | # Collect a timeout: # PATCH 139 | errors << TimeoutError.new("DNS resolv timeout: #{name}") # PATCH 140 | rescue NXDomain 141 | # Collect an NXDOMAIN error: # PATCH 142 | errors << NXDomainError.new("DNS name does not exist: #{name}") # PATCH 143 | end 144 | } 145 | rescue ResolvError 146 | # Allow subclasses to set this to override this behavior without # PATCH 147 | # wholesale monkeypatching. # PATCH 148 | raise if raise_errors # PATCH 149 | # Ignore other errors like vanilla Resolv::DNS does. # PATCH 150 | # Perhaps this is not a good idea, though, as it silently swallows # PATCH 151 | # SERVFAILs, etc. # PATCH 152 | end 153 | # If one lookup succeeds, we will have returned within "yield" already. # PATCH 154 | # Otherwise we now raise the first error that occurred: # PATCH 155 | raise errors.first if not errors.empty? # PATCH 156 | end 157 | end 158 | end 159 | end 160 | 161 | # vim:sw=2 sts=2 162 | -------------------------------------------------------------------------------- /lib/spf/macro_string.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | require 'spf/util' 3 | require 'spf/error' 4 | require 'uri' 5 | 6 | 7 | module SPF 8 | class MacroString 9 | 10 | def self.default_split_delimiters 11 | '.' 12 | end 13 | 14 | def self.default_join_delimiter 15 | '.' 16 | end 17 | 18 | def self.uri_unreserved_chars 19 | 'A-Za-z0-9\-._~' 20 | end 21 | 22 | def initialize(options = {}) 23 | super() 24 | @text = options[:text] \ 25 | or raise ArgumentError, "Missing required 'text' option" 26 | @server = options[:server] 27 | @request = options[:request] 28 | @is_explanation = options[:is_explanation] 29 | @expanded = nil 30 | end 31 | 32 | attr_reader :text, :server, :request 33 | 34 | def context(server, request) 35 | valid_context(true, server, request) 36 | @server = server 37 | @request = request 38 | @expanded = nil 39 | return 40 | end 41 | 42 | def expand(context = nil) 43 | return @expanded if @expanded 44 | 45 | return nil unless @text 46 | return (@expanded = @text) unless @text =~ /%/ 47 | # Short-circuit expansion if text has no '%' characters. 48 | 49 | server, request = context ? context : [@server, @request] 50 | 51 | valid_context(true, server, request) 52 | 53 | expanded = '' 54 | 55 | text = @text 56 | 57 | while m = text.match(/ (.*?) %(.) /x) do 58 | expanded += m[1] 59 | key = m[2] 60 | 61 | if (key == '{') 62 | if m2 = m.post_match.match(/ (\w|_\p{Alpha}+) ([0-9]+)? (r)? ([.\-+,\/_=])? } /x) 63 | char, rh_parts, reverse, delimiter = m2.captures 64 | 65 | # Upper-case macro chars trigger URL-escaping AKA percent-encoding 66 | # (RFC 4408, 8.1/26): 67 | do_percent_encode = char =~ /\p{Upper}/ 68 | char.downcase! 69 | 70 | if char == 's' # RFC 4408, 8.1/19 71 | value = request.identity 72 | elsif char == 'l' # RFC 4408, 8.1/19 73 | value = request.localpart 74 | elsif char == 'o' # RFC 4408, 8.1/19 75 | value = request.domain 76 | elsif char == 'd' # RFC 4408, 8.1/6/4 77 | value = request.authority_domain 78 | elsif char == 'i' # RFC 4408, 8.1/20, 8.1/21 79 | ip_address = request.ip_address 80 | ip_address = SPF::Util.ipv6_address_to_ipv4(ip_address) if SPF::Util.ipv6_address_is_ipv4_mapped(ip_address) 81 | if IP::V4 === ip_address 82 | value = ip_address.to_addr 83 | elsif IP::V6 === ip_address 84 | value = ip_address.to_hex.upcase.split('').join('.') 85 | else 86 | server.throw_result(:permerror, request, "Unexpected IP address version in request") 87 | end 88 | elsif char == 'p' # RFC 4408, 8.1/22 89 | # According to RFC 7208 the "p" macro letter should not be used (or even published). 90 | # Here it is left unexpanded and transformers and delimiters are not applied. 91 | value = '%{' + m2.to_s 92 | rh_parts = nil 93 | reverse = nil 94 | elsif char == 'v' # RFC 4408, 8.1/6/7 95 | if IP::V4 === request.ip_address 96 | value = 'in-addr' 97 | elsif IP::V6 === request.ip_address 98 | value = 'ip6' 99 | else 100 | # Unexpected IP address version. 101 | server.throw_result(:permerror, request, "Unexpected IP address version in request") 102 | end 103 | elsif char == 'h' # RFC 4408, 8.1/6/8 104 | value = request.helo_identity || 'unknown' 105 | elsif char == 'c' # RFC 4408, 8.1/20, 8.1/21 106 | raise SPF::InvalidMacroStringError.new("Illegal 'c' macro in non-explanation macro string '#{@text}'") unless @is_explanation 107 | ip_address = request.ip_address 108 | value = SPF::Util::ip_address_to_string(ip_address) 109 | elsif char == 'r' # RFC 4408, 8.1/23 110 | value = server.hostname || 'unknown' 111 | elsif char == 't' 112 | raise SPF::InvalidMacroStringError.new("Illegal 't' macro in non-explanation macro string '#{@text}'") unless @is_explanation 113 | value = Time.now.to_i.to_s 114 | elsif char == '_scope' 115 | # Scope pseudo macro for internal use only! 116 | value = request.scope.to_s 117 | else 118 | # Unknown macro character. 119 | raise SPF::InvalidMacroStringError.new("Invalid macro character #{char} in macro string '#{@text}'") 120 | end 121 | 122 | if rh_parts || reverse 123 | delimiter ||= self.class.default_split_delimiters 124 | list = value.split(delimiter) 125 | list.reverse! if reverse 126 | # Extract desired parts: 127 | if rh_parts && rh_parts.to_i > 0 128 | list = list.last(rh_parts.to_i) 129 | end 130 | if rh_parts && rh_parts.to_i == 0 131 | raise SPF::InvalidMacroStringError.new("Illegal selection of 0 (zero) right-hand parts in macro string '#{@text}'") 132 | end 133 | value = list.join(self.class.default_join_delimiter) 134 | end 135 | 136 | if do_percent_encode 137 | unsafe = Regexp.new('^' + self.class.uri_unreserved_chars) 138 | value = URI.escape(value, unsafe) 139 | end 140 | 141 | expanded += value 142 | 143 | text = m2.post_match 144 | else 145 | # Invalid macro expression. 146 | raise SPF::InvalidMacroStringError.new("Invalid macro expression in macro string '#{@text}'") 147 | end 148 | elsif key == '-' 149 | expanded += '-' 150 | text = m.post_match 151 | elsif key == '_' 152 | expanded += ' ' 153 | text = m.post_match 154 | elsif key == '%' 155 | expanded += '%' 156 | text = m.post_match 157 | else 158 | # Invalid macro expression. 159 | pos = m.offset(2).first 160 | raise SPF::InvalidMacroStringError.new("Invalid macro expression at pos #{pos} in macro string '#{@text}'") 161 | end 162 | end 163 | 164 | expanded += text # Append remaining unmatched characters. 165 | 166 | context ? expanded : @expanded = expanded 167 | end 168 | 169 | def to_s 170 | if valid_context(false) 171 | return expand 172 | else 173 | return @text 174 | end 175 | end 176 | 177 | def valid_context(required, server = self.server, request = self.request) 178 | if not SPF::Server === server 179 | raise SPF::MacroExpansionCtxRequiredError.new('SPF server object required') if required 180 | return false 181 | end 182 | if not SPF::Request === request 183 | raise SPF::MacroExpansionCtxRequiredError.new('SPF request object required') if required 184 | return false 185 | end 186 | return true 187 | end 188 | end 189 | end 190 | 191 | # vim:sw=2 sts=2 192 | -------------------------------------------------------------------------------- /lib/spf/model.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | require 'ip' 3 | 4 | require 'spf/util' 5 | 6 | 7 | class IP 8 | def contains?(ip_address) 9 | return ( 10 | self.to_irange.first <= ip_address.to_i and 11 | self.to_irange.last >= ip_address.to_i) 12 | end 13 | end 14 | 15 | 16 | class SPF::Record 17 | DEFAULT_QUALIFIER = '+'; 18 | end 19 | 20 | class SPF::Term 21 | 22 | NAME_PATTERN = '[[:alpha:]] [[:alnum:]\\-_\\.]*' 23 | 24 | MACRO_LITERAL_PATTERN = "[!-$&-~%]" 25 | MACRO_DELIMITER = "[\\.\\-+,\\/_=]" 26 | MACRO_TRANSFORMERS_PATTERN = "\\d*r?" 27 | MACRO_EXPAND_PATTERN = " 28 | % 29 | (?: 30 | { [[:alpha:]] } #{MACRO_TRANSFORMERS_PATTERN} #{MACRO_DELIMITER}* } | 31 | [%_-] 32 | ) 33 | " 34 | 35 | MACRO_STRING_PATTERN = " 36 | (?: 37 | #{MACRO_EXPAND_PATTERN} | 38 | #{MACRO_LITERAL_PATTERN} 39 | )* 40 | " 41 | 42 | TOPLABEL_PATTERN = " 43 | [[:alnum:]_-]+ - [[:alnum:]-]* [[:alnum:]] | 44 | [[:alnum:]]* [[:alpha:]] [[:alnum:]]* 45 | " 46 | 47 | DOMAIN_END_PATTERN = " 48 | (?: \\. #{TOPLABEL_PATTERN} \\.? | 49 | #{MACRO_EXPAND_PATTERN} 50 | ) 51 | " 52 | 53 | DOMAIN_SPEC_PATTERN = " #{MACRO_STRING_PATTERN} #{DOMAIN_END_PATTERN} " 54 | 55 | QNUM_PATTERN = " (?: 25[0-5] | 2[0-4]\\d | 1\\d\\d | [1-9]\\d | \\d ) " 56 | IPV4_ADDRESS_PATTERN = " #{QNUM_PATTERN} (?: \\. #{QNUM_PATTERN}){3} " 57 | 58 | HEXWORD_PATTERN = "[[:xdigit:]]{1,4}" 59 | 60 | TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN = / 61 | #{HEXWORD_PATTERN} : #{HEXWORD_PATTERN} | #{IPV4_ADDRESS_PATTERN} 62 | /x 63 | 64 | IPV6_ADDRESS_PATTERN = " 65 | # x:x:x:x:x:x:x:x | x:x:x:x:x:x:n.n.n.n 66 | (?: #{HEXWORD_PATTERN} : ){6} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} | 67 | # x::x:x:x:x:x:x | x::x:x:x:x:n.n.n.n 68 | (?: #{HEXWORD_PATTERN} : ){1} : (?: #{HEXWORD_PATTERN} : ){4} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} | 69 | # x[:x]::x:x:x:x:x | x[:x]::x:x:x:n.n.n.n 70 | (?: #{HEXWORD_PATTERN} : ){1,2} : (?: #{HEXWORD_PATTERN} : ){3} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} | 71 | # x[:...]::x:x:x:x | x[:...]::x:x:n.n.n.n 72 | (?: #{HEXWORD_PATTERN} : ){1,3} : (?: #{HEXWORD_PATTERN} : ){2} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} | 73 | # x[:...]::x:x:x | x[:...]::x:n.n.n.n 74 | (?: #{HEXWORD_PATTERN} : ){1,4} : (?: #{HEXWORD_PATTERN} : ){1} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} | 75 | # x[:...]::x:x | x[:...]::n.n.n.n 76 | (?: #{HEXWORD_PATTERN} : ){1,5} : #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} | 77 | # x[:...]::x | - 78 | (?: #{HEXWORD_PATTERN} : ){1,6} : #{HEXWORD_PATTERN} | 79 | # x[:...]:: | 80 | (?: #{HEXWORD_PATTERN} : ){1,7} : | 81 | # ::[...:]x | - 82 | :: (?: #{HEXWORD_PATTERN} : ){0,6} #{HEXWORD_PATTERN} | 83 | # - | ::[...:]n.n.n.n 84 | :: (?: #{HEXWORD_PATTERN} : ){0,5} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} | 85 | # :: | - 86 | :: 87 | " 88 | 89 | attr_reader :errors, :ip_netblocks, :ip_address, :ip_network, :ipv4_prefix_length, :ipv6_prefix_length, :domain_spec, :raw_params, :record_domain 90 | 91 | def initialize(options = {}) 92 | @ip_address = nil 93 | @ip_network = nil 94 | @ipv4_prefix_length = nil 95 | @ipv6_prefix_length = nil 96 | @raw_params = nil 97 | @errors = [] 98 | @ip_netblocks = [] 99 | @text = options[:text] 100 | @record_domain = options[:record_domain] 101 | @raise_exceptions = options.has_key?(:raise_exceptions) ? options[:raise_exceptions] : true 102 | end 103 | 104 | def error(exception) 105 | raise exception if @raise_exceptions 106 | @errors << exception 107 | end 108 | 109 | def self.new_from_string(text, options = {}) 110 | options[:text] = text 111 | term = self.new(options) 112 | term.parse 113 | return term 114 | end 115 | 116 | def parse_domain_spec(required = false) 117 | if @parse_text.sub!(/^(#{DOMAIN_SPEC_PATTERN})/x, '') 118 | domain_spec = $1 119 | domain_spec.sub!(/^(.*?)\.?$/, $1) 120 | @domain_spec = SPF::MacroString.new({:text => domain_spec}) 121 | elsif record_domain 122 | @domain_spec = SPF::MacroString.new({:text => record_domain}) 123 | elsif required 124 | error(SPF::TermDomainSpecExpectedError.new( 125 | "Missing required domain-spec in '#{@text}'")) 126 | end 127 | end 128 | 129 | def parse_ipv4_address(required = false) 130 | @raw_params = @parse_text.dup 131 | if @parse_text.sub!(/^(#{IPV4_ADDRESS_PATTERN})/x, '') 132 | @ip_address = $1 133 | elsif required 134 | error(SPF::TermIPv4AddressExpectedError.new( 135 | "Missing or invalid required IPv4 address in '#{@text}'")) 136 | end 137 | @ip_address = @parse_text.dup unless @ip_address 138 | 139 | end 140 | 141 | def parse_ipv4_prefix_length(required = false) 142 | if @parse_text.sub!(/^\/(\d+)/, '') 143 | bits = $1.to_i 144 | unless bits and bits >= 0 and bits <= 32 and $1 !~ /^0./ 145 | error(SPF::TermIPv4PrefixLengthExpectedError.new( 146 | "Invalid IPv4 prefix length encountered in '#{@text}'")) 147 | return 148 | end 149 | @ipv4_prefix_length = bits 150 | elsif required 151 | error(SPF::TermIPv4PrefixLengthExpectedError.new( 152 | "Missing required IPv4 prefix length in '#{@text}")) 153 | return 154 | else 155 | @ipv4_prefix_length = self.default_ipv4_prefix_length 156 | end 157 | end 158 | 159 | def parse_ipv4_network(required = false) 160 | @raw_params = @parse_text.dup 161 | self.parse_ipv4_address(required) 162 | self.parse_ipv4_prefix_length 163 | begin 164 | @ip_network = IP.new("#{@ip_address}/#{@ipv4_prefix_length}") if @ip_address and @ipv4_prefix_length 165 | rescue ArgumentError 166 | @ip_network = @ip_address 167 | end 168 | end 169 | 170 | def parse_ipv6_address(required = false) 171 | if @parse_text.sub!(/(#{IPV6_ADDRESS_PATTERN})(?=\/|$)/x, '') 172 | @ip_address = $1 173 | elsif required 174 | error(SPF::TermIPv6AddressExpectedError.new( 175 | "Missing or invalid required IPv6 address in '#{@text}'")) 176 | end 177 | @ip_address = @parse_text.dup unless @ip_address 178 | end 179 | 180 | def parse_ipv6_prefix_length(required = false) 181 | if @parse_text.sub!(/^\/(\d+)/, '') 182 | bits = $1.to_i 183 | unless bits and bits >= 0 and bits <= 128 and $1 !~ /^0./ 184 | error(SPF::TermIPv6PrefixLengthExpectedError.new( 185 | "Invalid IPv6 prefix length encountered in '#{@text}'")) 186 | return 187 | end 188 | @ipv6_prefix_length = bits 189 | elsif required 190 | error(SPF::TermIPv6PrefixLengthExpectedError.new( 191 | "Missing required IPv6 prefix length in '#{@text}'")) 192 | return 193 | else 194 | @ipv6_prefix_length = self.default_ipv6_prefix_length 195 | end 196 | end 197 | 198 | def parse_ipv6_network(required = false) 199 | @raw_params = @parse_text.dup 200 | self.parse_ipv6_address(required) 201 | self.parse_ipv6_prefix_length 202 | begin 203 | @ip_network = IP.new("#{@ip_address}/#{@ipv6_prefix_length}") if @ip_address and @ipv6_prefix_length 204 | rescue ArgumentError 205 | @ip_network = @ip_address 206 | end 207 | end 208 | 209 | def parse_ipv4_ipv6_prefix_lengths 210 | self.parse_ipv4_prefix_length 211 | if self.instance_variable_defined?(:@ipv4_prefix_length) and # An IPv4 prefix length has been parsed, and 212 | @parse_text.sub!(/^\//, '') # another slash is following. 213 | # Parse an IPv6 prefix length: 214 | self.parse_ipv6_prefix_length(true) 215 | end 216 | end 217 | 218 | def domain(server, request) 219 | if self.instance_variable_defined?(:@domain_spec) and @domain_spec 220 | return SPF::MacroString.new({:server => server, :request => request, :text => @domain_spec.text}) 221 | end 222 | return request.authority_domain 223 | end 224 | 225 | def text 226 | if self.instance_variable_defined?(:@text) 227 | return @text 228 | else 229 | raise SPF::NoUnparsedTextError 230 | end 231 | end 232 | end 233 | 234 | class SPF::UnknownTerm < SPF::Term 235 | end 236 | 237 | class SPF::Mech < SPF::Term 238 | 239 | DEFAULT_QUALIFIER = SPF::Record::DEFAULT_QUALIFIER 240 | def default_ipv4_prefix_length; 32; end 241 | def default_ipv6_prefix_length; 128; end 242 | 243 | QUALIFIER_PATTERN = '[+\\-~\\?]' 244 | NAME_PATTERN = "#{NAME_PATTERN} (?= [:\\/\\x20] | $ )" 245 | 246 | EXPLANATION_TEMPLATES_BY_RESULT_CODE = { 247 | :pass => "Sender is authorized to use '%{s}' in '%{_scope}' identity", 248 | :fail => "Sender is not authorized to use '%{s}' in '%{_scope}' identity", 249 | :softfail => "Sender is not authorized to use '%{s}' in '%{_scope}' identity, however domain is not currently prepared for false failures", 250 | :neutral => "Domain does not state whether sender is authorized to use '%{s}' in '%{_scope}' identity" 251 | } 252 | 253 | def initialize(options) 254 | super(options) 255 | 256 | @text = options[:text] 257 | if not self.instance_variable_defined?(:@parse_text) 258 | @parse_text = @text.dup 259 | end 260 | if self.instance_variable_defined?(:@domain_spec) and 261 | not SPF::MacroString === @domain_spec 262 | @domain_spec = SPF::MacroString.new({:text => @domain_spec}) 263 | end 264 | end 265 | 266 | def parse 267 | if not @parse_text 268 | raise SPF::NothingToParseError.new('Nothing to parse for mechanism') 269 | end 270 | parse_qualifier 271 | parse_name if @errors.empty? 272 | parse_params if @errors.empty? 273 | parse_end if @errors.empty? 274 | end 275 | 276 | def params; nil; end 277 | 278 | def parse_qualifier 279 | if @parse_text.sub!(/(#{QUALIFIER_PATTERN})?/x, '') 280 | @qualifier = $1 or DEFAULT_QUALIFIER 281 | else 282 | error(SPF::InvalidMechQualifierError.new( 283 | "Invalid qualifier encountered in '#{@text}'")) 284 | end 285 | end 286 | 287 | def parse_name 288 | if @parse_text.sub!(/^ (#{NAME_PATTERN}) (?: : (?=.) )? /x, '') 289 | @name = $1 290 | else 291 | error(SPF::InvalidMechError.new("Unexpected mechanism encountered in '#{@text}'")) 292 | end 293 | end 294 | 295 | def parse_params(required = true) 296 | @raw_params = @parse_text.dup 297 | # Parse generic string of parameters text (should be overridden in sub-classes): 298 | if @parse_text.sub!(/^(.*)/, '') 299 | @params_text = $1 300 | @raw_params = @params_text.dup 301 | end 302 | end 303 | 304 | def parse_end 305 | unless @parse_text == '' 306 | error(SPF::JunkInTermError.new("Junk encountered in mechanism '#{@text}'", @text, @parse_text)) 307 | end 308 | @parse_text = nil 309 | end 310 | 311 | def qualifier 312 | # Read-only! 313 | return @qualifier if self.instance_variable_defined?(:@qualifier) and @qualifier 314 | return DEFAULT_QUALIFIER 315 | end 316 | 317 | def to_s 318 | @params = nil unless self.instance_variable_defined?(:@params) 319 | 320 | return sprintf( 321 | '%s%s%s', 322 | @qualifier == DEFAULT_QUALIFIER ? '' : @qualifier, 323 | @name, 324 | @params ? @params : '' 325 | ) 326 | end 327 | 328 | def match_in_domain(server, request, domain) 329 | domain = self.domain(server, request) unless domain 330 | 331 | ipv4_prefix_length = @ipv4_prefix_length || self.default_ipv4_prefix_length 332 | ipv6_prefix_length = @ipv6_prefix_length || self.default_ipv6_prefix_length 333 | 334 | begin 335 | rrs_a = server.dns_lookup(domain.to_s, 'A') || [] 336 | rescue SPF::DNSError => e 337 | @errors << e 338 | return false 339 | end 340 | begin 341 | rrs_aaaa = server.dns_lookup(domain.to_s, 'AAAA') || [] 342 | rescue SPF::DNSError => e 343 | @errors << e 344 | return false 345 | end 346 | 347 | rrs = rrs_a + rrs_aaaa 348 | server.count_void_dns_lookup(request) if rrs.empty? 349 | 350 | rrs.each do |rr| 351 | if Resolv::DNS::Resource::IN::A === rr 352 | network = IP.new("#{rr.address}/#{ipv4_prefix_length}") 353 | @ip_netblocks << network 354 | return true if network.contains?(request.ip_address) 355 | elsif Resolv::DNS::Resource::IN::AAAA === rr 356 | network = IP.new("#{rr.address}/#{ipv6_prefix_length}") 357 | @ip_netblocks << network 358 | return true if network.contains?(request.ip_address_v6) 359 | else 360 | # Unexpected RR type. 361 | # TODO: Generate debug info or ignore silently. 362 | end 363 | end 364 | return false 365 | end 366 | 367 | def explain(server, request, result) 368 | explanation_template = self.explanation_template(server, request, result) 369 | return unless explanation_template 370 | begin 371 | explanation = SPF::MacroString.new({ 372 | :text => explanation_template, 373 | :server => server, 374 | :request => request, 375 | :is_explanation => true 376 | }) 377 | request.state(:local_explanation, explanation) 378 | rescue SPF::Error 379 | rescue SPF::Result 380 | end 381 | end 382 | 383 | def explanation_template(server, request, result) 384 | return EXPLANATION_TEMPLATES_BY_RESULT_CODE[result.code] 385 | end 386 | 387 | 388 | class SPF::Mech::A < SPF::Mech 389 | 390 | NAME = 'a' 391 | 392 | def parse_params(required = true) 393 | @raw_params = @parse_text.dup 394 | self.parse_domain_spec 395 | self.parse_ipv4_ipv6_prefix_lengths 396 | end 397 | 398 | def params 399 | params = '' 400 | if @domain_spec 401 | params += @domain_spec.to_s if @domain_spec 402 | end 403 | if @ipv4_prefix_length and @ipv4_prefix_length != self.default_ipv4_prefix_length 404 | params += '/' + @ipv4_prefix_length.to_s 405 | end 406 | if @ipv6_prefix_length and @ipv6_prefix_length != self.default_ipv6_prefix_length 407 | params += '//' + @ipv6_prefix_length.to_s 408 | end 409 | return params 410 | end 411 | 412 | def match(server, request, want_result = true) 413 | server.count_dns_interactive_term(request) 414 | return self.match_in_domain(server, request, self.domain(server, request)) 415 | end 416 | 417 | end 418 | 419 | class SPF::Mech::All < SPF::Mech 420 | 421 | NAME = 'all' 422 | 423 | def parse_params(required = true) 424 | # No parameters. 425 | end 426 | 427 | def match(server, request, want_result = true) 428 | return true 429 | end 430 | 431 | end 432 | 433 | class SPF::Mech::Exists < SPF::Mech 434 | 435 | NAME = 'exists' 436 | 437 | def parse_params(required = true) 438 | @raw_params = @parse_text.dup 439 | self.parse_domain_spec(required) 440 | # Other method of denoting "potentially ~infinite" netblocks? 441 | @ip_netblocks << nil 442 | end 443 | 444 | def params 445 | return @domain_spec ? @domain_spec : nil 446 | end 447 | 448 | def match(server, request, want_result = true) 449 | server.count_dns_interactive_term(request) 450 | 451 | domain = self.domain(server, request) 452 | begin 453 | rrs = server.dns_lookup(domain, 'A') 454 | return true if rrs.any? 455 | rescue SPF::DNSNXDomainError => e 456 | server.count_void_dns_lookup(request) 457 | return false 458 | end 459 | end 460 | 461 | end 462 | 463 | class SPF::Mech::IP4 < SPF::Mech 464 | 465 | NAME = 'ip4' 466 | 467 | def parse_params(required = true) 468 | self.parse_ipv4_network(required) 469 | if IP === @ip_network 470 | @ip_netblocks << @ip_network 471 | if @ip_network.respond_to?(:offset) && @ip_network.offset != 0 472 | @errors << SPF::InvalidMechCIDRError.new( 473 | "Invalid CIDR netblock - bits in host portion of address of #{@ip_network}" 474 | ) 475 | end 476 | end 477 | end 478 | 479 | def params 480 | return nil unless @ip_network 481 | return @ip_network if String === @ip_network 482 | result = @ip_network.to_addr 483 | if @ip_network.pfxlen != @default_ipv4_prefix_length 484 | result += "/#{@ip_network.pfxlen}" 485 | end 486 | return result 487 | end 488 | 489 | def match(server, request, want_result = true) 490 | return false unless @ip_network 491 | ip_network_v6 = IP::V4 === @ip_network ? 492 | SPF::Util.ipv4_address_to_ipv6(@ip_network) : 493 | @ip_network 494 | return ip_network_v6.contains?(request.ip_address_v6) 495 | end 496 | 497 | end 498 | 499 | class SPF::Mech::IP6 < SPF::Mech 500 | 501 | NAME = 'ip6' 502 | 503 | def parse_params(required = true) 504 | self.parse_ipv6_network(required) 505 | @ip_netblocks << @ip_network if IP === @ip_network 506 | if @ip_network.respond_to?(:offset) && @ip_network.offset != 0 507 | @errors << SPF::InvalidMechCIDRError.new( 508 | "Invalid CIDR netblock - bits in host portion of address of #{@ip_network}" 509 | ) 510 | end 511 | end 512 | 513 | def params 514 | return nil unless @ip_network 515 | return @ip_network if String === @ip_network 516 | params = @ip_network.to_addr 517 | params += '/' + @ip_network.pfxlen.to_s if 518 | @ip_network.pfxlen != self.default_ipv6_prefix_length 519 | return params 520 | end 521 | 522 | def match(server, request, want_result = true) 523 | return @ip_network.contains?(request.ip_address_v6) 524 | end 525 | 526 | end 527 | 528 | class SPF::Mech::Include < SPF::Mech 529 | 530 | NAME = 'include' 531 | 532 | def intitialize(options = {}) 533 | super(options) 534 | @nested_record = nil 535 | end 536 | 537 | def parse_params(required = true) 538 | @raw_params = @parse_text.dup 539 | self.parse_domain_spec(required) 540 | end 541 | 542 | def params 543 | return @domain_spec ? @domain_spec.to_s : nil 544 | end 545 | 546 | def match(server, request, want_result = true) 547 | 548 | server.count_dns_interactive_term(request) 549 | 550 | # Create sub-request with mutated authority domain: 551 | authority_domain = self.domain(server, request) 552 | sub_request = request.new_sub_request({:authority_domain => authority_domain}) 553 | 554 | # Process sub-request: 555 | result = server.process(sub_request) 556 | 557 | # Translate result of sub-request (RFC 4408, 5.9): 558 | 559 | return false unless want_result 560 | 561 | return true if SPF::Result::Pass === result 562 | 563 | return false if 564 | SPF::Result::Fail === result or 565 | SPF::Result::SoftFail === result or 566 | SPF::Result::Neutral === result 567 | 568 | server.throw_result(:permerror, request, 569 | "Include domain '#{authority_domain}' has no applicable sender policy") if 570 | SPF::Result::None === result 571 | 572 | # Propagate any other results (including {Perm,Temp}Error) as-is: 573 | raise result 574 | end 575 | 576 | def nested_record(server=nil, request=nil, loose_match = false) 577 | return @nested_record if @nested_record 578 | return nil unless server and request 579 | authority_domain = self.domain(server, request) 580 | sub_request = request.new_sub_request({:authority_domain => authority_domain}) 581 | return @nested_record = server.select_record(sub_request, loose_match) 582 | end 583 | 584 | end 585 | 586 | class SPF::Mech::MX < SPF::Mech 587 | 588 | NAME = 'mx' 589 | 590 | def parse_params(required = true) 591 | @raw_params = @parse_text.dup 592 | self.parse_domain_spec 593 | self.parse_ipv4_ipv6_prefix_lengths 594 | end 595 | 596 | def params 597 | params = '' 598 | if @domain_spec 599 | params += @domain_spec.to_s 600 | end 601 | if @ipv4_prefix_length and @ipv4_prefix_length != self.default_ipv4_prefix_length 602 | params += '/' + @ipv4_prefix_length.to_s 603 | end 604 | if @ipv6_prefix_length and @ipv6_prefix_length != self.default_ipv6_prefix_length 605 | params += '//' + @ipv6_prefix_length.to_s 606 | end 607 | return params 608 | end 609 | 610 | def match(server, request, want_result = true) 611 | 612 | server.count_dns_interactive_term(request) 613 | 614 | target_domain = self.domain(server, request) 615 | mx_packet = server.dns_lookup(target_domain, 'MX') 616 | mx_rrs = (mx_packet or server.count_void_dns_lookup(request)) 617 | 618 | # Respect the MX mechanism lookups limit (RFC 4408, 5.4/3/4): 619 | if server.max_name_lookups_per_mx_mech 620 | mx_rrs = mx_rrs[0, server.max_name_lookups_per_mx_mech] 621 | end 622 | 623 | # TODO: Use A records from packet's "additional" section? Probably not. 624 | 625 | # Check MX records: 626 | mx_rrs.each do |rr| 627 | if Resolv::DNS::Resource::IN::MX === rr 628 | return true if 629 | self.match_in_domain(server, request, rr.exchange) 630 | else 631 | # Unexpected RR type. 632 | # TODO: Generate debug info or ignore silently. 633 | end 634 | end 635 | return false 636 | end 637 | 638 | end 639 | 640 | class SPF::Mech::PTR < SPF::Mech 641 | NAME = 'ptr' 642 | 643 | def parse_params(required = true) 644 | @raw_params = @parse_text.dup 645 | self.parse_domain_spec 646 | end 647 | 648 | def params 649 | return @domain_spec ? @domain_spec : nil 650 | end 651 | 652 | def match(server, request, want_result = true) 653 | return SPF::Util.valid_domain_for_ip_address( 654 | server, request, request.ip_address, self.domain(server, request)) ? 655 | true : false 656 | end 657 | end 658 | end 659 | 660 | class SPF::Mod < SPF::Term 661 | 662 | def initialize(options = {}) 663 | super 664 | 665 | @parse_text = options[:parse_text] 666 | @text = options[:text] 667 | @domain_spec = options[:domain_spec] 668 | 669 | @parse_text = @text.dup unless @parse_text 670 | 671 | if @domain_spec and not SPF::MacroString === @domain_spec 672 | @domain_spec = SPF::MacroString.new({:text => @domain_spec}) 673 | end 674 | end 675 | 676 | def parse 677 | error(SPF::NothingToParseError('Nothing to parse for modifier')) unless @parse_text 678 | self.parse_name if @errors.empty? 679 | self.parse_params(true) if @errors.empty? 680 | self.parse_end if @errors.empty? 681 | end 682 | 683 | def parse_name 684 | @parse_text.sub!(/^(#{self.class::NAME})=/i, '') 685 | if $1 686 | @name = $1 687 | else 688 | error(SPF::InvalidModError.new( 689 | "Unexpected modifier name encoutered in #{@text}")) 690 | end 691 | end 692 | 693 | def parse_params(required = false) 694 | # Parse generic macro string of parameters text (should be overridden in sub-classes): 695 | @parse_text.sub!(/^(#{MACRO_STRING_PATTERN})$/x, '') 696 | if $1 697 | @params_text = $1 698 | elsif required 699 | error(SPF::InvalidMacroStringError.new( 700 | "Invalid macro string encountered in #{@text}")) 701 | end 702 | end 703 | 704 | def parse_end 705 | unless @parse_text == '' 706 | error(SPF::JunkInTermError.new("Junk encountered in modifier #{@text}", @text, @parse_text)) 707 | end 708 | @parse_text = nil 709 | end 710 | 711 | def to_s 712 | return sprintf( 713 | '%s=%s', 714 | @name, 715 | @params ? @params : '' 716 | ) 717 | end 718 | 719 | class SPF::GlobalMod < SPF::Mod 720 | end 721 | 722 | class SPF::PositionalMod < SPF::Mod 723 | end 724 | 725 | class SPF::UnknownMod < SPF::Mod 726 | NAME = 'uknown' 727 | end 728 | 729 | class SPF::Mod::Exp < SPF::GlobalMod 730 | 731 | attr_reader :domain_spec 732 | 733 | NAME = 'exp' 734 | 735 | def precedence; 0.2; end 736 | 737 | def parse_params(required = true) 738 | @raw_params = @parse_text.dup 739 | self.parse_domain_spec(required) 740 | end 741 | 742 | def params 743 | return @domain_spec 744 | end 745 | 746 | def process(server, request, result) 747 | begin 748 | exp_domain = @domain_spec.new({:server => server, :request => request}) 749 | txt_packet = server.dns_lookup(exp_domain, 'TXT') 750 | txt_rrs = txt_packet.answer.select {|x| x.type == 'TXT'}.map {|x| x.answer} 751 | unless text_rrs.length > 0 752 | server.throw_result(:permerror, request, 753 | "No authority explanation string available at domain '#{exp_domain}'") # RFC 4408, 6.2/4 754 | end 755 | unless text_rrs.length == 1 756 | server.throw_result(:permerror, request, 757 | "Redundant authority explanation strings found at domain '#{exp_domain}'") # RFC 4408, 6.2/4 758 | end 759 | explanation = SPF::MacroString.new( 760 | :text => txt_rrs[0].char_str_list.join(''), 761 | :server => server, 762 | :request => request, 763 | :is_explanation => true 764 | ) 765 | request.state(:authority_explanation, explanation) 766 | rescue SPF::DNSError, SPF::Result::Error 767 | # Ignore DNS and other errors. 768 | end 769 | return request 770 | end 771 | end 772 | 773 | class SPF::Mod::Redirect < SPF::GlobalMod 774 | 775 | attr_reader :domain_spec 776 | 777 | NAME = 'redirect' 778 | 779 | def precedence; 0.8; end 780 | 781 | def init(options = {}) 782 | super(options) 783 | @nested_record = nil 784 | end 785 | 786 | def parse_params(required = true) 787 | @raw_params = @parse_text.dup 788 | self.parse_domain_spec(required) 789 | end 790 | 791 | def params 792 | return @domain_spec 793 | end 794 | 795 | def process(server, request, result) 796 | 797 | server.count_dns_interactive_term(request) 798 | 799 | # Only perform redirection if no mechanism matched (RFC 4408, 6.1/1): 800 | return unless SPF::Result::NeutralByDefault === result 801 | 802 | # Create sub-request with mutated authorithy domain: 803 | sub_request = request.new_sub_request({:authority_domain => @domain_spec}) 804 | 805 | # Process sub-request: 806 | result = server.process(sub_request) 807 | 808 | @nested_record = sub_request.record 809 | 810 | # Translate result of sub-request (RFC 4408, 6.1/4): 811 | if SPF::Result::None === result 812 | server.throw_result(:permerror, request, 813 | "Redirect domain '#{authority_domain}' has no applicable sender policy") 814 | end 815 | 816 | # Propagate any other results as-is: 817 | raise result 818 | end 819 | 820 | def nested_record(server=nil, request=nil) 821 | return @nested_record if @nested_record 822 | return nil unless server and request 823 | server.count_dns_interactive_term(request) 824 | authority_domain = self.domain(server, request) 825 | sub_request = request.new_sub_request({:authority_domain => authority_domain}) 826 | return @nested_record = server.select_record(sub_request) 827 | end 828 | end 829 | end 830 | 831 | class SPF::Record 832 | 833 | attr_reader :terms, :text, :errors 834 | 835 | RESULTS_BY_QUALIFIER = { 836 | '' => :pass, 837 | '+' => :pass, 838 | '-' => :fail, 839 | '~' => :softfail, 840 | '?' => :neutral 841 | } 842 | 843 | def initialize(options) 844 | super() 845 | @parse_text = (@text = options[:text] if not self.instance_variable_defined?(:@parse_text)).dup 846 | @terms ||= [] 847 | @global_mods ||= {} 848 | @errors = [] 849 | @ip_netblocks = [] 850 | @record_domain = options[:record_domain] 851 | @raise_exceptions = options.has_key?(:raise_exceptions) ? options[:raise_exceptions] : true 852 | end 853 | 854 | def self.new_from_string(text, options = {}) 855 | options[:text] = text 856 | record = new(options) 857 | record.parse 858 | return record 859 | end 860 | 861 | def error(exception) 862 | raise exception if @raise_exceptions 863 | @errors << exception 864 | end 865 | 866 | def ip_netblocks 867 | @ip_netblocks.flatten! 868 | return @ip_netblocks 869 | end 870 | 871 | def parse 872 | unless self.instance_variable_defined?(:@parse_text) and @parse_text 873 | error(SPF::NothingToParseError.new('Nothing to parse for record')) 874 | return 875 | end 876 | self.parse_version_tag 877 | while @parse_text.length > 0 878 | term = nil 879 | begin 880 | term = self.parse_term 881 | rescue SPF::Error => e 882 | term.errors << e if term 883 | @errors << e 884 | raise if @raise_exceptions 885 | return if SPF::JunkInRecordError === e or SPF::JunkInTermError === e 886 | end 887 | end 888 | end 889 | 890 | def parse_version_tag 891 | @parse_text.sub!(/^#{self.version_tag_pattern}\s*/ix, '') 892 | unless $1 893 | raise SPF::InvalidRecordVersionError.new( 894 | "Not a '#{self.version_tag}' record: '#{@text}'") 895 | end 896 | end 897 | 898 | def parse_term 899 | regex = / 900 | ^ 901 | ( 902 | #{SPF::Mech::QUALIFIER_PATTERN}? 903 | (#{SPF::Mech::NAME_PATTERN}) 904 | [^\x20]* 905 | ) 906 | (?: \x20+ | $ ) 907 | /x 908 | 909 | term = nil 910 | if @parse_text.sub!(regex, '') and $& 911 | # Looks like a mechanism: 912 | mech_text = $1 913 | mech_name = $2.downcase 914 | mech_class = self.mech_classes[mech_name.to_sym] 915 | exception = nil 916 | unless mech_class 917 | exception = SPF::InvalidMechError.new("Unknown mechanism type '#{mech_name}' in '#{self.version_tag}' record") 918 | error(exception) 919 | mech_class = SPF::Mech 920 | end 921 | options = {:raise_exceptions => @raise_exceptions} 922 | if instance_variable_defined?("@record_domain") 923 | options[:record_domain] = @record_domain 924 | end 925 | term = mech = mech_class.new_from_string(mech_text, options) 926 | term.errors << exception if exception 927 | @ip_netblocks << mech.ip_netblocks if mech.ip_netblocks 928 | @terms << mech 929 | if mech_class == SPF::Mech 930 | raise SPF::InvalidMechError.new("Unknown mechanism type '#{mech_name}' in '#{self.version_tag}' record") 931 | end 932 | elsif ( 933 | @parse_text.sub!(/ 934 | ^ 935 | ( 936 | (#{SPF::Mod::NAME_PATTERN}) = 937 | [^\x20]* 938 | ) 939 | (?: \x20+ | $ ) 940 | /x, '') and $& 941 | ) 942 | # Looks like a modifier: 943 | mod_text = $1 944 | mod_name = $2.downcase 945 | mod_class = self.class::MOD_CLASSES[mod_name.to_sym] || SPF::UnknownMod 946 | if mod_class 947 | # Known modifier. 948 | term = mod = mod_class.new_from_string(mod_text, {:raise_exceptions => @raise_exceptions}) 949 | if SPF::GlobalMod === mod 950 | # Global modifier. 951 | if @global_mods[mod_name] 952 | raise SPF::DuplicateGlobalModError.new("Duplicate global modifier '#{mod_name}' encountered") 953 | end 954 | @global_mods[mod_name] = mod 955 | elsif SPF::PositionalMod === mod 956 | # Positional modifier, queue normally: 957 | @terms << mod 958 | end 959 | end 960 | 961 | else 962 | token_text = @parse_text.sub(/\s.*/, '') 963 | hint = nil 964 | if token_text =~ /#{SPF::Term::IPV4_ADDRESS_PATTERN}/x 965 | hint = 'missing ip4: before IPv4 address?' 966 | elsif token_text =~ /#{SPF::Term::IPV6_ADDRESS_PATTERN}/x 967 | hint = 'missing ip6: before IPv6 address?' 968 | end 969 | raise SPF::JunkInRecordError.new("Junk encountered in record '#{@text}'", @text, @parse_text, hint) 970 | end 971 | @errors.concat(term.errors) 972 | return term 973 | end 974 | 975 | def global_mods 976 | return @global_mods.values.sort {|a,b| a.precedence <=> b.precedence } 977 | end 978 | 979 | def global_mod(mod_name) 980 | return @global_mods[mod_name] 981 | end 982 | 983 | def to_s 984 | return [version_tag, @terms, @global_mods].join(' ') 985 | end 986 | 987 | def eval(server, request, want_result = true) 988 | raise SPF::OptionRequiredError.new('SPF server object required for record evaluation') unless server 989 | raise SPF::OptionRequiredError.new('Request object required for record evaluation') unless request 990 | begin 991 | @terms.each do |term| 992 | if SPF::Mech === term 993 | # Term is a mechanism. 994 | mech = term 995 | if mech.match(server, request, request.ip_address != nil) 996 | result_name = RESULTS_BY_QUALIFIER[mech.qualifier] 997 | result_class = server.result_class(result_name) 998 | result = result_class.new([server, request, "Mechanism '#{term.text}' matched"]) 999 | mech.explain(server, request, result) 1000 | raise result if want_result 1001 | end 1002 | elsif SPF::PositionalMod === term 1003 | # Term is a positional modifier. 1004 | mod = term 1005 | mod.process(server, request) 1006 | elsif SPF::UnknownMod === term 1007 | # Term is an unknown modifier. Ignore it (RFC 4408, 6/3). 1008 | else 1009 | # Invalid term object encountered: 1010 | error(SPF::UnexpectedTermObjectError.new("Unexpected term object '#{term}' encountered.")) 1011 | end 1012 | end 1013 | server.throw_result(:neutral_by_default, request, 1014 | 'Default neutral result due to no mechanism matches') 1015 | rescue SPF::Result => result 1016 | # Process global modifiers in ascending order of precedence: 1017 | global_mods.each do |global_mod| 1018 | global_mod.process(server, request, result) 1019 | end 1020 | raise result if want_result 1021 | end 1022 | end 1023 | 1024 | class SPF::Record::V1 < SPF::Record 1025 | 1026 | MECH_CLASSES = { 1027 | :all => SPF::Mech::All, 1028 | :ip4 => SPF::Mech::IP4, 1029 | :ip6 => SPF::Mech::IP6, 1030 | :a => SPF::Mech::A, 1031 | :mx => SPF::Mech::MX, 1032 | :ptr => SPF::Mech::PTR, 1033 | :exists => SPF::Mech::Exists, 1034 | :include => SPF::Mech::Include 1035 | } 1036 | 1037 | MOD_CLASSES = { 1038 | :redirect => SPF::Mod::Redirect, 1039 | :exp => SPF::Mod::Exp, 1040 | } 1041 | 1042 | 1043 | def scopes 1044 | [:helo, :mfrom] 1045 | end 1046 | 1047 | def version_tag 1048 | 'v=spf1' 1049 | end 1050 | 1051 | def self.version_tag 1052 | 'v=spf1' 1053 | end 1054 | 1055 | def version_tag_pattern 1056 | " v=spf(1) (?= \\x20+ | $ ) " 1057 | end 1058 | 1059 | def mech_classes 1060 | MECH_CLASSES 1061 | end 1062 | 1063 | def initialize(options = {}) 1064 | super(options) 1065 | 1066 | @scopes ||= options[:scopes] 1067 | if @scopes and scopes.any? 1068 | unless @scopes.length > 0 1069 | raise SPF::InvalidScopeError.new('No scopes for v=spf1 record') 1070 | end 1071 | if @scopes.length == 2 1072 | unless ( 1073 | @scopes[0] == :helo and @scopes[1] == :mfrom or 1074 | @scopes[0] == :mfrom and @scopes[1] == :helo) 1075 | raise SPF::InvalidScope.new( 1076 | "Invalid set of scopes " + @scopes.map{|x| "'#{x}'"}.join(', ') + "for v=spf1 record") 1077 | end 1078 | end 1079 | end 1080 | end 1081 | end 1082 | 1083 | class SPF::Record::V2 < SPF::Record 1084 | 1085 | MECH_CLASSES = { 1086 | :all => SPF::Mech::All, 1087 | :ip4 => SPF::Mech::IP4, 1088 | :ip6 => SPF::Mech::IP6, 1089 | :a => SPF::Mech::A, 1090 | :mx => SPF::Mech::MX, 1091 | :ptr => SPF::Mech::PTR, 1092 | :exists => SPF::Mech::Exists, 1093 | :include => SPF::Mech::Include 1094 | } 1095 | 1096 | MOD_CLASSES = { 1097 | :redirect => SPF::Mod::Redirect, 1098 | :exp => SPF::Mod::Exp 1099 | } 1100 | 1101 | VALID_SCOPE = /^(?: mfrom | pra )$/x 1102 | def version_tag 1103 | 'v=spf2.0' 1104 | end 1105 | 1106 | def scopes 1107 | [:mfrom, :pra] 1108 | end 1109 | 1110 | def version_tag_pattern 1111 | " 1112 | spf(2\.0) 1113 | \/ 1114 | ( (?: mfrom | pra ) (?: , (?: mfrom | pra ) )* ) 1115 | (?= \\x20 | $ ) 1116 | " 1117 | end 1118 | 1119 | def mech_classes 1120 | MECH_CLASSES 1121 | end 1122 | 1123 | def initialize(options = {}) 1124 | super(options) 1125 | unless @parse_text 1126 | scopes = @scopes || {} 1127 | raise SPF::InvalidScopeError.new('No scopes for spf2.0 record') if scopes.empty? 1128 | scopes.each do |scope| 1129 | if scope !~ VALID_SCOPE 1130 | raise SPF::InvalidScopeError.new("Invalid scope '#{scope}' for spf2.0 record") 1131 | end 1132 | end 1133 | end 1134 | end 1135 | 1136 | def version_tag 1137 | return 'spf2.0' if not @scopes # no scopes parsed 1138 | return 'spf2.0/' + @scopes.join(',') 1139 | end 1140 | 1141 | def parse_version_tag 1142 | 1143 | @parse_text.sub!(/#{version_tag_pattern}(?:\x20+|$)/ix, '') 1144 | if $1 1145 | scopes = @scopes = "#{$2}".split(/,/) 1146 | if scopes.empty? 1147 | raise SPF::InvalidScopeError.new('No scopes for spf2.0 record') 1148 | end 1149 | scopes.each do |scope| 1150 | if scope !~ VALID_SCOPE 1151 | raise SPF::InvalidScopeError.new("Invalid scope '#{scope}' for spf2.0 record") 1152 | end 1153 | end 1154 | else 1155 | raise SPF::InvalidRecordVersionError.new( 1156 | "Not a 'spf2.0' record: '#{@text}'") 1157 | end 1158 | end 1159 | end 1160 | end 1161 | 1162 | # vim:sw=2 sts=2 1163 | -------------------------------------------------------------------------------- /lib/spf/request.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | require 'ip' 3 | 4 | require 'spf/error' 5 | 6 | class SPF::Request 7 | 8 | attr_reader :scope, :identity, :domain, :localpart, :ip_address, :ip_address_v6, :helo_identity, :versions, :sub_requests 9 | attr_accessor :record, :opt, :root_request, :super_request 10 | 11 | VERSIONS_FOR_SCOPE = { 12 | :helo => [1 ], 13 | :mfrom => [1, 2], 14 | :pra => [ 2] 15 | } 16 | 17 | SCOPES_BY_VERSION = { 18 | 1 => [:helo, :mfrom ], 19 | 2 => [ :mfrom, :pra] 20 | } 21 | 22 | DEFAULT_LOCALPART = 'postmaster' 23 | 24 | def initialize(options = {}) 25 | @opt = options 26 | @state = {} 27 | @versions = options[:versions] 28 | @scope = options[:scope] || :mfrom 29 | @scope = @scope.to_sym if String === @scope 30 | @authority_domain = options[:authority_domain] 31 | @identity = options[:identity] 32 | @ip_address = options[:ip_address] 33 | @helo_identity = options[:helo_identity] 34 | @root_request = self 35 | @super_request = self 36 | @record = nil 37 | @sub_requests = [] 38 | 39 | # Scope: 40 | versions_for_scope = VERSIONS_FOR_SCOPE[@scope] or 41 | raise SPF::InvalidScopeError.new("Invalid scope '#{@scope}'") 42 | 43 | # Versions: 44 | if @versions 45 | if Integer === @versions 46 | # Single version specified as a Integer: 47 | @versions = [@versions] 48 | elsif not Array === @versions 49 | # Something other than Integer or array specified: 50 | raise SPF::InvalidOptionValueError.new("'versions' option must be symbol or array") 51 | end 52 | 53 | # All requested record versions must be supported: 54 | unsupported_versions = @versions.select { |x| 55 | not SCOPES_BY_VERSION[x] 56 | } 57 | if unsupported_versions.any? 58 | raise SPF::InvalidOptionValueError.new( 59 | "Unsupported record version(s): " + 60 | unsupported_versions.map { |x| "'#{x}'" }.join(', ')) 61 | end 62 | else 63 | # No versions specified, use all versions relevant to scope: 64 | @versions = versions_for_scope 65 | end 66 | 67 | versions = @versions.select {|x| versions_for_scope.include?(x)} 68 | if versions.empty? 69 | raise SPF::InvalidScopeError.new( 70 | "Invalid scope '#{@scope}' for record version(s) #{@versions}" 71 | ) 72 | end 73 | @versions = versions 74 | 75 | # Identity: 76 | raise SPF::OptionRequiredError.new( 77 | "Missing required 'identity' option") unless @identity 78 | raise SPF::InvalidOptionValueError.new( 79 | "'identity' option must not be empty") if @identity.empty? 80 | 81 | # Extract domain and localpart from identity: 82 | if ((@scope == :mfrom or @scope == :pra) and 83 | @identity =~ /^(.*)@(.*?)$/) 84 | @localpart = $1 85 | @domain = $2 86 | else 87 | @domain = @identity 88 | end 89 | # Lower-case domain and removee eventual trailing dot. 90 | @domain.downcase! 91 | @domain.chomp!('.') 92 | if (not self.instance_variable_defined?(:@localpart) or 93 | not @localpart or not @localpart.length > 0) 94 | @localpart = DEFAULT_LOCALPART 95 | end 96 | 97 | # HELO identity: 98 | if @scope == :helo 99 | @helo_identity ||= @identity 100 | end 101 | 102 | return unless @ip_address 103 | 104 | # Ensure ip_address is an IP object: 105 | unless IP === @ip_address 106 | @ip_address = IP.new(@ip_address) 107 | end 108 | 109 | # Convert IPv4 address to IPv4-mapped IPv6 address: 110 | 111 | if SPF::Util.ipv6_address_is_ipv4_mapped(@ip_address) 112 | @ip_address_v6 = @ip_address # Accept as IPv6 address as-is 113 | @ip_address = SPF::Util.ipv6_address_to_ipv4(@ip_address) 114 | elsif IP::V4 === @ip_address 115 | @ip_address_v6 = SPF::Util.ipv4_address_to_ipv6(@ip_address) 116 | elsif IP::V6 === @ip_address 117 | @ip_address_v6 = @ip_address 118 | else 119 | raise SPF::InvalidOptionValueError.new("Unexpected IP address version"); 120 | end 121 | end 122 | 123 | def new_sub_request(options) 124 | obj = self.class.new(@opt.merge(options)) 125 | obj.super_request = self 126 | obj.root_request = super_request.root_request 127 | @sub_requests << obj 128 | return obj 129 | end 130 | 131 | def authority_domain 132 | return (@authority_domain or @domain) 133 | end 134 | 135 | def state(field, value = nil) 136 | unless field 137 | raise SPF::OptionRequiredError.new('Field name required') 138 | end 139 | if value 140 | if Integer === value 141 | @state[field] = 0 unless @state[field] 142 | return (@state[field] += value) 143 | else 144 | return (@state[field] = value) 145 | end 146 | else 147 | return @state[field] 148 | end 149 | end 150 | end 151 | 152 | # vim:sw=2 sts=2 153 | -------------------------------------------------------------------------------- /lib/spf/result.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | require 'spf/model' 3 | require 'spf/util' 4 | 5 | class SPF::Result < Exception 6 | 7 | attr_reader :server, :request, :result_text 8 | 9 | class SPF::Result::Pass < SPF::Result 10 | def code 11 | :pass 12 | end 13 | end 14 | 15 | class SPF::Result::Fail < SPF::Result 16 | def code 17 | :fail 18 | end 19 | 20 | def authority_explanation 21 | if self.instance_variable_defined?(:@authority_explanation) 22 | return @authority_explanation 23 | end 24 | 25 | @authority_explanation = nil 26 | 27 | server = @server 28 | request = @request 29 | 30 | authority_explanation_macrostring = request.state('authority_explanation') 31 | 32 | # If an explicit explanation was specified by the authority domain... 33 | if authority_explanation_macrostring 34 | begin 35 | # ... then try to expand it: 36 | @authority_explanation = authority_explanation_macrostring.expand 37 | rescue SPF::InvalidMacroString 38 | # Igonre expansion errors and leave authority explanation undefined. 39 | end 40 | end 41 | 42 | # If no authority explanation could be determined so far... 43 | unless @authority_explanation 44 | @authority_explanation = server.default_authority_explanation.new({:request => request}).expand 45 | end 46 | return @authority_explanation 47 | end 48 | end 49 | 50 | class SPF::Result::SoftFail < SPF::Result 51 | def code 52 | :softfail 53 | end 54 | end 55 | 56 | class SPF::Result::Neutral < SPF::Result 57 | def code 58 | :neutral 59 | end 60 | end 61 | 62 | class SPF::Result::NeutralByDefault < SPF::Result::Neutral 63 | # This is a special-case of the Neutral result that is thrown as a default 64 | # when "falling off" the end of the record. See SPF::Record.eval(). 65 | NAME = :neutral_by_default 66 | end 67 | 68 | class SPF::Result::None < SPF::Result 69 | def code 70 | :none 71 | end 72 | end 73 | 74 | class SPF::Result::Error < SPF::Result 75 | def code 76 | :error 77 | end 78 | end 79 | 80 | class SPF::Result::TempError < SPF::Result::Error 81 | def code 82 | :temperror 83 | end 84 | end 85 | 86 | class SPF::Result::PermError < SPF::Result::Error 87 | def code 88 | :permerror 89 | end 90 | end 91 | 92 | 93 | RESULT_CLASSES = { 94 | :pass => SPF::Result::Pass, 95 | :fail => SPF::Result::Fail, 96 | :softfail => SPF::Result::SoftFail, 97 | :neutral => SPF::Result::Neutral, 98 | :neutral_by_default => SPF::Result::NeutralByDefault, 99 | :none => SPF::Result::None, 100 | :error => SPF::Result::Error, 101 | :permerror => SPF::Result::PermError, 102 | :temperror => SPF::Result::TempError 103 | } 104 | 105 | RECEIVED_SPF_HEADER_NAME = 'Received-SPF' 106 | 107 | RECEIVED_SPF_HEADER_SCOPE_NAMES_BY_SCOPE = { 108 | :helo => 'helo', 109 | :mfrom => 'envelope-from', 110 | :pra => 'pra' 111 | } 112 | 113 | RECEIVED_SPF_HEADER_IDENTITY_KEY_NAMES_BY_SCOPE = { 114 | :helo => 'helo', 115 | :mfrom => 'envelope-from', 116 | :pra => 'pra' 117 | } 118 | 119 | ATEXT_PATTERN = /[[:alnum:]!#\$%&'*+\-\/=?^_`{|}~]/ 120 | DOT_ATOM_PATTERN = / 121 | (#{ATEXT_PATTERN})+ ( \. (#{ATEXT_PATTERN})+ )* 122 | /x 123 | 124 | def initialize(args = []) 125 | @server = args.shift if args.any? 126 | unless self.instance_variable_defined?(:@server) 127 | raise SPF::OptionRequiredError.new('SPF server object required') 128 | end 129 | @request = args.shift if args.any? 130 | unless self.instance_variable_defined?(:@request) 131 | raise SPF::OptionRequiredError.new('Request object required') 132 | end 133 | @result_text = args.shift if args.any? 134 | end 135 | 136 | def name 137 | return self.code 138 | end 139 | 140 | def klass(name=nil) 141 | if name 142 | name = name.to_sym if String === name 143 | return RESULT_CLASSES[name] 144 | else 145 | return name 146 | end 147 | end 148 | 149 | def isa_by_name(name) 150 | suspect_class = self.klass(name.downcase) 151 | return false unless suspect_class 152 | return suspect_class === self 153 | end 154 | 155 | def is_code(code) 156 | return self.isa_by_name(code.downcase) 157 | end 158 | 159 | def to_s 160 | return sprintf('%s (%s)', self.name, SPF::Util.sanitize_string(super.to_s)) 161 | end 162 | 163 | def local_explanation 164 | return @local_explanation if self.instance_variable_defined?(:@local_explanation) 165 | 166 | # Prepare local explanation: 167 | request = self.request 168 | local_explanation = request.state(:local_explanation) 169 | if local_explanation 170 | local_explanation = sprintf('%s (%s)', local_explanation.expand, @text) 171 | else 172 | local_explanation = @text 173 | end 174 | 175 | # Resolve authority domains of root-request and bottom sub-requests: 176 | root_request = request.root_request 177 | local_explanation = (request == root_request or not root_request) ? 178 | sprintf('%s: %s', request.authority_domain, local_explanation) : 179 | sprintf('%s ... %s: %s', root_request.authority_domain, request.authority_domain, local_explanation) 180 | 181 | return @local_explanation = SPF::Util.sanitize_string(local_explanation) 182 | end 183 | 184 | def received_spf_header 185 | return @received_spf_header if self.instance_variable_defined?(:@received_spf_header) 186 | scope_name = self.received_spf_header_scope_names_by_scope[@request.scope] 187 | identify_key_name = self.received_spf_header_identity_key_names_by_scope[@request.scope] 188 | info_pairs = [ 189 | :receiver => @server.hostname || 'unknown', 190 | :identity => scope_name, 191 | identity_key_name.to_sym => @request.identity, 192 | :client_ip => SPF::Util.ip_address_to_string(@request.ip_address) 193 | ] 194 | if @request.scope != :helo and @request.helo_identity 195 | info_pairs[:helo] = @request.helo_identity 196 | end 197 | info_string = '' 198 | while info_pairs.any? 199 | key = info_pairs.shift 200 | value = info_pairs.shift 201 | info_string += '; ' unless info_string.blank? 202 | if value !~ /^#{DOT_ATOM_PATTERN}$/o 203 | value.gsub!(/(["\\])/, "\\#{$1}") # Escape '\' and '"' characters. 204 | value = "\"#{value}\"" # Double-quote value. 205 | end 206 | info_string += "#{key}=#{value}" 207 | end 208 | return @received_spf_header = sprintf( 209 | '%s: %s (%s) %s', 210 | @received_spf_header_name, 211 | self.code, 212 | self.local_explanation, 213 | info_string 214 | ) 215 | end 216 | 217 | end 218 | 219 | # vim:sw=2 sts=2 220 | -------------------------------------------------------------------------------- /lib/spf/test.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | require 'spf/test/case' 4 | require 'spf/test/scenario' 5 | 6 | module SPF::Test 7 | #class SPF::Test::Base 8 | 9 | attr_accessor :scenarios 10 | 11 | def self.new_from_yaml(yaml_text, options={}) 12 | @scenarios = YAML.load_documents(yaml_text).map {|doc| SPF::Test::Scenario.new_from_yaml(doc)} 13 | return @scenarios 14 | end 15 | 16 | def self.new_from_yaml_file(file_name, options={}) 17 | return self.new_from_yaml(File.open(file_name, 'r').read) 18 | end 19 | end 20 | 21 | #end 22 | 23 | -------------------------------------------------------------------------------- /lib/spf/test/case.rb: -------------------------------------------------------------------------------- 1 | require 'spf/test' 2 | 3 | module SPF::Test 4 | class SPF::Test::Case 5 | 6 | attr_accessor :name, :description, :comment, :spec_refs, :scope, :identity, 7 | :ip_address, :helo_identity, :expected_results, 8 | :expected_explanation 9 | def initialize(options) 10 | @scope = options[:scope] || 'mfrom' 11 | @name = options[:name] 12 | @description = options[:description] 13 | @comment = options[:comment] 14 | @spec_refs = options[:spec] 15 | @identity = options[:identity] 16 | @ip_address = options[:host] 17 | @helo_identity = options[:helo] 18 | @expected_results = options[:expected_results] 19 | @expected_explanation = options[:expected_explanation] 20 | 21 | end 22 | 23 | def self.new_from_yaml_struct(yaml_struct) 24 | scope = yaml_struct['scope'] || yaml_struct['mailfrom'] ? 'mfrom' : 'helo' 25 | obj = self.new( 26 | name: yaml_struct['name'], 27 | description: yaml_struct['description'], 28 | comment: yaml_struct['comment'], 29 | spec_refs: yaml_struct['spec'], 30 | scope: scope, 31 | identity: yaml_struct['identity'], 32 | ip_address: yaml_struct['host'], 33 | helo_identity: yaml_struct['helo'], 34 | expected_results: yaml_struct['result'], 35 | expected_explanation: yaml_struct['explanation'] 36 | ) 37 | if obj.scope == 'helo' 38 | obj.identity ||= yaml_struct['helo'] 39 | elsif obj.scope == 'mfrom' 40 | obj.identity ||= yaml_struct['mailfrom'] 41 | end 42 | return obj 43 | end 44 | 45 | def is_expected_result(result_code) 46 | return expected_results.has_key?(result_code) 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /lib/spf/test/scenario.rb: -------------------------------------------------------------------------------- 1 | require 'spf/test' 2 | 3 | require 'ip' 4 | require 'yaml' 5 | 6 | #class Resolv::DNS::Resource::IN::SPF < Resolv::DNS::Resource::IN::TXT 7 | # resolv.rb doesn't define an SPF resource type. 8 | # TypeValue = 99 9 | #end 10 | 11 | module SPF::Test 12 | class SPF::Test::Scenario 13 | 14 | attr_accessor :records, :test_cases, :description, :records_by_domain 15 | 16 | def self.new_from_yaml_struct(yaml_struct, options = {}) 17 | obj = self.new 18 | 19 | puts 'SPF::Test::Scenario.new_from_yaml_struct' 20 | obj.description = yaml_struct['description'] 21 | tests = yaml_struct['tests'] 22 | test_cases = obj.test_cases = {} 23 | tests.each_key do |test_name| 24 | tests[test_name]['name'] = test_name 25 | test_cases[test_name] = SPF::Test::Case.new_from_yaml_struct(tests[test_name]) 26 | end 27 | 28 | zonedata = yaml_struct['zonedata'] || {} 29 | records = obj.records = [] 30 | records_by_domain = obj.records_by_domain = {} 31 | 32 | zonedata.each_key do |domain| 33 | records_by_type = records_by_domain[domain.downcase] = {} 34 | txt_rr_synthesis = true 35 | zonedata[domain].each do |record_struct| 36 | if Hash === record_struct 37 | # TYPE => DATA 38 | type, data_struct = record_struct.each_pair.first 39 | 40 | if data_struct =~ /^(TIMEOUT|RCODE[1-5])$/ 41 | records_by_type[type] = data_struct 42 | elsif data_struct == 'NO-SYNTHESIS' and type == 'TXT' 43 | txt_rr_synthesis = false 44 | else 45 | record = nil 46 | if type == 'SPF' or type == 'TXT' 47 | if data_struct == 'NONE' 48 | txt_rr_synthesis = false 49 | next 50 | else 51 | data_struct = [data_struct] unless Array === data_struct 52 | if type == 'SPF' 53 | record = Resolv::DNS::Resource::IN::SPF.new(data_struct) 54 | else 55 | record = Resolv::DNS::Resource::IN::TXT.new(data_struct) 56 | end 57 | end 58 | elsif type == 'A' or type == 'AAAA' 59 | address = IP.new(data_struct).to_s 60 | if type == 'A' 61 | record = Resolv::DNS::Resource::IN::A.new(address) 62 | else 63 | record = Resolv::DNS::Resource::IN::AAAA.new(address) 64 | end 65 | elsif type == 'MX' 66 | record = Resolv::DNS::Resource::IN::MX.new( 67 | data_struct[0], data_struct[1] 68 | ) 69 | elsif type == 'PTR' 70 | record = Resolv::DNS::Resource::IN::PTR.new(data_struct) 71 | elsif type == 'CNAME' 72 | record = Resolv::DNS::Resource::IN::CNAME.new(data_struct) 73 | else 74 | # Unexpected RR type! 75 | raise ArgumentError, "Unexpected RR type '#{type}' in zonedata" 76 | end 77 | raise Exception, 'nil record!' unless record 78 | (records_by_type[type] ||= []) << record 79 | records << record 80 | end 81 | elsif String === record_struct 82 | # TIMEOUT, RCODE#, NO-TXT-SYNTHESIS 83 | if record_struct =~ /^(TIMEOUT|RCODE[1-5])$/ 84 | records_by_type['ANY'] = record_struct 85 | elsif record_struct == 'NO-TXT-SYNTHESIS' 86 | txt_rr_synthesis = false 87 | else 88 | raise ArgumentError, 'Unexpected record token' 89 | end 90 | else 91 | raise ArgumentError, 'Unexpected record structure' 92 | end 93 | end 94 | 95 | # TXT RR synthesis: 96 | if ( 97 | txt_rr_synthesis and 98 | records_by_type.has_key?('SPF') and 99 | not records_by_type.has_key?('TXT') 100 | ) 101 | records_by_type['SPF'].each do |spf_record| 102 | txt_record = Resolv::DNS::Resource::IN::TXT.new(*spf_record.strings) 103 | records_by_type['TXT'] ||= [] 104 | records_by_type['TXT'] << txt_record 105 | end 106 | end 107 | end 108 | 109 | return obj 110 | end 111 | 112 | def self.new_from_yaml(yaml_struct, options = {}) 113 | return self.new_from_yaml_struct(yaml_struct) 114 | end 115 | 116 | def as_yaml 117 | raw_yaml_data = { 118 | description: @description, 119 | tests: @tests, 120 | zonedata: @zonedata 121 | } 122 | return YAML.dump(raw_yaml_data) 123 | end 124 | 125 | def test_cases 126 | return @test_cases.values 127 | end 128 | 129 | def spec_refs(granularity) 130 | return @test_cases.map{|x| x.spec_refs(granularity)}.sort.uniq 131 | end 132 | 133 | def records_for_domain(domain, type) 134 | domain = domain.sub(/^\./, '') 135 | domain = domain.sub(/\.$/, '') 136 | type ||= 'ANY' 137 | 138 | recordset = @records_by_domain[domain] or return []; # Uknown domain. 139 | 140 | # ANY queries are unsupported, return RCODE 4 ("not implemented"): 141 | return 'RCODE4' if type == 'ANY' 142 | 143 | # Use TIMEOUT/RCODE#/RRs entry meant for requested type: 144 | return recordset[type] if recordset[type] 145 | 146 | # Use TIMEOUT/RCODE#/RRs meant for any type: 147 | return recordset['ANY'] if recordset['ANY'] 148 | 149 | return [] 150 | end 151 | end 152 | end -------------------------------------------------------------------------------- /lib/spf/util.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | require 'ip' 3 | require 'socket' 4 | 5 | require 'spf/error' 6 | 7 | # 8 | # == SPF utility class 9 | # 10 | 11 | # Interface: 12 | # ############################################################################## 13 | 14 | 15 | # == SYNOPSIS 16 | # 17 | # require 'spf/util' 18 | # 19 | # 20 | # hostname = SPF::Util.hostname 21 | # 22 | # ipv6_address_v4mapped = SPF::Util.ipv4_address_to_ipv6(ipv4_address) 23 | # 24 | # ipv4_address = SPF::Util->ipv6_address_to_ipv4($ipv6_address_v4mapped) 25 | # 26 | # is_v4mapped = SPF::Util->ipv6_address_is_ipv4_mapped(ipv6_address) 27 | # 28 | # ip_address_string = SPF::Util->ip_address_to_string(ip_address) 29 | # 30 | # reverse_name = SPF::Util->ip_address_reverse(ip_address) 31 | # 32 | # validated_domain = SPF::Util->valid_domain_for_ip_address( 33 | # spf_server, request, ip_address, domain, 34 | # find_best_match, # Defaults to false 35 | # accept_any_domain # Defaults to false 36 | # ) 37 | # sanitized_string = SPF::Util->sanitize_string(string) 38 | # 39 | 40 | module SPF 41 | module Util 42 | 43 | def self.ipv4_mapped_ipv6_address_pattern 44 | /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})/i 45 | end 46 | 47 | def self.hostname 48 | return @hostname ||= Addrinfo.getaddrinfo(Socket.gethostname, nil, nil, nil, nil, Socket::AI_CANONNAME).first.canonname 49 | rescue SocketError 50 | return @hostname ||= Socket.gethostname 51 | end 52 | 53 | def self.ipv4_address_to_ipv6(ipv4_address) 54 | unless IP::V4 === ipv4_address 55 | raise SPF::InvalidOptionValueError.new('IP::V4 address expected') 56 | end 57 | return IP.new("::ffff:#{ipv4_address.to_addr}/#{ipv4_address.pfxlen - 32 + 128}") 58 | end 59 | 60 | def self.ipv6_address_to_ipv4(ipv6_address) 61 | unless IP::V6 === ipv6_address and ipv6_address.ipv4_mapped? 62 | raise SPF::InvalidOptionValueError, 'IPv4-mapped IP::V6 address expected' 63 | end 64 | return ipv6_address.native 65 | end 66 | 67 | def self.ipv6_address_is_ipv4_mapped(ipv6_address) 68 | return ipv6_address.ipv4_mapped? 69 | end 70 | 71 | def self.ip_address_to_string(ip_address) 72 | unless IP::V4 === ip_address or IP::V6 === ip_address 73 | raise SPF::InvalidOptionValueError.new('IP::V4 or IP::V6 address expected') 74 | end 75 | return ip_address.to_addr 76 | end 77 | 78 | def self.ip_address_reverse(ip_address) 79 | unless IP::V4 === ip_address or IP::V6 === ip_address 80 | raise SPF::InvalidOptionValueError.new('IP::V4 or IP::V6 address expected') 81 | end 82 | # Treat IPv4-mapped IPv6 addresses as IPv4 addresses: 83 | ip_address = ipv6_address_to_ipv4(ip_address) if ip_address.ipv4_mapped? 84 | case ip_address 85 | when IP::V4 86 | octets = ip_address.to_addr.split('.').first(ip_address.pfxlen / 8) 87 | return "#{octets .reverse.join('.')}.in-addr.arpa." 88 | when IP::V6 89 | nibbles = ip_address.to_hex .split('') .first(ip_address.pfxlen / 4) 90 | return "#{nibbles.reverse.join('.')}.ip6.arpa." 91 | end 92 | end 93 | 94 | def self.valid_domain_for_ip_address( 95 | sever, request, ip_address, domain, 96 | find_best_match = false, 97 | accept_any_domain = false 98 | ) 99 | # TODO 100 | end 101 | 102 | def self.sanitize_string(string) 103 | return \ 104 | string && 105 | string. 106 | gsub(/([\x00-\x1f\x7f-\xff])/) { |c| sprintf('\x%02x', c.ord) }. 107 | gsub(/([\u{0100}-\u{ffff}])/) { |u| sprintf('\x{%04x}', u.ord) } 108 | end 109 | 110 | end 111 | end 112 | 113 | # vim:sw=2 sts=2 114 | -------------------------------------------------------------------------------- /lib/spf/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | module SPF 3 | VERSION = '0.1.1' 4 | end 5 | -------------------------------------------------------------------------------- /spec/macrostring_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe SPF::MacroString do 4 | before(:each) do 5 | @request = SPF::Request.new( 6 | identity: 'strong-bad@email.example.com', 7 | ip_address: IP.new('192.0.2.3') 8 | ) 9 | @server = SPF::Server.new 10 | end 11 | 12 | # RFC 4408, 8.1/26 13 | 14 | describe '#expand' do 15 | context 'given a valid macro string' do 16 | it 'expands the "s" macro letter to ' do 17 | macro_str = described_class.new( 18 | text: '%{s}', 19 | request: @request, 20 | server: @server 21 | ) 22 | expanded = macro_str.expand 23 | expect(expanded).to eq('strong-bad@email.example.com') 24 | end 25 | 26 | it 'expands the "l" macro letter to local-part of ' do 27 | macro_str = described_class.new( 28 | text: '%{l}', 29 | request: @request, 30 | server: @server 31 | ) 32 | expanded = macro_str.expand 33 | expect(expanded).to eq('strong-bad') 34 | end 35 | 36 | it 'expands the "o" macro letter to domain of ' do 37 | macro_str = described_class.new( 38 | text: '%{o}', 39 | request: @request, 40 | server: @server 41 | ) 42 | expanded = macro_str.expand 43 | expect(expanded).to eq('email.example.com') 44 | end 45 | 46 | it 'expands the "d" macro letter to ' do 47 | macro_str = described_class.new( 48 | text: '%{d}', 49 | request: @request, 50 | server: @server 51 | ) 52 | expanded = macro_str.expand 53 | expect(expanded).to eq('email.example.com') 54 | end 55 | 56 | it 'expands the "i" macro letter to ' do 57 | macro_str = described_class.new( 58 | text: '%{i}', 59 | request: @request, 60 | server: @server 61 | ) 62 | expanded = macro_str.expand 63 | expect(expanded).to eq('192.0.2.3') 64 | end 65 | 66 | it 'does not expand the "p" macro letter' do 67 | macro_str = described_class.new( 68 | text: '%{p}', 69 | request: @request, 70 | server: @server 71 | ) 72 | expanded = macro_str.expand 73 | expect(expanded).to eq('%{p}') 74 | end 75 | 76 | it 'does not expand the "p" macro letter with transformers and delimiters' do 77 | macro_str = described_class.new( 78 | text: 'spamhaus.%{p1r+}.example.org', 79 | request: @request, 80 | server: @server 81 | ) 82 | expanded = macro_str.expand 83 | expect(expanded).to eq('spamhaus.%{p1r+}.example.org') 84 | end 85 | 86 | it 'expands the "v" macro letter to the string "in-addr" if is ipv4' do 87 | macro_str = described_class.new( 88 | text: '%{v}', 89 | request: @request, 90 | server: @server 91 | ) 92 | expanded = macro_str.expand 93 | expect(expanded).to eq('in-addr') 94 | end 95 | 96 | it 'expands the "v" macro letter to the string "ip6" if is ipv6' do 97 | request = SPF::Request.new( 98 | identity: 'strong-bad@email.example.com', 99 | ip_address: IP.new('2001:DB8::CB01') 100 | ) 101 | macro_str = described_class.new( 102 | text: '%{v}', 103 | request: request, 104 | server: @server 105 | ) 106 | expanded = macro_str.expand 107 | expect(expanded).to eq('ip6') 108 | end 109 | 110 | it 'expands the "h" macro letter to HELO/EHLO domain' do 111 | request = SPF::Request.new( 112 | identity: 'strong-bad@email.example.com', 113 | ip_address: '192.0.2.3', 114 | helo_identity: 'helo' 115 | ) 116 | macro_str = described_class.new( 117 | text: '%{h}', 118 | request: request, 119 | server: @server 120 | ) 121 | expanded = macro_str.expand 122 | expect(expanded).to eq('helo') 123 | end 124 | 125 | it 'expands the "c" macro letter to SMTP client IP (easily readable format)' do 126 | macro_str = described_class.new( 127 | text: '%{c}', 128 | request: @request, 129 | server: @server, 130 | is_explanation: true 131 | ) 132 | expanded = macro_str.expand 133 | expect(expanded).to eq('192.0.2.3') 134 | end 135 | 136 | it 'expands the "r" macro letter to domain name of host performing the check' do 137 | server = SPF::Server.new( 138 | hostname: 'hostname' 139 | ) 140 | macro_str = described_class.new( 141 | text: '%{r}', 142 | request: @request, 143 | server: server, 144 | is_explanation: true 145 | ) 146 | expanded = macro_str.expand 147 | expect(expanded).to eq('hostname') 148 | end 149 | 150 | it 'expands the "t" macro letter to current timestamp' do 151 | time_now = Time.now 152 | allow(Time).to receive(:now).and_return(time_now) 153 | macro_str = described_class.new( 154 | text: '%{t}', 155 | request: @request, 156 | server: @server, 157 | is_explanation: true 158 | ) 159 | expanded = macro_str.expand 160 | expect(expanded).to eq(time_now.to_i.to_s) 161 | end 162 | 163 | # Examples from RFC 4408 8.2/30 164 | 165 | it 'expands "%{ir}.%{v}._spf.%{d2}" correctly' do 166 | macro_str = described_class.new( 167 | text: '%{ir}.%{v}._spf.%{d2}', 168 | request: @request, 169 | server: @server 170 | ) 171 | expanded = macro_str.expand 172 | expect(expanded).to eq('3.2.0.192.in-addr._spf.example.com') 173 | end 174 | 175 | it 'expands "%{lr-}.lp._spf.%{d2}" correctly' do 176 | macro_str = described_class.new( 177 | text: '%{lr-}.lp._spf.%{d2}', 178 | request: @request, 179 | server: @server 180 | ) 181 | expanded = macro_str.expand 182 | expect(expanded).to eq('bad.strong.lp._spf.example.com') 183 | end 184 | 185 | it 'expands "%{lr-}.lp.%{ir}.%{v}._spf.%{d2}" correctly' do 186 | macro_str = described_class.new( 187 | text: '%{lr-}.lp.%{ir}.%{v}._spf.%{d2}', 188 | request: @request, 189 | server: @server 190 | ) 191 | expanded = macro_str.expand 192 | expect(expanded).to eq('bad.strong.lp.3.2.0.192.in-addr._spf.example.com') 193 | end 194 | 195 | it 'expands "%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}" correctly' do 196 | macro_str = described_class.new( 197 | text: '%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}', 198 | request: @request, 199 | server: @server 200 | ) 201 | expanded = macro_str.expand 202 | expect(expanded).to eq('3.2.0.192.in-addr.strong.lp._spf.example.com') 203 | end 204 | 205 | it 'expands "%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}" correctly' do 206 | macro_str = described_class.new( 207 | text: '%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}', 208 | request: @request, 209 | server: @server 210 | ) 211 | expanded = macro_str.expand 212 | expect(expanded).to eq('3.2.0.192.in-addr.strong.lp._spf.example.com') 213 | end 214 | 215 | it 'expands "%{ir}.%{v}._spf.%{d2}" correctly with an IPv6 request' do 216 | request = SPF::Request.new( 217 | identity: 'strong-bad@email.example.com', 218 | ip_address: IP.new('2001:DB8::CB01') 219 | ) 220 | macro_str = described_class.new( 221 | text: '%{ir}.%{v}._spf.%{d2}', 222 | request: request, 223 | server: @server 224 | ) 225 | expanded = macro_str.expand 226 | expect(expanded).to eq('1.0.B.C.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.B.D.0.1.0.0.2.ip6._spf.example.com') 227 | end 228 | end 229 | 230 | context 'given an invalid macro string' do 231 | context 'A "%"" character not followed by a "{", "%", "-", or "_" character' do 232 | it 'returns an "InvalidMacroStringError"' do 233 | macro_str = described_class.new( 234 | text: '-exists:%(ir).sbl.spamhaus.example.org', 235 | request: @request, 236 | server: @server 237 | ) 238 | expect { macro_str.expand }.to raise_error(SPF::InvalidMacroStringError) 239 | end 240 | end 241 | 242 | context 'A non-allowed macro letter' do 243 | it 'returns an "InvalidMacroStringError"' do 244 | macro_str = described_class.new( 245 | text: '%{z}.sbl.spamhaus.example.org', 246 | request: @request, 247 | server: @server 248 | ) 249 | expect { macro_str.expand }.to raise_error(SPF::InvalidMacroStringError) 250 | end 251 | end 252 | 253 | context 'A macro expression without a closing bracket' do 254 | it 'returns an "InvalidMacroStringError"' do 255 | macro_str = described_class.new( 256 | text: '%{i.sbl.spamhaus.example.org', 257 | request: @request, 258 | server: @server 259 | ) 260 | expect { macro_str.expand }.to raise_error(SPF::InvalidMacroStringError) 261 | end 262 | end 263 | end 264 | end 265 | end 266 | -------------------------------------------------------------------------------- /spec/mech_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'resolv_programmable' 3 | 4 | describe SPF::Mech::Exists do 5 | before(:all) do 6 | @request = SPF::Request.new ({ 7 | identity: 'somebody@example.com' 8 | }) 9 | end 10 | 11 | describe '#match' do 12 | context 'when an A record is found' do 13 | test_resolver_1 = Resolv::DNS::Programmable.new ({ 14 | records: { 15 | 'example.com' => [ 16 | Resolv::DNS::Resource::IN::A.new('192.168.0.1') 17 | ] 18 | } 19 | }) 20 | 21 | server = SPF::Server.new( 22 | dns_resolver: test_resolver_1 23 | ) 24 | 25 | mech = SPF::Mech::Exists.new ({ 26 | text: 'example.com', 27 | server: server, 28 | request: @request 29 | }) 30 | 31 | it 'returns true' do 32 | mech_match = mech.match(server, @request) 33 | expect(mech_match).to be_truthy 34 | end 35 | end 36 | 37 | context 'when an A record is not found' do 38 | test_resolver_empty = Resolv::DNS::Programmable.new ({ 39 | records: {} 40 | }) 41 | 42 | server = SPF::Server.new( 43 | dns_resolver: test_resolver_empty 44 | ) 45 | 46 | mech = SPF::Mech::Exists.new ({ 47 | text: 'example.com', 48 | server: server, 49 | request: @request 50 | }) 51 | 52 | it 'returns false' do 53 | mech_match = mech.match(server, @request) 54 | expect(mech_match).to be_falsy 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/request_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | 3 | describe 'SPF::Request instantiation' do 4 | request = SPF::Request.new( 5 | versions: [1, 2], 6 | scope: 'mfrom', 7 | identity: 'fred@example.com', 8 | ip_address: '192.168.0.1', 9 | helo_identity: 'mta.example.com', 10 | ) 11 | 12 | it 'is a basic request object' do 13 | expect(request.is_a?(SPF::Request)).to be_truthy 14 | end 15 | 16 | it 'has correct request versions' do 17 | expect(request.versions).to eq [1, 2] 18 | end 19 | 20 | it 'has correct scope' do 21 | expect(request.scope).to eq :mfrom 22 | end 23 | 24 | it 'has correct authority_domain' do 25 | expect(request.authority_domain).to eq 'example.com' 26 | end 27 | 28 | it 'has correct identity' do 29 | expect(request.identity).to eq 'fred@example.com' 30 | end 31 | 32 | it 'has correct domain' do 33 | expect(request.domain).to eq 'example.com' 34 | end 35 | 36 | it 'has correct localpart' do 37 | expect(request.localpart).to eq 'fred' 38 | end 39 | 40 | it 'has correct ip address' do 41 | expect(request.ip_address.is_a?(IP)).to be_truthy 42 | expect(request.ip_address).to eq IP.new('192.168.0.1') 43 | end 44 | 45 | it 'has correct helo identity' do 46 | expect(request.helo_identity).to eq 'mta.example.com' 47 | end 48 | 49 | it 'creates sub-request object' do 50 | clone = request.new_sub_request(ip_address: '192.168.0.254') 51 | expect(clone.identity).to eq 'fred@example.com' 52 | expect(clone.ip_address).to eq IP.new('192.168.0.254') 53 | end 54 | end 55 | 56 | describe 'SPF::Request minimally parameterized MAIL FROM request' do 57 | request = SPF::Request.new( 58 | identity: 'fred@example.com', 59 | ip_address: '192.168.0.1' 60 | ) 61 | 62 | it 'is an SPF::Request object' do 63 | expect(request.is_a?(SPF::Request)).to be_truthy 64 | end 65 | 66 | it 'has correct versions' do 67 | expect(request.versions).to eq [1, 2] 68 | end 69 | 70 | it 'has correct scope' do 71 | expect(request.scope).to eq :mfrom 72 | end 73 | 74 | it 'has correct authority_domain' do 75 | expect(request.authority_domain).to eq 'example.com' 76 | end 77 | 78 | it 'has no helo_identity' do 79 | expect(request.helo_identity).to be nil 80 | end 81 | end 82 | 83 | describe 'SPF::Request minimally parameterized HELO request' do 84 | request = SPF::Request.new( 85 | scope: 'helo', 86 | identity: 'mta.example.com', 87 | ip_address: '192.168.0.1' 88 | ) 89 | 90 | it 'is an SPF::Request object' do 91 | expect(request.is_a?(SPF::Request)).to be_truthy 92 | end 93 | 94 | it 'has correct versions' do 95 | expect(request.versions).to eq [1] 96 | end 97 | 98 | it 'has correct scope' do 99 | expect(request.scope).to eq :helo 100 | end 101 | 102 | it 'has correct authority_domain' do 103 | expect(request.authority_domain).to eq 'mta.example.com' 104 | end 105 | 106 | it 'has correct helo_identity' do 107 | expect(request.helo_identity).to eq 'mta.example.com' 108 | end 109 | end 110 | 111 | describe 'versions validation' do 112 | 113 | it 'supports versions => int' do 114 | request = SPF::Request.new( 115 | versions: 1, 116 | identity: 'fred@example.com', 117 | ip_address: '192.168.0.1' 118 | ) 119 | expect(request.versions).to eq [1] 120 | end 121 | 122 | it 'raises error on invalid versions type' do 123 | expect { 124 | request = SPF::Request.new ( { 125 | versions: {}, # Illegal versions type! 126 | identity: 'fred@example.com', 127 | ip_address: '192.168.0.1' 128 | }) 129 | }.to raise_error(SPF::InvalidOptionValueError) 130 | end 131 | 132 | it 'detects illegal versions' do 133 | expect { 134 | request = SPF::Request.new ( { 135 | versions: [1, 666], # Illegal versions number! 136 | identity: 'fred@example.com', 137 | ip_address: '192.168.0.1' 138 | }) 139 | }.to raise_error(SPF::InvalidOptionValueError) 140 | end 141 | 142 | it 'drops versions irrelevant for scope' do 143 | request = SPF::Request.new ({ 144 | versions: [1, 2], 145 | scope: :helo, 146 | identity: 'mta.example.com', 147 | ip_address: '192.168.0.1', 148 | }) 149 | expect(request.versions).to eq [1] 150 | end 151 | end 152 | 153 | describe 'scope validation' do 154 | it 'detects invalid scope' do 155 | expect { 156 | SPF::Request.new( 157 | scope: :foo, 158 | identity: 'fred@example.com', 159 | ip_address: '192.168.0.1', 160 | ) 161 | }.to raise_error(SPF::InvalidScopeError) 162 | end 163 | 164 | it'detects invalid scope for versions' do 165 | expect { 166 | SPF::Request.new( 167 | scope: :pra, 168 | versions: 1, 169 | identity: 'fred@example.com', 170 | ip_address: '192.168.0.1', 171 | ) 172 | }.to raise_error(SPF::InvalidScopeError) 173 | end 174 | end 175 | 176 | describe 'identity validation' do 177 | it 'detects missing identity option' do 178 | expect { 179 | SPF::Request.new( 180 | ip_address: '192.168.0.1', 181 | ) 182 | }.to raise_error(SPF::OptionRequiredError) 183 | end 184 | 185 | request = SPF::Request.new( 186 | scope: :mfrom, 187 | identity: 'mta.example.com', 188 | ip_address: '192.168.0.1', 189 | ) 190 | 191 | it 'extracts domain from identity correctly' do 192 | expect(request.domain).to eq 'mta.example.com' 193 | end 194 | 195 | it 'has default "postmaster" localpart' do 196 | expect(request.localpart).to eq 'postmaster' 197 | end 198 | end 199 | 200 | describe 'IP address validation' do 201 | 202 | it 'accepts IP object for ip_address' do 203 | ip_address = IP.new('192.168.0.1') 204 | request = SPF::Request.new( 205 | identity: 'fred@example.com', 206 | ip_address: ip_address 207 | ) 208 | expect(request.ip_address).to be ip_address 209 | end 210 | 211 | it 'treats IPv4-mapped IPv6 address as IPv4 address' do 212 | request = SPF::Request.new( 213 | identity: 'fred@example.com', 214 | ip_address: '::ffff:192.168.0.1' 215 | ) 216 | expect(request.ip_address).to eq IP.new('192.168.0.1') 217 | end 218 | end 219 | 220 | describe 'custom request state' do 221 | request = SPF::Request.new( 222 | identity: 'fred@example.com', 223 | ip_address: '192.168.0.1', 224 | ) 225 | 226 | it 'reads uninitialized state field' do 227 | expect(request.state('uninitialized')).to be nil 228 | end 229 | 230 | it 'writes and reads state field' do 231 | request.state('foo', 'bar') 232 | expect(request.state('foo')).to eq 'bar' 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /spec/resolv_programmable.rb: -------------------------------------------------------------------------------- 1 | require 'resolv' 2 | 3 | class Resolv::DNS 4 | class Programmable < Resolv::DNS 5 | def initialize(config_info = nil, &resolver_code) 6 | super 7 | config_info ||= {} 8 | @records = config_info[:records] || {} 9 | @resolver_code = config_info[:resolver_code] || resolver_code 10 | if not @records and not @resolver_code 11 | raise ArgumentError, "Either :records option or resolver code specified as block or :resolver_code option required" 12 | end 13 | if not @records and @resolver_code and not @resolver_code.respond_to?(:call) 14 | raise ArgumentError, "Resolver code not callable" 15 | end 16 | @dns_fallback = config_info[:dns_fallback] || false 17 | end 18 | 19 | def each_resource(name, typeclass, &proc) 20 | lazy_initialize 21 | name = Name.create(name).to_s # Validate/normalize name argument. 22 | 23 | rcode, answers = nil, [] 24 | 25 | # First, call resolver code, if any: 26 | if @resolver_code 27 | rcode, answers = @resolver_code.call(name, typeclass) 28 | answers ||= [] 29 | end 30 | 31 | # Second, get records from records hash, if any: 32 | if not rcode and @records 33 | records_for_name = @records[name] 34 | case records_for_name 35 | when nil 36 | # No records defined. Do nothing. 37 | when Integer 38 | # RCODE. 39 | rcode, answers = records_for_name, [] 40 | when Array 41 | # Answer records. 42 | records_for_name = records_for_name.dup 43 | records_for_name.select! { |resource| resource.class == typeclass } \ 44 | unless typeclass == Resolv::DNS::Resource::IN::ANY 45 | if records_for_name.any? 46 | rcode, answers = RCode::NoError, answers + records_for_name 47 | end 48 | else 49 | raise ArgumentError, "Value in :records option hash must be one of nil, RCode::*, or Array of Resolv::DNS::Resource; got: #{records_for_name.inspect}" 50 | end 51 | end 52 | 53 | # Third, fall back to DNS resolution, if allowed: 54 | if not rcode and @dns_fallback 55 | return super 56 | end 57 | 58 | rcode ||= answers.any? ? RCode::NoError : RCode::NXDomain 59 | 60 | case rcode 61 | when RCode::NoError 62 | # Synthesize reply for consumption by extract_resources: 63 | reply = Message.new 64 | answers.each do |resource| 65 | reply.add_answer(name, nil, resource) 66 | end 67 | extract_resources(reply, name, typeclass, &proc) 68 | return 69 | when RCode::NXDomain 70 | @config.resolv(name) do # Give Resolv::DNS::Config#resolv a chance to handle the exception. 71 | raise Config::NXDomain.new(name.to_s) 72 | end 73 | else 74 | @config.resolv(name) do # Give Resolv::DNS::Config#resolv a chance to handle the exception. 75 | raise Config::OtherResolvError.new(name.to_s) 76 | end 77 | end 78 | end 79 | end 80 | end 81 | 82 | # vim:sw=2 sts=2 83 | -------------------------------------------------------------------------------- /spec/result_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | 3 | describe 'basic instantiation' do 4 | result = SPF::Result.new(['dummy server', 'dummy request', 'result text']) 5 | it 'creates basic result server' do 6 | expect(result.server).to eq 'dummy server' 7 | end 8 | it 'creates basic result request' do 9 | expect(result.request).to eq 'dummy request' 10 | end 11 | it 'creates basic result text' do 12 | expect(result.result_text).to eq 'result text' 13 | end 14 | end 15 | 16 | describe 'isa_by_name' do 17 | result = SPF::Result::Pass.new(['dummy server', 'dummy request']) 18 | it 'should be a pass object' do 19 | expect(result.isa_by_name('PaSs')).to be true 20 | end 21 | it 'should not be a foo object' do 22 | expect(result.isa_by_name('foo')).to be false 23 | end 24 | end 25 | 26 | describe 'is_code' do 27 | result = SPF::Result::Pass.new(['dummy server', 'dummy request']) 28 | it 'should be a pass object' do 29 | expect(result.is_code('PaSs')).to be true 30 | end 31 | it 'should not be a foo object' do 32 | expect(result.is_code('foo')).to be false 33 | end 34 | end 35 | 36 | describe 'NeutralByDefault' do 37 | result = SPF::Result::NeutralByDefault.new(['dummy server', 'dummy request']) 38 | it 'should have neutral result code' do 39 | expect(result.code).to eq :neutral 40 | end 41 | it 'should be a neutral-by-default' do 42 | expect(result.isa_by_name('neutral_by_default')).to be true 43 | end 44 | it 'should be a neutral' do 45 | expect(result.isa_by_name('neutral')).to be true 46 | end 47 | end -------------------------------------------------------------------------------- /spec/rfc4406-tests.yml: -------------------------------------------------------------------------------- 1 | # RFC 4406 test-suite (version 2006.11) 2 | # 3 | # (C) 2006 Julian Mehnle 4 | # $Id: rfc4406-tests.yml 30 2006-11-27 19:55:10Z Julian Mehnle $ 5 | # 6 | # vim:sw=2 sts=2 7 | --- 8 | description: Selecting records 9 | tests: 10 | v2-preferred-over-v1: 11 | description: >- 12 | "spf2.0" records ought to be preferred over "v=spf1" records. 13 | spec: 4.4/6 14 | helo: mail.example.com 15 | host: 1.2.3.4 16 | mailfrom: foo@v2+v1.example.com 17 | result: fail 18 | redundant-v2: 19 | description: >- 20 | Redundant "spf2.0" records must cause a PermError. 21 | spec: 4.4/8 22 | helo: mail.example.com 23 | host: 1.2.3.4 24 | mailfrom: foo@v2+v2+v1.example.com 25 | result: permerror 26 | zonedata: 27 | v2+v1.example.com: 28 | - SPF: spf2.0/mfrom -all 29 | - SPF: v=spf1 +all 30 | v2+v2+v1.example.com: 31 | - SPF: spf2.0/mfrom -all 32 | - SPF: spf2.0/mfrom,pra -all 33 | - SPF: v=spf1 -all 34 | -------------------------------------------------------------------------------- /spec/rfc4408-tests.yml: -------------------------------------------------------------------------------- 1 | # This is the openspf.org test suite (release 2009.10) based on RFC 4408. 2 | # http://www.openspf.org/Test_Suite 3 | # 4 | # $Id: rfc4408-tests.yml 108 2009-10-31 19:51:18Z Julian Mehnle $ 5 | # vim:sw=2 sts=2 et 6 | # 7 | # See rfc4408-tests.CHANGES for a changelog. 8 | # 9 | # Contributors: 10 | # Stuart D Gathman 90% of the tests 11 | # Julian Mehnle some tests, proofread YAML syntax, formal schema 12 | # Frank Ellermann 13 | # Scott Kitterman 14 | # Wayne Schlitt 15 | # Craig Whitmore 16 | # Norman Maurer 17 | # Mark Shewmaker 18 | # Philip Gladstone 19 | # 20 | --- 21 | description: Initial processing 22 | tests: 23 | toolonglabel: 24 | description: >- 25 | DNS labels limited to 63 chars. 26 | comment: >- 27 | For initial processing, a long label results in None, not TempError 28 | spec: 4.3/1 29 | helo: mail.example.net 30 | host: 1.2.3.5 31 | mailfrom: lyme.eater@A123456789012345678901234567890123456789012345678901234567890123.example.com 32 | result: none 33 | longlabel: 34 | description: >- 35 | DNS labels limited to 63 chars. 36 | spec: 4.3/1 37 | helo: mail.example.net 38 | host: 1.2.3.5 39 | mailfrom: lyme.eater@A12345678901234567890123456789012345678901234567890123456789012.example.com 40 | result: fail 41 | emptylabel: 42 | spec: 4.3/1 43 | helo: mail.example.net 44 | host: 1.2.3.5 45 | mailfrom: lyme.eater@A...example.com 46 | result: none 47 | helo-not-fqdn: 48 | spec: 4.3/1 49 | helo: A2345678 50 | host: 1.2.3.5 51 | mailfrom: "" 52 | result: none 53 | helo-domain-literal: 54 | spec: 4.3/1 55 | helo: "[1.2.3.5]" 56 | host: 1.2.3.5 57 | mailfrom: "" 58 | result: none 59 | nolocalpart: 60 | spec: 4.3/2 61 | helo: mail.example.net 62 | host: 1.2.3.4 63 | mailfrom: '@example.net' 64 | result: fail 65 | explanation: postmaster 66 | domain-literal: 67 | spec: 4.3/1 68 | helo: OEMCOMPUTER 69 | host: 1.2.3.5 70 | mailfrom: "foo@[1.2.3.5]" 71 | result: none 72 | zonedata: 73 | example.com: 74 | - TIMEOUT 75 | example.net: 76 | - SPF: v=spf1 -all exp=exp.example.net 77 | a.example.net: 78 | - SPF: v=spf1 -all exp=exp.example.net 79 | exp.example.net: 80 | - TXT: '%{l}' 81 | a12345678901234567890123456789012345678901234567890123456789012.example.com: 82 | - SPF: v=spf1 -all 83 | --- 84 | description: Record lookup 85 | tests: 86 | both: 87 | spec: 4.4/1 88 | helo: mail.example.net 89 | host: 1.2.3.4 90 | mailfrom: foo@both.example.net 91 | result: fail 92 | txtonly: 93 | description: Result is none if checking SPF records only. 94 | spec: 4.4/1 95 | helo: mail.example.net 96 | host: 1.2.3.4 97 | mailfrom: foo@txtonly.example.net 98 | result: [fail, none] 99 | spfonly: 100 | description: Result is none if checking TXT records only. 101 | spec: 4.4/1 102 | helo: mail.example.net 103 | host: 1.2.3.4 104 | mailfrom: foo@spfonly.example.net 105 | result: [fail, none] 106 | spftimeout: 107 | description: >- 108 | TXT record present, but SPF lookup times out. 109 | Result is temperror if checking SPF records only. 110 | comment: >- 111 | This actually happens for a popular braindead DNS server. 112 | spec: 4.4/1 113 | helo: mail.example.net 114 | host: 1.2.3.4 115 | mailfrom: foo@spftimeout.example.net 116 | result: [fail, temperror] 117 | txttimeout: 118 | description: >- 119 | SPF record present, but TXT lookup times out. 120 | If only TXT records are checked, result is temperror. 121 | spec: 4.4/1 122 | helo: mail.example.net 123 | host: 1.2.3.4 124 | mailfrom: foo@txttimeout.example.net 125 | result: [fail, temperror] 126 | nospftxttimeout: 127 | description: >- 128 | No SPF record present, and TXT lookup times out. 129 | If only TXT records are checked, result is temperror. 130 | comment: >- 131 | Because TXT records is where v=spf1 records will likely be, returning 132 | temperror will try again later. A timeout due to a braindead server 133 | is unlikely in the case of TXT, as opposed to the newer SPF RR. 134 | spec: 4.4/1 135 | helo: mail.example.net 136 | host: 1.2.3.4 137 | mailfrom: foo@nospftxttimeout.example.net 138 | result: [temperror, none] 139 | alltimeout: 140 | description: Both TXT and SPF queries time out 141 | spec: 4.4/2 142 | helo: mail.example.net 143 | host: 1.2.3.4 144 | mailfrom: foo@alltimeout.example.net 145 | result: temperror 146 | zonedata: 147 | both.example.net: 148 | - TXT: v=spf1 -all 149 | - SPF: v=spf1 -all 150 | txtonly.example.net: 151 | - TXT: v=spf1 -all 152 | spfonly.example.net: 153 | - SPF: v=spf1 -all 154 | - TXT: NONE 155 | spftimeout.example.net: 156 | - TXT: v=spf1 -all 157 | - TIMEOUT 158 | txttimeout.example.net: 159 | - SPF: v=spf1 -all 160 | - TXT: NONE 161 | - TIMEOUT 162 | nospftxttimeout.example.net: 163 | - SPF: "v=spf3 !a:yahoo.com -all" 164 | - TXT: NONE 165 | - TIMEOUT 166 | alltimeout.example.net: 167 | - TIMEOUT 168 | --- 169 | description: Selecting records 170 | tests: 171 | nospace1: 172 | description: >- 173 | Version must be terminated by space or end of record. TXT pieces 174 | are joined without intervening spaces. 175 | spec: 4.5/4 176 | helo: mail.example1.com 177 | host: 1.2.3.4 178 | mailfrom: foo@example2.com 179 | result: none 180 | empty: 181 | description: Empty SPF record. 182 | spec: 4.5/4 183 | helo: mail1.example1.com 184 | host: 1.2.3.4 185 | mailfrom: foo@example1.com 186 | result: neutral 187 | nospace2: 188 | spec: 4.5/4 189 | helo: mail.example1.com 190 | host: 1.2.3.4 191 | mailfrom: foo@example3.com 192 | result: pass 193 | spfoverride: 194 | description: >- 195 | SPF records override TXT records. Older implementation may 196 | check TXT records only. 197 | spec: 4.5/5 198 | helo: mail.example1.com 199 | host: 1.2.3.4 200 | mailfrom: foo@example4.com 201 | result: [pass, fail] 202 | multitxt1: 203 | description: >- 204 | Older implementations will give permerror/unknown because of 205 | the conflicting TXT records. However, RFC 4408 says the SPF 206 | records overrides them. 207 | spec: 4.5/5 208 | helo: mail.example1.com 209 | host: 1.2.3.4 210 | mailfrom: foo@example5.com 211 | result: [pass, permerror] 212 | multitxt2: 213 | description: >- 214 | Multiple records is a permerror, v=spf1 is case insensitive 215 | comment: >- 216 | Implementations that query for only SPF-type RRs will acceptably yield 217 | "none". 218 | spec: 4.5/6 219 | helo: mail.example1.com 220 | host: 1.2.3.4 221 | mailfrom: foo@example6.com 222 | result: [permerror, none] 223 | multispf1: 224 | description: >- 225 | Multiple records is a permerror, even when they are identical. 226 | However, this situation cannot be reliably reproduced with live 227 | DNS since cache and resolvers are allowed to combine identical 228 | records. 229 | spec: 4.5/6 230 | helo: mail.example1.com 231 | host: 1.2.3.4 232 | mailfrom: foo@example7.com 233 | result: [permerror, fail] 234 | multispf2: 235 | description: >- 236 | Older implementations ignoring SPF-type records will give pass because 237 | there is a (single) TXT record. But RFC 4408 requires permerror because 238 | the SPF records override and there are more than one. 239 | spec: 4.5/6 240 | helo: mail.example1.com 241 | host: 1.2.3.4 242 | mailfrom: foo@example8.com 243 | result: [permerror, pass] 244 | nospf: 245 | spec: 4.5/7 246 | helo: mail.example1.com 247 | host: 1.2.3.4 248 | mailfrom: foo@mail.example1.com 249 | result: none 250 | case-insensitive: 251 | description: >- 252 | v=spf1 is case insensitive 253 | spec: 4.5/6 254 | helo: mail.example1.com 255 | host: 1.2.3.4 256 | mailfrom: foo@example9.com 257 | result: softfail 258 | zonedata: 259 | example3.com: 260 | - SPF: v=spf10 261 | - SPF: v=spf1 mx 262 | - MX: [0, mail.example1.com] 263 | example1.com: 264 | - SPF: v=spf1 265 | example2.com: 266 | - SPF: ['v=spf1', 'mx'] 267 | mail.example1.com: 268 | - A: 1.2.3.4 269 | example4.com: 270 | - SPF: v=spf1 +all 271 | - TXT: v=spf1 -all 272 | example5.com: 273 | - SPF: v=spf1 +all 274 | - TXT: v=spf1 -all 275 | - TXT: v=spf1 +all 276 | example6.com: 277 | - TXT: v=spf1 -all 278 | - TXT: V=sPf1 +all 279 | example7.com: 280 | - SPF: v=spf1 -all 281 | - SPF: v=spf1 -all 282 | example8.com: 283 | - SPF: V=spf1 -all 284 | - SPF: v=spf1 -all 285 | - TXT: v=spf1 +all 286 | example9.com: 287 | - SPF: v=SpF1 ~all 288 | --- 289 | description: Record evaluation 290 | tests: 291 | detect-errors-anywhere: 292 | description: Any syntax errors anywhere in the record MUST be detected. 293 | spec: 4.6 294 | helo: mail.example.com 295 | host: 1.2.3.4 296 | mailfrom: foo@t1.example.com 297 | result: permerror 298 | modifier-charset-good: 299 | description: name = ALPHA *( ALPHA / DIGIT / "-" / "_" / "." ) 300 | spec: 4.6.1/2 301 | helo: mail.example.com 302 | host: 1.2.3.4 303 | mailfrom: foo@t2.example.com 304 | result: pass 305 | modifier-charset-bad1: 306 | description: >- 307 | '=' character immediately after the name and before any ":" or "/" 308 | spec: 4.6.1/4 309 | helo: mail.example.com 310 | host: 1.2.3.4 311 | mailfrom: foo@t3.example.com 312 | result: permerror 313 | modifier-charset-bad2: 314 | description: >- 315 | '=' character immediately after the name and before any ":" or "/" 316 | spec: 4.6.1/4 317 | helo: mail.example.com 318 | host: 1.2.3.4 319 | mailfrom: foo@t4.example.com 320 | result: permerror 321 | redirect-after-mechanisms1: 322 | description: >- 323 | The "redirect" modifier has an effect after all the mechanisms. 324 | comment: >- 325 | The redirect in this example would violate processing limits, except 326 | that it is never used because of the all mechanism. 327 | spec: 4.6.3 328 | helo: mail.example.com 329 | host: 1.2.3.4 330 | mailfrom: foo@t5.example.com 331 | result: softfail 332 | redirect-after-mechanisms2: 333 | description: >- 334 | The "redirect" modifier has an effect after all the mechanisms. 335 | spec: 4.6.3 336 | helo: mail.example.com 337 | host: 1.2.3.5 338 | mailfrom: foo@t6.example.com 339 | result: fail 340 | default-result: 341 | description: Default result is neutral. 342 | spec: 4.7/1 343 | helo: mail.example.com 344 | host: 1.2.3.5 345 | mailfrom: foo@t7.example.com 346 | result: neutral 347 | redirect-is-modifier: 348 | description: |- 349 | Invalid mechanism. Redirect is a modifier. 350 | spec: 4.6.1/4 351 | helo: mail.example.com 352 | host: 1.2.3.4 353 | mailfrom: foo@t8.example.com 354 | result: permerror 355 | invalid-domain: 356 | description: >- 357 | Domain-spec must end in macro-expand or valid toplabel. 358 | spec: 8.1/2 359 | helo: mail.example.com 360 | host: 1.2.3.4 361 | mailfrom: foo@t9.example.com 362 | result: permerror 363 | invalid-domain-empty-label: 364 | description: >- 365 | target-name that is a valid domain-spec per RFC 4408 but an invalid 366 | domain name per RFC 1035 (empty label) must be treated as non-existent. 367 | comment: >- 368 | An empty domain label, i.e. two successive dots, in a mechanism 369 | target-name is valid domain-spec syntax, even though a DNS query cannot 370 | be composed from it. The spec being unclear about it, this could either 371 | be considered a syntax error, or, by analogy to 4.3/1 and 5/10/3, the 372 | mechanism chould be treated as a no-match. 373 | spec: [4.3/1, 5/10/3] 374 | helo: mail.example.com 375 | host: 1.2.3.4 376 | mailfrom: foo@t10.example.com 377 | result: [permerror, fail] 378 | invalid-domain-long: 379 | description: >- 380 | target-name that is a valid domain-spec per RFC 4408 but an invalid 381 | domain name per RFC 1035 (long label) must be treated as non-existent. 382 | comment: >- 383 | A domain label longer than 63 characters in a mechanism target-name is 384 | valid domain-spec syntax, even though a DNS query cannot be composed 385 | from it. The spec being unclear about it, this could either be 386 | considered a syntax error, or, by analogy to 4.3/1 and 5/10/3, the 387 | mechanism chould be treated as a no-match. 388 | spec: [4.3/1, 5/10/3] 389 | helo: mail.example.com 390 | host: 1.2.3.4 391 | mailfrom: foo@t11.example.com 392 | result: [permerror,fail] 393 | invalid-domain-long-via-macro: 394 | description: >- 395 | target-name that is a valid domain-spec per RFC 4408 but an invalid 396 | domain name per RFC 1035 (long label) must be treated as non-existent. 397 | comment: >- 398 | A domain label longer than 63 characters that results from macro 399 | expansion in a mechanism target-name is valid domain-spec syntax (and is 400 | not even subject to syntax checking after macro expansion), even though 401 | a DNS query cannot be composed from it. The spec being unclear about 402 | it, this could either be considered a syntax error, or, by analogy to 403 | 4.3/1 and 5/10/3, the mechanism chould be treated as a no-match. 404 | spec: [4.3/1, 5/10/3] 405 | helo: "%%%%%%%%%%%%%%%%%%%%%%" 406 | host: 1.2.3.4 407 | mailfrom: foo@t12.example.com 408 | result: [permerror,fail] 409 | zonedata: 410 | mail.example.com: 411 | - A: 1.2.3.4 412 | t1.example.com: 413 | - SPF: v=spf1 ip4:1.2.3.4 -all moo 414 | t2.example.com: 415 | - SPF: v=spf1 moo.cow-far_out=man:dog/cat ip4:1.2.3.4 -all 416 | t3.example.com: 417 | - SPF: v=spf1 moo.cow/far_out=man:dog/cat ip4:1.2.3.4 -all 418 | t4.example.com: 419 | - SPF: v=spf1 moo.cow:far_out=man:dog/cat ip4:1.2.3.4 -all 420 | t5.example.com: 421 | - SPF: v=spf1 redirect=t5.example.com ~all 422 | t6.example.com: 423 | - SPF: v=spf1 ip4:1.2.3.4 redirect=t2.example.com 424 | t7.example.com: 425 | - SPF: v=spf1 ip4:1.2.3.4 426 | t8.example.com: 427 | - SPF: v=spf1 ip4:1.2.3.4 redirect:t2.example.com 428 | t9.example.com: 429 | - SPF: v=spf1 a:foo-bar -all 430 | t10.example.com: 431 | - SPF: v=spf1 a:mail.example...com -all 432 | t11.example.com: 433 | - SPF: v=spf1 a:a123456789012345678901234567890123456789012345678901234567890123.example.com -all 434 | t12.example.com: 435 | - SPF: v=spf1 a:%{H}.bar -all 436 | --- 437 | description: ALL mechanism syntax 438 | tests: 439 | all-dot: 440 | description: | 441 | all = "all" 442 | comment: |- 443 | At least one implementation got this wrong 444 | spec: 5.1/1 445 | helo: mail.example.com 446 | host: 1.2.3.4 447 | mailfrom: foo@e1.example.com 448 | result: permerror 449 | all-arg: 450 | description: | 451 | all = "all" 452 | comment: |- 453 | At least one implementation got this wrong 454 | spec: 5.1/1 455 | helo: mail.example.com 456 | host: 1.2.3.4 457 | mailfrom: foo@e2.example.com 458 | result: permerror 459 | all-cidr: 460 | description: | 461 | all = "all" 462 | spec: 5.1/1 463 | helo: mail.example.com 464 | host: 1.2.3.4 465 | mailfrom: foo@e3.example.com 466 | result: permerror 467 | all-neutral: 468 | description: | 469 | all = "all" 470 | spec: 5.1/1 471 | helo: mail.example.com 472 | host: 1.2.3.4 473 | mailfrom: foo@e4.example.com 474 | result: neutral 475 | all-double: 476 | description: | 477 | all = "all" 478 | spec: 5.1/1 479 | helo: mail.example.com 480 | host: 1.2.3.4 481 | mailfrom: foo@e5.example.com 482 | result: pass 483 | zonedata: 484 | mail.example.com: 485 | - A: 1.2.3.4 486 | e1.example.com: 487 | - SPF: v=spf1 -all. 488 | e2.example.com: 489 | - SPF: v=spf1 -all:foobar 490 | e3.example.com: 491 | - SPF: v=spf1 -all/8 492 | e4.example.com: 493 | - SPF: v=spf1 ?all 494 | e5.example.com: 495 | - SPF: v=spf1 all -all 496 | --- 497 | description: PTR mechanism syntax 498 | tests: 499 | ptr-cidr: 500 | description: |- 501 | PTR = "ptr" [ ":" domain-spec ] 502 | spec: 5.5/2 503 | helo: mail.example.com 504 | host: 1.2.3.4 505 | mailfrom: foo@e1.example.com 506 | result: permerror 507 | ptr-match-target: 508 | description: >- 509 | Check all validated domain names to see if they end in the 510 | domain. 511 | spec: 5.5/5 512 | helo: mail.example.com 513 | host: 1.2.3.4 514 | mailfrom: foo@e2.example.com 515 | result: pass 516 | ptr-match-implicit: 517 | description: >- 518 | Check all validated domain names to see if they end in the 519 | domain. 520 | spec: 5.5/5 521 | helo: mail.example.com 522 | host: 1.2.3.4 523 | mailfrom: foo@e3.example.com 524 | result: pass 525 | ptr-nomatch-invalid: 526 | description: >- 527 | Check all validated domain names to see if they end in the 528 | domain. 529 | comment: >- 530 | This PTR record does not validate 531 | spec: 5.5/5 532 | helo: mail.example.com 533 | host: 1.2.3.4 534 | mailfrom: foo@e4.example.com 535 | result: fail 536 | ptr-match-ip6: 537 | description: >- 538 | Check all validated domain names to see if they end in the 539 | domain. 540 | spec: 5.5/5 541 | helo: mail.example.com 542 | host: CAFE:BABE::1 543 | mailfrom: foo@e3.example.com 544 | result: pass 545 | ptr-empty-domain: 546 | description: >- 547 | domain-spec cannot be empty. 548 | spec: 5.5/2 549 | helo: mail.example.com 550 | host: 1.2.3.4 551 | mailfrom: foo@e5.example.com 552 | result: permerror 553 | zonedata: 554 | mail.example.com: 555 | - A: 1.2.3.4 556 | e1.example.com: 557 | - SPF: v=spf1 ptr/0 -all 558 | e2.example.com: 559 | - SPF: v=spf1 ptr:example.com -all 560 | 4.3.2.1.in-addr.arpa: 561 | - PTR: e3.example.com 562 | - PTR: e4.example.com 563 | - PTR: mail.example.com 564 | 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.E.B.A.B.E.F.A.C.ip6.arpa: 565 | - PTR: e3.example.com 566 | e3.example.com: 567 | - SPF: v=spf1 ptr -all 568 | - A: 1.2.3.4 569 | - AAAA: CAFE:BABE::1 570 | e4.example.com: 571 | - SPF: v=spf1 ptr -all 572 | e5.example.com: 573 | - SPF: "v=spf1 ptr:" 574 | --- 575 | description: A mechanism syntax 576 | tests: 577 | a-cidr6: 578 | description: | 579 | A = "a" [ ":" domain-spec ] [ dual-cidr-length ] 580 | dual-cidr-length = [ ip4-cidr-length ] [ "/" ip6-cidr-length ] 581 | spec: 5.3/2 582 | helo: mail.example.com 583 | host: 1.2.3.4 584 | mailfrom: foo@e6.example.com 585 | result: fail 586 | a-bad-cidr4: 587 | description: | 588 | A = "a" [ ":" domain-spec ] [ dual-cidr-length ] 589 | dual-cidr-length = [ ip4-cidr-length ] [ "/" ip6-cidr-length ] 590 | spec: 5.3/2 591 | helo: mail.example.com 592 | host: 1.2.3.4 593 | mailfrom: foo@e6a.example.com 594 | result: permerror 595 | a-bad-cidr6: 596 | description: | 597 | A = "a" [ ":" domain-spec ] [ dual-cidr-length ] 598 | dual-cidr-length = [ ip4-cidr-length ] [ "/" ip6-cidr-length ] 599 | spec: 5.3/2 600 | helo: mail.example.com 601 | host: 1.2.3.4 602 | mailfrom: foo@e7.example.com 603 | result: permerror 604 | a-multi-ip1: 605 | description: >- 606 | A matches any returned IP. 607 | spec: 5.3/3 608 | helo: mail.example.com 609 | host: 1.2.3.4 610 | mailfrom: foo@e10.example.com 611 | result: pass 612 | a-multi-ip2: 613 | description: >- 614 | A matches any returned IP. 615 | spec: 5.3/3 616 | helo: mail.example.com 617 | host: 1.2.3.4 618 | mailfrom: foo@e10.example.com 619 | result: pass 620 | a-bad-domain: 621 | description: >- 622 | domain-spec must pass basic syntax checks; 623 | a ':' may appear in domain-spec, but not in top-label 624 | spec: 8.1/2 625 | helo: mail.example.com 626 | host: 1.2.3.4 627 | mailfrom: foo@e9.example.com 628 | result: permerror 629 | a-nxdomain: 630 | description: >- 631 | If no ips are returned, A mechanism does not match, even with /0. 632 | spec: 5.3/3 633 | helo: mail.example.com 634 | host: 1.2.3.4 635 | mailfrom: foo@e1.example.com 636 | result: fail 637 | a-cidr4-0: 638 | description: >- 639 | Matches if any A records are present in DNS. 640 | spec: 5.3/3 641 | helo: mail.example.com 642 | host: 1.2.3.4 643 | mailfrom: foo@e2.example.com 644 | result: pass 645 | a-cidr4-0-ip6: 646 | description: >- 647 | Matches if any A records are present in DNS. 648 | spec: 5.3/3 649 | helo: mail.example.com 650 | host: 1234::1 651 | mailfrom: foo@e2.example.com 652 | result: fail 653 | a-cidr6-0-ip4: 654 | description: >- 655 | Would match if any AAAA records are present in DNS, 656 | but not for an IP4 connection. 657 | spec: 5.3/3 658 | helo: mail.example.com 659 | host: 1.2.3.4 660 | mailfrom: foo@e2a.example.com 661 | result: fail 662 | a-cidr6-0-ip4mapped: 663 | description: >- 664 | Would match if any AAAA records are present in DNS, 665 | but not for an IP4 connection. 666 | spec: 5.3/3 667 | helo: mail.example.com 668 | host: ::FFFF:1.2.3.4 669 | mailfrom: foo@e2a.example.com 670 | result: fail 671 | a-cidr6-0-ip6: 672 | description: >- 673 | Matches if any AAAA records are present in DNS. 674 | spec: 5.3/3 675 | helo: mail.example.com 676 | host: 1234::1 677 | mailfrom: foo@e2a.example.com 678 | result: pass 679 | a-cidr6-0-nxdomain: 680 | description: >- 681 | No match if no AAAA records are present in DNS. 682 | spec: 5.3/3 683 | helo: mail.example.com 684 | host: 1234::1 685 | mailfrom: foo@e2b.example.com 686 | result: fail 687 | a-null: 688 | description: >- 689 | Null octets not allowed in toplabel 690 | spec: 8.1/2 691 | helo: mail.example.com 692 | host: 1.2.3.5 693 | mailfrom: foo@e3.example.com 694 | result: permerror 695 | a-numeric: 696 | description: >- 697 | toplabel may not be all numeric 698 | comment: >- 699 | A common publishing mistake is using ip4 addresses with A mechanism. 700 | This should receive special diagnostic attention in the permerror. 701 | spec: 8.1/2 702 | helo: mail.example.com 703 | host: 1.2.3.4 704 | mailfrom: foo@e4.example.com 705 | result: permerror 706 | a-numeric-toplabel: 707 | description: >- 708 | toplabel may not be all numeric 709 | spec: 8.1/2 710 | helo: mail.example.com 711 | host: 1.2.3.4 712 | mailfrom: foo@e5.example.com 713 | result: permerror 714 | a-dash-in-toplabel: 715 | description: >- 716 | toplabel may contain dashes 717 | comment: >- 718 | Going from the "toplabel" grammar definition, an implementation using 719 | regular expressions in incrementally parsing SPF records might 720 | erroneously try to match a TLD such as ".xn--zckzah" (cf. IDN TLDs!) to 721 | '( *alphanum ALPHA *alphanum )' first before trying the alternative 722 | '( 1*alphanum "-" *( alphanum / "-" ) alphanum )', essentially causing 723 | a non-greedy, and thus, incomplete match. Make sure a greedy match is 724 | performed! 725 | spec: 8.1/2 726 | helo: mail.example.com 727 | host: 1.2.3.4 728 | mailfrom: foo@e14.example.com 729 | result: pass 730 | a-bad-toplabel: 731 | description: >- 732 | toplabel may not begin with a dash 733 | spec: 8.1/2 734 | helo: mail.example.com 735 | host: 1.2.3.4 736 | mailfrom: foo@e12.example.com 737 | result: permerror 738 | a-only-toplabel: 739 | description: >- 740 | domain-spec may not consist of only a toplabel. 741 | spec: 8.1/2 742 | helo: mail.example.com 743 | host: 1.2.3.4 744 | mailfrom: foo@e5a.example.com 745 | result: permerror 746 | a-only-toplabel-trailing-dot: 747 | description: >- 748 | domain-spec may not consist of only a toplabel. 749 | comment: >- 750 | "A trailing dot doesn't help." 751 | spec: 8.1/2 752 | helo: mail.example.com 753 | host: 1.2.3.4 754 | mailfrom: foo@e5b.example.com 755 | result: permerror 756 | a-colon-domain: 757 | description: >- 758 | domain-spec may contain any visible char except % 759 | spec: 8.1/2 760 | helo: mail.example.com 761 | host: 1.2.3.4 762 | mailfrom: foo@e11.example.com 763 | result: pass 764 | a-colon-domain-ip4mapped: 765 | description: >- 766 | domain-spec may contain any visible char except % 767 | spec: 8.1/2 768 | helo: mail.example.com 769 | host: ::FFFF:1.2.3.4 770 | mailfrom: foo@e11.example.com 771 | result: pass 772 | a-empty-domain: 773 | description: >- 774 | domain-spec cannot be empty. 775 | spec: 5.3/2 776 | helo: mail.example.com 777 | host: 1.2.3.4 778 | mailfrom: foo@e13.example.com 779 | result: permerror 780 | zonedata: 781 | mail.example.com: 782 | - A: 1.2.3.4 783 | e1.example.com: 784 | - SPF: v=spf1 a/0 -all 785 | e2.example.com: 786 | - A: 1.1.1.1 787 | - AAAA: 1234::2 788 | - SPF: v=spf1 a/0 -all 789 | e2a.example.com: 790 | - AAAA: 1234::1 791 | - SPF: v=spf1 a//0 -all 792 | e2b.example.com: 793 | - A: 1.1.1.1 794 | - SPF: v=spf1 a//0 -all 795 | e3.example.com: 796 | - SPF: "v=spf1 a:foo.example.com\0" 797 | e4.example.com: 798 | - SPF: v=spf1 a:111.222.33.44 799 | e5.example.com: 800 | - SPF: v=spf1 a:abc.123 801 | e5a.example.com: 802 | - SPF: v=spf1 a:museum 803 | e5b.example.com: 804 | - SPF: v=spf1 a:museum. 805 | e6.example.com: 806 | - SPF: v=spf1 a//33 -all 807 | e6a.example.com: 808 | - SPF: v=spf1 a/33 -all 809 | e7.example.com: 810 | - SPF: v=spf1 a//129 -all 811 | e9.example.com: 812 | - SPF: v=spf1 a:example.com:8080 813 | e10.example.com: 814 | - SPF: v=spf1 a:foo.example.com/24 815 | foo.example.com: 816 | - A: 1.1.1.1 817 | - A: 1.2.3.5 818 | e11.example.com: 819 | - SPF: v=spf1 a:foo:bar/baz.example.com 820 | foo:bar/baz.example.com: 821 | - A: 1.2.3.4 822 | e12.example.com: 823 | - SPF: v=spf1 a:example.-com 824 | e13.example.com: 825 | - SPF: "v=spf1 a:" 826 | e14.example.com: 827 | - SPF: "v=spf1 a:foo.example.xn--zckzah -all" 828 | foo.example.xn--zckzah: 829 | - A: 1.2.3.4 830 | --- 831 | description: Include mechanism semantics and syntax 832 | tests: 833 | include-fail: 834 | description: >- 835 | recursive check_host() result of fail causes include to not match. 836 | spec: 5.2/9 837 | helo: mail.example.com 838 | host: 1.2.3.4 839 | mailfrom: foo@e1.example.com 840 | result: softfail 841 | include-softfail: 842 | description: >- 843 | recursive check_host() result of softfail causes include to not match. 844 | spec: 5.2/9 845 | helo: mail.example.com 846 | host: 1.2.3.4 847 | mailfrom: foo@e2.example.com 848 | result: pass 849 | include-neutral: 850 | description: >- 851 | recursive check_host() result of neutral causes include to not match. 852 | spec: 5.2/9 853 | helo: mail.example.com 854 | host: 1.2.3.4 855 | mailfrom: foo@e3.example.com 856 | result: fail 857 | include-temperror: 858 | description: >- 859 | recursive check_host() result of temperror causes include to temperror 860 | spec: 5.2/9 861 | helo: mail.example.com 862 | host: 1.2.3.4 863 | mailfrom: foo@e4.example.com 864 | result: temperror 865 | include-permerror: 866 | description: >- 867 | recursive check_host() result of permerror causes include to permerror 868 | spec: 5.2/9 869 | helo: mail.example.com 870 | host: 1.2.3.4 871 | mailfrom: foo@e5.example.com 872 | result: permerror 873 | include-syntax-error: 874 | description: >- 875 | include = "include" ":" domain-spec 876 | spec: 5.2/1 877 | helo: mail.example.com 878 | host: 1.2.3.4 879 | mailfrom: foo@e6.example.com 880 | result: permerror 881 | include-cidr: 882 | description: >- 883 | include = "include" ":" domain-spec 884 | spec: 5.2/1 885 | helo: mail.example.com 886 | host: 1.2.3.4 887 | mailfrom: foo@e9.example.com 888 | result: permerror 889 | include-none: 890 | description: >- 891 | recursive check_host() result of none causes include to permerror 892 | spec: 5.2/9 893 | helo: mail.example.com 894 | host: 1.2.3.4 895 | mailfrom: foo@e7.example.com 896 | result: permerror 897 | include-empty-domain: 898 | description: >- 899 | domain-spec cannot be empty. 900 | spec: 5.2/1 901 | helo: mail.example.com 902 | host: 1.2.3.4 903 | mailfrom: foo@e8.example.com 904 | result: permerror 905 | zonedata: 906 | mail.example.com: 907 | - A: 1.2.3.4 908 | ip5.example.com: 909 | - SPF: v=spf1 ip4:1.2.3.5 -all 910 | ip6.example.com: 911 | - SPF: v=spf1 ip4:1.2.3.6 ~all 912 | ip7.example.com: 913 | - SPF: v=spf1 ip4:1.2.3.7 ?all 914 | ip8.example.com: 915 | - TIMEOUT 916 | erehwon.example.com: 917 | - TXT: v=spfl am not an SPF record 918 | e1.example.com: 919 | - SPF: v=spf1 include:ip5.example.com ~all 920 | e2.example.com: 921 | - SPF: v=spf1 include:ip6.example.com all 922 | e3.example.com: 923 | - SPF: v=spf1 include:ip7.example.com -all 924 | e4.example.com: 925 | - SPF: v=spf1 include:ip8.example.com -all 926 | e5.example.com: 927 | - SPF: v=spf1 include:e6.example.com -all 928 | e6.example.com: 929 | - SPF: v=spf1 include +all 930 | e7.example.com: 931 | - SPF: v=spf1 include:erehwon.example.com -all 932 | e8.example.com: 933 | - SPF: "v=spf1 include: -all" 934 | e9.example.com: 935 | - SPF: "v=spf1 include:ip5.example.com/24 -all" 936 | --- 937 | description: MX mechanism syntax 938 | tests: 939 | mx-cidr6: 940 | description: | 941 | MX = "mx" [ ":" domain-spec ] [ dual-cidr-length ] 942 | dual-cidr-length = [ ip4-cidr-length ] [ "/" ip6-cidr-length ] 943 | spec: 5.4/2 944 | helo: mail.example.com 945 | host: 1.2.3.4 946 | mailfrom: foo@e6.example.com 947 | result: fail 948 | mx-bad-cidr4: 949 | description: | 950 | MX = "mx" [ ":" domain-spec ] [ dual-cidr-length ] 951 | dual-cidr-length = [ ip4-cidr-length ] [ "/" ip6-cidr-length ] 952 | spec: 5.4/2 953 | helo: mail.example.com 954 | host: 1.2.3.4 955 | mailfrom: foo@e6a.example.com 956 | result: permerror 957 | mx-bad-cidr6: 958 | description: | 959 | MX = "mx" [ ":" domain-spec ] [ dual-cidr-length ] 960 | dual-cidr-length = [ ip4-cidr-length ] [ "/" ip6-cidr-length ] 961 | spec: 5.4/2 962 | helo: mail.example.com 963 | host: 1.2.3.4 964 | mailfrom: foo@e7.example.com 965 | result: permerror 966 | mx-multi-ip1: 967 | description: >- 968 | MX matches any returned IP. 969 | spec: 5.4/3 970 | helo: mail.example.com 971 | host: 1.2.3.4 972 | mailfrom: foo@e10.example.com 973 | result: pass 974 | mx-multi-ip2: 975 | description: >- 976 | MX matches any returned IP. 977 | spec: 5.4/3 978 | helo: mail.example.com 979 | host: 1.2.3.4 980 | mailfrom: foo@e10.example.com 981 | result: pass 982 | mx-bad-domain: 983 | description: >- 984 | domain-spec must pass basic syntax checks 985 | comment: >- 986 | A ':' may appear in domain-spec, but not in top-label. 987 | spec: 8.1/2 988 | helo: mail.example.com 989 | host: 1.2.3.4 990 | mailfrom: foo@e9.example.com 991 | result: permerror 992 | mx-nxdomain: 993 | description: >- 994 | If no ips are returned, MX mechanism does not match, even with /0. 995 | spec: 5.4/3 996 | helo: mail.example.com 997 | host: 1.2.3.4 998 | mailfrom: foo@e1.example.com 999 | result: fail 1000 | mx-cidr4-0: 1001 | description: >- 1002 | Matches if any A records for any MX records are present in DNS. 1003 | spec: 5.4/3 1004 | helo: mail.example.com 1005 | host: 1.2.3.4 1006 | mailfrom: foo@e2.example.com 1007 | result: pass 1008 | mx-cidr4-0-ip6: 1009 | description: >- 1010 | Matches if any A records for any MX records are present in DNS. 1011 | spec: 5.4/3 1012 | helo: mail.example.com 1013 | host: 1234::1 1014 | mailfrom: foo@e2.example.com 1015 | result: fail 1016 | mx-cidr6-0-ip4: 1017 | description: >- 1018 | Would match if any AAAA records for MX records are present in DNS, 1019 | but not for an IP4 connection. 1020 | spec: 5.4/3 1021 | helo: mail.example.com 1022 | host: 1.2.3.4 1023 | mailfrom: foo@e2a.example.com 1024 | result: fail 1025 | mx-cidr6-0-ip4mapped: 1026 | description: >- 1027 | Would match if any AAAA records for MX records are present in DNS, 1028 | but not for an IP4 connection. 1029 | spec: 5.4/3 1030 | helo: mail.example.com 1031 | host: ::FFFF:1.2.3.4 1032 | mailfrom: foo@e2a.example.com 1033 | result: fail 1034 | mx-cidr6-0-ip6: 1035 | description: >- 1036 | Matches if any AAAA records for any MX records are present in DNS. 1037 | spec: 5.3/3 1038 | helo: mail.example.com 1039 | host: 1234::1 1040 | mailfrom: foo@e2a.example.com 1041 | result: pass 1042 | mx-cidr6-0-nxdomain: 1043 | description: >- 1044 | No match if no AAAA records for any MX records are present in DNS. 1045 | spec: 5.4/3 1046 | helo: mail.example.com 1047 | host: 1234::1 1048 | mailfrom: foo@e2b.example.com 1049 | result: fail 1050 | mx-null: 1051 | description: >- 1052 | Null not allowed in top-label. 1053 | spec: 8.1/2 1054 | helo: mail.example.com 1055 | host: 1.2.3.5 1056 | mailfrom: foo@e3.example.com 1057 | result: permerror 1058 | mx-numeric-top-label: 1059 | description: >- 1060 | Top-label may not be all numeric 1061 | spec: 8.1/2 1062 | helo: mail.example.com 1063 | host: 1.2.3.4 1064 | mailfrom: foo@e5.example.com 1065 | result: permerror 1066 | mx-colon-domain: 1067 | description: >- 1068 | Domain-spec may contain any visible char except % 1069 | spec: 8.1/2 1070 | helo: mail.example.com 1071 | host: 1.2.3.4 1072 | mailfrom: foo@e11.example.com 1073 | result: pass 1074 | mx-colon-domain-ip4mapped: 1075 | description: >- 1076 | Domain-spec may contain any visible char except % 1077 | spec: 8.1/2 1078 | helo: mail.example.com 1079 | host: ::FFFF:1.2.3.4 1080 | mailfrom: foo@e11.example.com 1081 | result: pass 1082 | mx-bad-toplab: 1083 | description: >- 1084 | Toplabel may not begin with - 1085 | spec: 8.1/2 1086 | helo: mail.example.com 1087 | host: 1.2.3.4 1088 | mailfrom: foo@e12.example.com 1089 | result: permerror 1090 | mx-empty: 1091 | description: >- 1092 | test null MX 1093 | comment: >- 1094 | Some implementations have had trouble with null MX 1095 | spec: 5.4/3 1096 | helo: mail.example.com 1097 | host: 1.2.3.4 1098 | mailfrom: "" 1099 | result: neutral 1100 | mx-implicit: 1101 | description: >- 1102 | If the target name has no MX records, check_host() MUST NOT pretend the 1103 | target is its single MX, and MUST NOT default to an A lookup on the 1104 | target-name directly. 1105 | spec: 5.4/4 1106 | helo: mail.example.com 1107 | host: 1.2.3.4 1108 | mailfrom: foo@e4.example.com 1109 | result: neutral 1110 | mx-empty-domain: 1111 | description: >- 1112 | domain-spec cannot be empty. 1113 | spec: 5.2/1 1114 | helo: mail.example.com 1115 | host: 1.2.3.4 1116 | mailfrom: foo@e13.example.com 1117 | result: permerror 1118 | zonedata: 1119 | mail.example.com: 1120 | - A: 1.2.3.4 1121 | - MX: [0, ""] 1122 | - SPF: v=spf1 mx 1123 | e1.example.com: 1124 | - SPF: v=spf1 mx/0 -all 1125 | - MX: [0, e1.example.com] 1126 | e2.example.com: 1127 | - A: 1.1.1.1 1128 | - AAAA: 1234::2 1129 | - MX: [0, e2.example.com] 1130 | - SPF: v=spf1 mx/0 -all 1131 | e2a.example.com: 1132 | - AAAA: 1234::1 1133 | - MX: [0, e2a.example.com] 1134 | - SPF: v=spf1 mx//0 -all 1135 | e2b.example.com: 1136 | - A: 1.1.1.1 1137 | - MX: [0, e2b.example.com] 1138 | - SPF: v=spf1 mx//0 -all 1139 | e3.example.com: 1140 | - SPF: "v=spf1 mx:foo.example.com\0" 1141 | e4.example.com: 1142 | - SPF: v=spf1 mx 1143 | - A: 1.2.3.4 1144 | e5.example.com: 1145 | - SPF: v=spf1 mx:abc.123 1146 | e6.example.com: 1147 | - SPF: v=spf1 mx//33 -all 1148 | e6a.example.com: 1149 | - SPF: v=spf1 mx/33 -all 1150 | e7.example.com: 1151 | - SPF: v=spf1 mx//129 -all 1152 | e9.example.com: 1153 | - SPF: v=spf1 mx:example.com:8080 1154 | e10.example.com: 1155 | - SPF: v=spf1 mx:foo.example.com/24 1156 | foo.example.com: 1157 | - MX: [0, foo1.example.com] 1158 | foo1.example.com: 1159 | - A: 1.1.1.1 1160 | - A: 1.2.3.5 1161 | e11.example.com: 1162 | - SPF: v=spf1 mx:foo:bar/baz.example.com 1163 | foo:bar/baz.example.com: 1164 | - MX: [0, "foo:bar/baz.example.com"] 1165 | - A: 1.2.3.4 1166 | e12.example.com: 1167 | - SPF: v=spf1 mx:example.-com 1168 | e13.example.com: 1169 | - SPF: "v=spf1 mx: -all" 1170 | --- 1171 | description: EXISTS mechanism syntax 1172 | tests: 1173 | exists-empty-domain: 1174 | description: >- 1175 | domain-spec cannot be empty. 1176 | spec: 5.7/2 1177 | helo: mail.example.com 1178 | host: 1.2.3.4 1179 | mailfrom: foo@e1.example.com 1180 | result: permerror 1181 | exists-implicit: 1182 | description: >- 1183 | exists = "exists" ":" domain-spec 1184 | spec: 5.7/2 1185 | helo: mail.example.com 1186 | host: 1.2.3.4 1187 | mailfrom: foo@e2.example.com 1188 | result: permerror 1189 | exists-cidr: 1190 | description: >- 1191 | exists = "exists" ":" domain-spec 1192 | spec: 5.7/2 1193 | helo: mail.example.com 1194 | host: 1.2.3.4 1195 | mailfrom: foo@e3.example.com 1196 | result: permerror 1197 | zonedata: 1198 | mail.example.com: 1199 | - A: 1.2.3.4 1200 | e1.example.com: 1201 | - SPF: "v=spf1 exists:" 1202 | e2.example.com: 1203 | - SPF: "v=spf1 exists" 1204 | e3.example.com: 1205 | - SPF: "v=spf1 exists:mail.example.com/24" 1206 | --- 1207 | description: IP4 mechanism syntax 1208 | tests: 1209 | cidr4-0: 1210 | description: >- 1211 | ip4-cidr-length = "/" 1*DIGIT 1212 | spec: 5.6/2 1213 | helo: mail.example.com 1214 | host: 1.2.3.4 1215 | mailfrom: foo@e1.example.com 1216 | result: pass 1217 | cidr4-32: 1218 | description: >- 1219 | ip4-cidr-length = "/" 1*DIGIT 1220 | spec: 5.6/2 1221 | helo: mail.example.com 1222 | host: 1.2.3.4 1223 | mailfrom: foo@e2.example.com 1224 | result: pass 1225 | cidr4-33: 1226 | description: >- 1227 | Invalid CIDR should get permerror. 1228 | comment: >- 1229 | The RFC is silent on ip4 CIDR > 32 or ip6 CIDR > 128. However, 1230 | since there is no reasonable interpretation (except a noop), we have 1231 | read between the lines to see a prohibition on invalid CIDR. 1232 | spec: 5.6/2 1233 | helo: mail.example.com 1234 | host: 1.2.3.4 1235 | mailfrom: foo@e3.example.com 1236 | result: permerror 1237 | cidr4-032: 1238 | description: >- 1239 | Invalid CIDR should get permerror. 1240 | comment: >- 1241 | Leading zeros are not explicitly prohibited by the RFC. However, 1242 | since the RFC explicity prohibits leading zeros in ip4-network, 1243 | our interpretation is that CIDR should be also. 1244 | spec: 5.6/2 1245 | helo: mail.example.com 1246 | host: 1.2.3.4 1247 | mailfrom: foo@e4.example.com 1248 | result: permerror 1249 | bare-ip4: 1250 | description: >- 1251 | IP4 = "ip4" ":" ip4-network [ ip4-cidr-length ] 1252 | spec: 5.6/2 1253 | helo: mail.example.com 1254 | host: 1.2.3.4 1255 | mailfrom: foo@e5.example.com 1256 | result: permerror 1257 | bad-ip4-port: 1258 | description: >- 1259 | IP4 = "ip4" ":" ip4-network [ ip4-cidr-length ] 1260 | comment: >- 1261 | This has actually been published in SPF records. 1262 | spec: 5.6/2 1263 | helo: mail.example.com 1264 | host: 1.2.3.4 1265 | mailfrom: foo@e8.example.com 1266 | result: permerror 1267 | bad-ip4-short: 1268 | description: >- 1269 | It is not permitted to omit parts of the IP address instead of 1270 | using CIDR notations. 1271 | spec: 5.6/4 1272 | helo: mail.example.com 1273 | host: 1.2.3.4 1274 | mailfrom: foo@e9.example.com 1275 | result: permerror 1276 | ip4-dual-cidr: 1277 | description: >- 1278 | dual-cidr-length not permitted on ip4 1279 | spec: 5.6/2 1280 | helo: mail.example.com 1281 | host: 1.2.3.4 1282 | mailfrom: foo@e6.example.com 1283 | result: permerror 1284 | ip4-mapped-ip6: 1285 | description: >- 1286 | IP4 mapped IP6 connections MUST be treated as IP4 1287 | spec: 5/9/2 1288 | helo: mail.example.com 1289 | host: ::FFFF:1.2.3.4 1290 | mailfrom: foo@e7.example.com 1291 | result: fail 1292 | zonedata: 1293 | mail.example.com: 1294 | - A: 1.2.3.4 1295 | e1.example.com: 1296 | - SPF: v=spf1 ip4:1.1.1.1/0 -all 1297 | e2.example.com: 1298 | - SPF: v=spf1 ip4:1.2.3.4/32 -all 1299 | e3.example.com: 1300 | - SPF: v=spf1 ip4:1.2.3.4/33 -all 1301 | e4.example.com: 1302 | - SPF: v=spf1 ip4:1.2.3.4/032 -all 1303 | e5.example.com: 1304 | - SPF: v=spf1 ip4 1305 | e6.example.com: 1306 | - SPF: v=spf1 ip4:1.2.3.4//32 1307 | e7.example.com: 1308 | - SPF: v=spf1 -ip4:1.2.3.4 ip6:::FFFF:1.2.3.4 1309 | e8.example.com: 1310 | - SPF: v=spf1 ip4:1.2.3.4:8080 1311 | e9.example.com: 1312 | - SPF: v=spf1 ip4:1.2.3 1313 | --- 1314 | description: IP6 mechanism syntax 1315 | comment: >- 1316 | IP4 only implementations may skip tests where host is not IP4 1317 | tests: 1318 | bare-ip6: 1319 | description: >- 1320 | IP6 = "ip6" ":" ip6-network [ ip6-cidr-length ] 1321 | spec: 5.6/2 1322 | helo: mail.example.com 1323 | host: 1.2.3.4 1324 | mailfrom: foo@e1.example.com 1325 | result: permerror 1326 | cidr6-0-ip4: 1327 | description: >- 1328 | IP4 connections do not match ip6. 1329 | comment: >- 1330 | There is controversy over ip4 mapped connections. RFC4408 clearly 1331 | requires such connections to be considered as ip4. However, 1332 | some interpret the RFC to mean that such connections should *also* 1333 | match appropriate ip6 mechanisms (but not, inexplicably, A or MX 1334 | mechanisms). Until there is consensus, both 1335 | results are acceptable. 1336 | spec: 5/9/2 1337 | helo: mail.example.com 1338 | host: 1.2.3.4 1339 | mailfrom: foo@e2.example.com 1340 | result: [neutral, pass] 1341 | cidr6-ip4: 1342 | description: >- 1343 | Even if the SMTP connection is via IPv6, an IPv4-mapped IPv6 IP address 1344 | (see RFC 3513, Section 2.5.5) MUST still be considered an IPv4 address. 1345 | comment: >- 1346 | There is controversy over ip4 mapped connections. RFC4408 clearly 1347 | requires such connections to be considered as ip4. However, 1348 | some interpret the RFC to mean that such connections should *also* 1349 | match appropriate ip6 mechanisms (but not, inexplicably, A or MX 1350 | mechanisms). Until there is consensus, both 1351 | results are acceptable. 1352 | spec: 5/9/2 1353 | helo: mail.example.com 1354 | host: ::FFFF:1.2.3.4 1355 | mailfrom: foo@e2.example.com 1356 | result: [neutral, pass] 1357 | cidr6-0: 1358 | description: >- 1359 | Match any IP6 1360 | spec: 5/8 1361 | helo: mail.example.com 1362 | host: DEAF:BABE::CAB:FEE 1363 | mailfrom: foo@e2.example.com 1364 | result: pass 1365 | cidr6-129: 1366 | description: >- 1367 | Invalid CIDR 1368 | comment: >- 1369 | IP4 only implementations MUST fully syntax check all mechanisms, 1370 | even if they otherwise ignore them. 1371 | spec: 5.6/2 1372 | helo: mail.example.com 1373 | host: 1.2.3.4 1374 | mailfrom: foo@e3.example.com 1375 | result: permerror 1376 | cidr6-bad: 1377 | description: >- 1378 | dual-cidr syntax not used for ip6 1379 | comment: >- 1380 | IP4 only implementations MUST fully syntax check all mechanisms, 1381 | even if they otherwise ignore them. 1382 | spec: 5.6/2 1383 | helo: mail.example.com 1384 | host: 1.2.3.4 1385 | mailfrom: foo@e4.example.com 1386 | result: permerror 1387 | cidr6-33: 1388 | description: >- 1389 | make sure ip4 cidr restriction are not used for ip6 1390 | spec: 5.6/2 1391 | helo: mail.example.com 1392 | host: "CAFE:BABE:8000::" 1393 | mailfrom: foo@e5.example.com 1394 | result: pass 1395 | cidr6-33-ip4: 1396 | description: >- 1397 | make sure ip4 cidr restriction are not used for ip6 1398 | spec: 5.6/2 1399 | helo: mail.example.com 1400 | host: 1.2.3.4 1401 | mailfrom: foo@e5.example.com 1402 | result: neutral 1403 | ip6-bad1: 1404 | description: >- 1405 | spec: 5.6/2 1406 | helo: mail.example.com 1407 | host: 1.2.3.4 1408 | mailfrom: foo@e6.example.com 1409 | result: permerror 1410 | zonedata: 1411 | mail.example.com: 1412 | - A: 1.2.3.4 1413 | e1.example.com: 1414 | - SPF: v=spf1 -all ip6 1415 | e2.example.com: 1416 | - SPF: v=spf1 ip6:::1.1.1.1/0 1417 | e3.example.com: 1418 | - SPF: v=spf1 ip6:::1.1.1.1/129 1419 | e4.example.com: 1420 | - SPF: v=spf1 ip6:::1.1.1.1//33 1421 | e5.example.com: 1422 | - SPF: v=spf1 ip6:CAFE:BABE:8000::/33 1423 | e6.example.com: 1424 | - SPF: v=spf1 ip6::CAFE::BABE 1425 | --- 1426 | description: Semantics of exp and other modifiers 1427 | comment: >- 1428 | Implementing exp= is optional. If not implemented, the test driver should 1429 | not check the explanation field. 1430 | tests: 1431 | redirect-none: 1432 | description: >- 1433 | If no SPF record is found, or if the target-name is malformed, the result 1434 | is a "PermError" rather than "None". 1435 | spec: 6.1/4 1436 | helo: mail.example.com 1437 | host: 1.2.3.4 1438 | mailfrom: foo@e10.example.com 1439 | result: permerror 1440 | redirect-cancels-exp: 1441 | description: >- 1442 | when executing "redirect", exp= from the original domain MUST NOT be used. 1443 | spec: 6.2/13 1444 | helo: mail.example.com 1445 | host: 1.2.3.4 1446 | mailfrom: foo@e1.example.com 1447 | result: fail 1448 | explanation: DEFAULT 1449 | redirect-syntax-error: 1450 | description: | 1451 | redirect = "redirect" "=" domain-spec 1452 | comment: >- 1453 | A literal application of the grammar causes modifier syntax 1454 | errors (except for macro syntax) to become unknown-modifier. 1455 | 1456 | modifier = explanation | redirect | unknown-modifier 1457 | 1458 | However, it is generally agreed, with precedent in other RFCs, 1459 | that unknown-modifier should not be "greedy", and should not 1460 | match known modifier names. There should have been explicit 1461 | prose to this effect, and some has been proposed as an erratum. 1462 | spec: 6.1/2 1463 | helo: mail.example.com 1464 | host: 1.2.3.4 1465 | mailfrom: foo@e17.example.com 1466 | result: permerror 1467 | include-ignores-exp: 1468 | description: >- 1469 | when executing "include", exp= from the target domain MUST NOT be used. 1470 | spec: 6.2/13 1471 | helo: mail.example.com 1472 | host: 1.2.3.4 1473 | mailfrom: foo@e7.example.com 1474 | result: fail 1475 | explanation: Correct! 1476 | redirect-cancels-prior-exp: 1477 | description: >- 1478 | when executing "redirect", exp= from the original domain MUST NOT be used. 1479 | spec: 6.2/13 1480 | helo: mail.example.com 1481 | host: 1.2.3.4 1482 | mailfrom: foo@e3.example.com 1483 | result: fail 1484 | explanation: See me. 1485 | invalid-modifier: 1486 | description: | 1487 | unknown-modifier = name "=" macro-string 1488 | name = ALPHA *( ALPHA / DIGIT / "-" / "_" / "." ) 1489 | comment: >- 1490 | Unknown modifier name must begin with alpha. 1491 | spec: A/3 1492 | helo: mail.example.com 1493 | host: 1.2.3.4 1494 | mailfrom: foo@e5.example.com 1495 | result: permerror 1496 | empty-modifier-name: 1497 | description: | 1498 | name = ALPHA *( ALPHA / DIGIT / "-" / "_" / "." ) 1499 | comment: >- 1500 | Unknown modifier name must not be empty. 1501 | spec: A/3 1502 | helo: mail.example.com 1503 | host: 1.2.3.4 1504 | mailfrom: foo@e6.example.com 1505 | result: permerror 1506 | dorky-sentinel: 1507 | description: >- 1508 | An implementation that uses a legal expansion as a sentinel. We 1509 | cannot check them all, but we can check this one. 1510 | comment: >- 1511 | Spaces are allowed in local-part. 1512 | spec: 8.1/6 1513 | helo: mail.example.com 1514 | host: 1.2.3.4 1515 | mailfrom: "Macro Error@e8.example.com" 1516 | result: fail 1517 | explanation: Macro Error in implementation 1518 | exp-multiple-txt: 1519 | description: | 1520 | Ignore exp if multiple TXT records. 1521 | comment: >- 1522 | If domain-spec is empty, or there are any DNS processing errors (any 1523 | RCODE other than 0), or if no records are returned, or if more than one 1524 | record is returned, or if there are syntax errors in the explanation 1525 | string, then proceed as if no exp modifier was given. 1526 | spec: 6.2/4 1527 | helo: mail.example.com 1528 | host: 1.2.3.4 1529 | mailfrom: foo@e11.example.com 1530 | result: fail 1531 | explanation: DEFAULT 1532 | exp-no-txt: 1533 | description: | 1534 | Ignore exp if no TXT records. 1535 | comment: >- 1536 | If domain-spec is empty, or there are any DNS processing errors (any 1537 | RCODE other than 0), or if no records are returned, or if more than one 1538 | record is returned, or if there are syntax errors in the explanation 1539 | string, then proceed as if no exp modifier was given. 1540 | spec: 6.2/4 1541 | helo: mail.example.com 1542 | host: 1.2.3.4 1543 | mailfrom: foo@e22.example.com 1544 | result: fail 1545 | explanation: DEFAULT 1546 | exp-dns-error: 1547 | description: | 1548 | Ignore exp if DNS error. 1549 | comment: >- 1550 | If domain-spec is empty, or there are any DNS processing errors (any 1551 | RCODE other than 0), or if no records are returned, or if more than one 1552 | record is returned, or if there are syntax errors in the explanation 1553 | string, then proceed as if no exp modifier was given. 1554 | spec: 6.2/4 1555 | helo: mail.example.com 1556 | host: 1.2.3.4 1557 | mailfrom: foo@e21.example.com 1558 | result: fail 1559 | explanation: DEFAULT 1560 | exp-empty-domain: 1561 | description: | 1562 | PermError if exp= domain-spec is empty. 1563 | comment: >- 1564 | Section 6.2/4 says, "If domain-spec is empty, or there are any DNS 1565 | processing errors (any RCODE other than 0), or if no records are 1566 | returned, or if more than one record is returned, or if there are syntax 1567 | errors in the explanation string, then proceed as if no exp modifier was 1568 | given." However, "if domain-spec is empty" conflicts with the grammar 1569 | given for the exp modifier. This was reported as an erratum, and the 1570 | solution chosen was to report explicit "exp=" as PermError, but ignore 1571 | problems due to macro expansion, DNS, or invalid explanation string. 1572 | spec: 6.2/4 1573 | helo: mail.example.com 1574 | host: 1.2.3.4 1575 | mailfrom: foo@e12.example.com 1576 | result: permerror 1577 | explanation-syntax-error: 1578 | description: | 1579 | Ignore exp if the explanation string has a syntax error. 1580 | comment: >- 1581 | If domain-spec is empty, or there are any DNS processing errors (any 1582 | RCODE other than 0), or if no records are returned, or if more than one 1583 | record is returned, or if there are syntax errors in the explanation 1584 | string, then proceed as if no exp modifier was given. 1585 | spec: 6.2/4 1586 | helo: mail.example.com 1587 | host: 1.2.3.4 1588 | mailfrom: foo@e13.example.com 1589 | result: fail 1590 | explanation: DEFAULT 1591 | exp-syntax-error: 1592 | description: | 1593 | explanation = "exp" "=" domain-spec 1594 | comment: >- 1595 | A literal application of the grammar causes modifier syntax 1596 | errors (except for macro syntax) to become unknown-modifier. 1597 | 1598 | modifier = explanation | redirect | unknown-modifier 1599 | 1600 | However, it is generally agreed, with precedent in other RFCs, 1601 | that unknown-modifier should not be "greedy", and should not 1602 | match known modifier names. There should have been explicit 1603 | prose to this effect, and some has been proposed as an erratum. 1604 | spec: 6.2/1 1605 | helo: mail.example.com 1606 | host: 1.2.3.4 1607 | mailfrom: foo@e16.example.com 1608 | result: permerror 1609 | exp-twice: 1610 | description: | 1611 | exp= appears twice. 1612 | comment: >- 1613 | These two modifiers (exp,redirect) MUST NOT appear in a record more than 1614 | once each. If they do, then check_host() exits with a result of 1615 | "PermError". 1616 | spec: 6/2 1617 | helo: mail.example.com 1618 | host: 1.2.3.4 1619 | mailfrom: foo@e14.example.com 1620 | result: permerror 1621 | redirect-empty-domain: 1622 | description: | 1623 | redirect = "redirect" "=" domain-spec 1624 | comment: >- 1625 | Unlike for exp, there is no instruction to override the permerror 1626 | for an empty domain-spec (which is invalid syntax). 1627 | spec: 6.2/4 1628 | helo: mail.example.com 1629 | host: 1.2.3.4 1630 | mailfrom: foo@e18.example.com 1631 | result: permerror 1632 | redirect-twice: 1633 | description: | 1634 | redirect= appears twice. 1635 | comment: >- 1636 | These two modifiers (exp,redirect) MUST NOT appear in a record more than 1637 | once each. If they do, then check_host() exits with a result of 1638 | "PermError". 1639 | spec: 6/2 1640 | helo: mail.example.com 1641 | host: 1.2.3.4 1642 | mailfrom: foo@e15.example.com 1643 | result: permerror 1644 | unknown-modifier-syntax: 1645 | description: | 1646 | unknown-modifier = name "=" macro-string 1647 | comment: >- 1648 | Unknown modifiers must have valid macro syntax. 1649 | spec: A/3 1650 | helo: mail.example.com 1651 | host: 1.2.3.4 1652 | mailfrom: foo@e9.example.com 1653 | result: permerror 1654 | default-modifier-obsolete: 1655 | description: | 1656 | Unknown modifiers do not modify the RFC SPF result. 1657 | comment: >- 1658 | Some implementations may have a leftover default= modifier from 1659 | earlier drafts. 1660 | spec: 6/3 1661 | helo: mail.example.com 1662 | host: 1.2.3.4 1663 | mailfrom: foo@e19.example.com 1664 | result: neutral 1665 | default-modifier-obsolete2: 1666 | description: | 1667 | Unknown modifiers do not modify the RFC SPF result. 1668 | comment: >- 1669 | Some implementations may have a leftover default= modifier from 1670 | earlier drafts. 1671 | spec: 6/3 1672 | helo: mail.example.com 1673 | host: 1.2.3.4 1674 | mailfrom: foo@e20.example.com 1675 | result: neutral 1676 | zonedata: 1677 | mail.example.com: 1678 | - A: 1.2.3.4 1679 | e1.example.com: 1680 | - SPF: v=spf1 exp=exp1.example.com redirect=e2.example.com 1681 | e2.example.com: 1682 | - SPF: v=spf1 -all 1683 | e3.example.com: 1684 | - SPF: v=spf1 exp=exp1.example.com redirect=e4.example.com 1685 | e4.example.com: 1686 | - SPF: v=spf1 -all exp=exp2.example.com 1687 | exp1.example.com: 1688 | - TXT: No-see-um 1689 | exp2.example.com: 1690 | - TXT: See me. 1691 | exp3.example.com: 1692 | - TXT: Correct! 1693 | exp4.example.com: 1694 | - TXT: "%{l} in implementation" 1695 | e5.example.com: 1696 | - SPF: v=spf1 1up=foo 1697 | e6.example.com: 1698 | - SPF: v=spf1 =all 1699 | e7.example.com: 1700 | - SPF: v=spf1 include:e3.example.com -all exp=exp3.example.com 1701 | e8.example.com: 1702 | - SPF: v=spf1 -all exp=exp4.example.com 1703 | e9.example.com: 1704 | - SPF: v=spf1 -all foo=%abc 1705 | e10.example.com: 1706 | - SPF: v=spf1 redirect=erehwon.example.com 1707 | e11.example.com: 1708 | - SPF: v=spf1 -all exp=e11msg.example.com 1709 | e11msg.example.com: 1710 | - TXT: Answer a fool according to his folly. 1711 | - TXT: Do not answer a fool according to his folly. 1712 | e12.example.com: 1713 | - SPF: v=spf1 exp= -all 1714 | e13.example.com: 1715 | - SPF: v=spf1 exp=e13msg.example.com -all 1716 | e13msg.example.com: 1717 | - TXT: The %{x}-files. 1718 | e14.example.com: 1719 | - SPF: v=spf1 exp=e13msg.example.com -all exp=e11msg.example.com 1720 | e15.example.com: 1721 | - SPF: v=spf1 redirect=e12.example.com -all redirect=e12.example.com 1722 | e16.example.com: 1723 | - SPF: v=spf1 exp=-all 1724 | e17.example.com: 1725 | - SPF: v=spf1 redirect=-all ?all 1726 | e18.example.com: 1727 | - SPF: v=spf1 ?all redirect= 1728 | e19.example.com: 1729 | - SPF: v=spf1 default=pass 1730 | e20.example.com: 1731 | - SPF: "v=spf1 default=+" 1732 | e21.example.com: 1733 | - SPF: v=spf1 exp=e21msg.example.com -all 1734 | e21msg.example.com: 1735 | - TIMEOUT 1736 | e22.example.com: 1737 | - SPF: v=spf1 exp=mail.example.com -all 1738 | --- 1739 | description: Macro expansion rules 1740 | tests: 1741 | trailing-dot-domain: 1742 | spec: 8.1/16 1743 | description: >- 1744 | trailing dot is ignored for domains 1745 | helo: msgbas2x.cos.example.com 1746 | host: 192.168.218.40 1747 | mailfrom: test@example.com 1748 | result: pass 1749 | trailing-dot-exp: 1750 | spec: 8.1 1751 | description: >- 1752 | trailing dot is not removed from explanation 1753 | comment: >- 1754 | A simple way for an implementation to ignore trailing dots on 1755 | domains is to remove it when present. But be careful not to 1756 | remove it for explanation text. 1757 | helo: msgbas2x.cos.example.com 1758 | host: 192.168.218.40 1759 | mailfrom: test@exp.example.com 1760 | result: fail 1761 | explanation: This is a test. 1762 | exp-only-macro-char: 1763 | spec: 8.1/8 1764 | description: >- 1765 | The following macro letters are allowed only in "exp" text: c, r, t 1766 | helo: msgbas2x.cos.example.com 1767 | host: 192.168.218.40 1768 | mailfrom: test@e2.example.com 1769 | result: permerror 1770 | invalid-macro-char: 1771 | spec: 8.1/9 1772 | description: >- 1773 | A '%' character not followed by a '{', '%', '-', or '_' character 1774 | is a syntax error. 1775 | helo: msgbas2x.cos.example.com 1776 | host: 192.168.218.40 1777 | mailfrom: test@e1.example.com 1778 | result: permerror 1779 | macro-mania-in-domain: 1780 | description: >- 1781 | macro-encoded percents (%%), spaces (%_), and URL-percent-encoded 1782 | spaces (%-) 1783 | spec: 8.1/3, 8.1/4 1784 | helo: mail.example.com 1785 | host: 1.2.3.4 1786 | mailfrom: test@e1a.example.com 1787 | result: pass 1788 | exp-txt-macro-char: 1789 | spec: 8.1/20 1790 | description: >- 1791 | For IPv4 addresses, both the "i" and "c" macros expand 1792 | to the standard dotted-quad format. 1793 | helo: msgbas2x.cos.example.com 1794 | host: 192.168.218.40 1795 | mailfrom: test@e3.example.com 1796 | result: fail 1797 | explanation: Connections from 192.168.218.40 not authorized. 1798 | domain-name-truncation: 1799 | spec: 8.1/25 1800 | description: >- 1801 | When the result of macro expansion is used in a domain name query, if the 1802 | expanded domain name exceeds 253 characters, the left side is truncated 1803 | to fit, by removing successive domain labels until the total length does 1804 | not exceed 253 characters. 1805 | helo: msgbas2x.cos.example.com 1806 | host: 192.168.218.40 1807 | mailfrom: test@somewhat.long.exp.example.com 1808 | result: fail 1809 | explanation: Congratulations! That was tricky. 1810 | v-macro-ip4: 1811 | spec: 8.1/6 1812 | description: |- 1813 | v = the string "in-addr" if is ipv4, or "ip6" if is ipv6 1814 | helo: msgbas2x.cos.example.com 1815 | host: 192.168.218.40 1816 | mailfrom: test@e4.example.com 1817 | result: fail 1818 | explanation: 192.168.218.40 is queried as 40.218.168.192.in-addr.arpa 1819 | v-macro-ip6: 1820 | spec: 8.1/6 1821 | description: |- 1822 | v = the string "in-addr" if is ipv4, or "ip6" if is ipv6 1823 | helo: msgbas2x.cos.example.com 1824 | host: CAFE:BABE::1 1825 | mailfrom: test@e4.example.com 1826 | result: fail 1827 | explanation: cafe:babe::1 is queried as 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.E.B.A.B.E.F.A.C.ip6.arpa 1828 | undef-macro: 1829 | spec: 8.1/6 1830 | description: >- 1831 | Allowed macros chars are 'slodipvh' plus 'crt' in explanation. 1832 | helo: msgbas2x.cos.example.com 1833 | host: CAFE:BABE::192.168.218.40 1834 | mailfrom: test@e5.example.com 1835 | result: permerror 1836 | p-macro-ip4-novalid: 1837 | spec: 8.1/22 1838 | description: |- 1839 | p = the validated domain name of 1840 | comment: >- 1841 | The PTR in this example does not validate. 1842 | helo: msgbas2x.cos.example.com 1843 | host: 192.168.218.40 1844 | mailfrom: test@e6.example.com 1845 | result: fail 1846 | explanation: connect from unknown 1847 | p-macro-ip4-valid: 1848 | spec: 8.1/22 1849 | description: |- 1850 | p = the validated domain name of 1851 | comment: >- 1852 | If a subdomain of the is present, it SHOULD be used. 1853 | helo: msgbas2x.cos.example.com 1854 | host: 192.168.218.41 1855 | mailfrom: test@e6.example.com 1856 | result: fail 1857 | explanation: connect from mx.example.com 1858 | p-macro-ip6-novalid: 1859 | spec: 8.1/22 1860 | description: |- 1861 | p = the validated domain name of 1862 | comment: >- 1863 | The PTR in this example does not validate. 1864 | helo: msgbas2x.cos.example.com 1865 | host: CAFE:BABE::1 1866 | mailfrom: test@e6.example.com 1867 | result: fail 1868 | explanation: connect from unknown 1869 | p-macro-ip6-valid: 1870 | spec: 8.1/22 1871 | description: |- 1872 | p = the validated domain name of 1873 | comment: >- 1874 | If a subdomain of the is present, it SHOULD be used. 1875 | helo: msgbas2x.cos.example.com 1876 | host: CAFE:BABE::3 1877 | mailfrom: test@e6.example.com 1878 | result: fail 1879 | explanation: connect from mx.example.com 1880 | p-macro-multiple: 1881 | spec: 8.1/22 1882 | description: |- 1883 | p = the validated domain name of 1884 | comment: >- 1885 | If a subdomain of the is present, it SHOULD be used. 1886 | helo: msgbas2x.cos.example.com 1887 | host: 192.168.218.42 1888 | mailfrom: test@e7.example.com 1889 | result: [pass, softfail] 1890 | upper-macro: 1891 | spec: 8.1/26 1892 | description: >- 1893 | Uppercased macros expand exactly as their lowercased equivalents, 1894 | and are then URL escaped. 1895 | helo: msgbas2x.cos.example.com 1896 | host: 192.168.218.42 1897 | mailfrom: jack&jill=up@e8.example.com 1898 | result: fail 1899 | explanation: http://example.com/why.html?l=jack%26jill%3Dup 1900 | hello-macro: 1901 | spec: 8.1/6 1902 | description: |- 1903 | h = HELO/EHLO domain 1904 | helo: msgbas2x.cos.example.com 1905 | host: 192.168.218.40 1906 | mailfrom: test@e9.example.com 1907 | result: pass 1908 | invalid-hello-macro: 1909 | spec: 8.1/2 1910 | description: |- 1911 | h = HELO/EHLO domain, but HELO is invalid 1912 | comment: >- 1913 | Domain-spec must end in either a macro, or a valid toplabel. 1914 | It is not correct to check syntax after macro expansion. 1915 | helo: "JUMPIN' JUPITER" 1916 | host: 192.168.218.40 1917 | mailfrom: test@e9.example.com 1918 | result: fail 1919 | hello-domain-literal: 1920 | spec: 8.1/2 1921 | description: |- 1922 | h = HELO/EHLO domain, but HELO is a domain literal 1923 | comment: >- 1924 | Domain-spec must end in either a macro, or a valid toplabel. 1925 | It is not correct to check syntax after macro expansion. 1926 | helo: "[192.168.218.40]" 1927 | host: 192.168.218.40 1928 | mailfrom: test@e9.example.com 1929 | result: fail 1930 | require-valid-helo: 1931 | spec: 8.1/6 1932 | description: >- 1933 | Example of requiring valid helo in sender policy. This is a complex 1934 | policy testing several points at once. 1935 | helo: OEMCOMPUTER 1936 | host: 1.2.3.4 1937 | mailfrom: test@e10.example.com 1938 | result: fail 1939 | macro-reverse-split-on-dash: 1940 | spec: [8.1/15, 8.1/16, 8.1/17, 8.1/18] 1941 | description: >- 1942 | Macro value transformation (splitting on arbitrary characters, reversal, 1943 | number of right-hand parts to use) 1944 | helo: mail.example.com 1945 | host: 1.2.3.4 1946 | mailfrom: philip-gladstone-test@e11.example.com 1947 | result: pass 1948 | macro-multiple-delimiters: 1949 | spec: [8.1/15, 8.1/16] 1950 | description: |- 1951 | Multiple delimiters may be specified in a macro expression. 1952 | macro-expand = ( "%{" macro-letter transformers *delimiter "}" ) 1953 | / "%%" / "%_" / "%-" 1954 | helo: mail.example.com 1955 | host: 1.2.3.4 1956 | mailfrom: foo-bar+zip+quux@e12.example.com 1957 | result: pass 1958 | zonedata: 1959 | example.com.d.spf.example.com: 1960 | - SPF: v=spf1 redirect=a.spf.example.com 1961 | a.spf.example.com: 1962 | - SPF: v=spf1 include:o.spf.example.com. ~all 1963 | o.spf.example.com: 1964 | - SPF: v=spf1 ip4:192.168.218.40 1965 | msgbas2x.cos.example.com: 1966 | - A: 192.168.218.40 1967 | example.com: 1968 | - A: 192.168.90.76 1969 | - SPF: v=spf1 redirect=%{d}.d.spf.example.com. 1970 | exp.example.com: 1971 | - SPF: v=spf1 exp=msg.example.com. -all 1972 | msg.example.com: 1973 | - TXT: This is a test. 1974 | e1.example.com: 1975 | - SPF: v=spf1 -exists:%(ir).sbl.example.com ?all 1976 | e1a.example.com: 1977 | - SPF: "v=spf1 a:macro%%percent%_%_space%-url-space.example.com -all" 1978 | "macro%percent space%20url-space.example.com": 1979 | - A: 1.2.3.4 1980 | e2.example.com: 1981 | - SPF: v=spf1 -all exp=%{r}.example.com 1982 | e3.example.com: 1983 | - SPF: v=spf1 -all exp=%{ir}.example.com 1984 | 40.218.168.192.example.com: 1985 | - TXT: Connections from %{c} not authorized. 1986 | somewhat.long.exp.example.com: 1987 | - SPF: v=spf1 -all exp=foobar.%{o}.%{o}.%{o}.%{o}.%{o}.%{o}.%{o}.%{o}.example.com 1988 | somewhat.long.exp.example.com.somewhat.long.exp.example.com.somewhat.long.exp.example.com.somewhat.long.exp.example.com.somewhat.long.exp.example.com.somewhat.long.exp.example.com.somewhat.long.exp.example.com.somewhat.long.exp.example.com.example.com: 1989 | - TXT: Congratulations! That was tricky. 1990 | e4.example.com: 1991 | - SPF: v=spf1 -all exp=e4msg.example.com 1992 | e4msg.example.com: 1993 | - TXT: "%{c} is queried as %{ir}.%{v}.arpa" 1994 | e5.example.com: 1995 | - SPF: v=spf1 a:%{a}.example.com -all 1996 | e6.example.com: 1997 | - SPF: v=spf1 -all exp=e6msg.example.com 1998 | e6msg.example.com: 1999 | - TXT: "connect from %{p}" 2000 | mx.example.com: 2001 | - A: 192.168.218.41 2002 | - A: 192.168.218.42 2003 | - AAAA: CAFE:BABE::2 2004 | - AAAA: CAFE:BABE::3 2005 | 40.218.168.192.in-addr.arpa: 2006 | - PTR: mx.example.com 2007 | 41.218.168.192.in-addr.arpa: 2008 | - PTR: mx.example.com 2009 | 42.218.168.192.in-addr.arpa: 2010 | - PTR: mx.example.com 2011 | - PTR: mx.e7.example.com 2012 | 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.E.B.A.B.E.F.A.C.ip6.arpa: 2013 | - PTR: mx.example.com 2014 | 3.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.E.B.A.B.E.F.A.C.ip6.arpa: 2015 | - PTR: mx.example.com 2016 | mx.e7.example.com: 2017 | - A: 192.168.218.42 2018 | mx.e7.example.com.should.example.com: 2019 | - A: 127.0.0.2 2020 | mx.example.com.ok.example.com: 2021 | - A: 127.0.0.2 2022 | e7.example.com: 2023 | - SPF: v=spf1 exists:%{p}.should.example.com ~exists:%{p}.ok.example.com 2024 | e8.example.com: 2025 | - SPF: v=spf1 -all exp=msg8.%{D2} 2026 | msg8.example.com: 2027 | - TXT: "http://example.com/why.html?l=%{L}" 2028 | e9.example.com: 2029 | - SPF: v=spf1 a:%{H} -all 2030 | e10.example.com: 2031 | - SPF: v=spf1 -include:_spfh.%{d2} ip4:1.2.3.0/24 -all 2032 | _spfh.example.com: 2033 | - SPF: v=spf1 -a:%{h} +all 2034 | e11.example.com: 2035 | - SPF: v=spf1 exists:%{i}.%{l2r-}.user.%{d2} 2036 | 1.2.3.4.gladstone.philip.user.example.com: 2037 | - A: 127.0.0.2 2038 | e12.example.com: 2039 | - SPF: v=spf1 exists:%{l2r+-}.user.%{d2} 2040 | bar.foo.user.example.com: 2041 | - A: 127.0.0.2 2042 | --- 2043 | description: Processing limits 2044 | tests: 2045 | redirect-loop: 2046 | description: >- 2047 | SPF implementations MUST limit the number of mechanisms and modifiers 2048 | that do DNS lookups to at most 10 per SPF check. 2049 | spec: 10.1/6 2050 | helo: mail.example.com 2051 | host: 1.2.3.4 2052 | mailfrom: foo@e1.example.com 2053 | result: permerror 2054 | include-loop: 2055 | description: >- 2056 | SPF implementations MUST limit the number of mechanisms and modifiers 2057 | that do DNS lookups to at most 10 per SPF check. 2058 | spec: 10.1/6 2059 | helo: mail.example.com 2060 | host: 1.2.3.4 2061 | mailfrom: foo@e2.example.com 2062 | result: permerror 2063 | mx-limit: 2064 | description: >- 2065 | there MUST be a limit of no more than 10 MX looked up and checked. 2066 | comment: >- 2067 | The required result for this test was the subject of much 2068 | controversy. Many felt that the RFC *should* have specified 2069 | permerror, but the consensus was that it failed to actually do so. 2070 | The preferred result reflects evaluating the 10 allowed MX records in the 2071 | order returned by the test data - or sorted via priority. 2072 | If testing with live DNS, the MX order may be random, and a pass 2073 | result would still be compliant. The SPF result is effectively 2074 | random. 2075 | spec: 10.1/7 2076 | helo: mail.example.com 2077 | host: 1.2.3.5 2078 | mailfrom: foo@e4.example.com 2079 | result: [neutral, pass] 2080 | ptr-limit: 2081 | description: >- 2082 | there MUST be a limit of no more than 10 PTR looked up and checked. 2083 | comment: >- 2084 | The result of this test cannot be permerror not only because the 2085 | RFC does not specify it, but because the sender has no control over 2086 | the PTR records of spammers. 2087 | The preferred result reflects evaluating the 10 allowed PTR records in 2088 | the order returned by the test data. 2089 | If testing with live DNS, the PTR order may be random, and a pass 2090 | result would still be compliant. The SPF result is effectively 2091 | randomized. 2092 | spec: 10.1/7 2093 | helo: mail.example.com 2094 | host: 1.2.3.5 2095 | mailfrom: foo@e5.example.com 2096 | result: [neutral, pass] 2097 | false-a-limit: 2098 | description: >- 2099 | unlike MX, PTR, there is no RR limit for A 2100 | comment: >- 2101 | There seems to be a tendency for developers to want to limit 2102 | A RRs in addition to MX and PTR. These are IPs, not usable for 2103 | 3rd party DoS attacks, and hence need no low limit. 2104 | spec: 10.1/7 2105 | helo: mail.example.com 2106 | host: 1.2.3.12 2107 | mailfrom: foo@e10.example.com 2108 | result: pass 2109 | mech-at-limit: 2110 | description: >- 2111 | SPF implementations MUST limit the number of mechanisms and modifiers 2112 | that do DNS lookups to at most 10 per SPF check. 2113 | spec: 10.1/6 2114 | helo: mail.example.com 2115 | host: 1.2.3.4 2116 | mailfrom: foo@e6.example.com 2117 | result: pass 2118 | mech-over-limit: 2119 | description: >- 2120 | SPF implementations MUST limit the number of mechanisms and modifiers 2121 | that do DNS lookups to at most 10 per SPF check. 2122 | comment: >- 2123 | We do not check whether an implementation counts mechanisms before 2124 | or after evaluation. The RFC is not clear on this. 2125 | spec: 10.1/6 2126 | helo: mail.example.com 2127 | host: 1.2.3.4 2128 | mailfrom: foo@e7.example.com 2129 | result: permerror 2130 | include-at-limit: 2131 | description: >- 2132 | SPF implementations MUST limit the number of mechanisms and modifiers 2133 | that do DNS lookups to at most 10 per SPF check. 2134 | comment: >- 2135 | The part of the RFC that talks about MAY parse the entire record first 2136 | (4.6) is specific to syntax errors. Processing limits is a different, 2137 | non-syntax issue. Processing limits (10.1) specifically talks about 2138 | limits during a check. 2139 | spec: 10.1/6 2140 | helo: mail.example.com 2141 | host: 1.2.3.4 2142 | mailfrom: foo@e8.example.com 2143 | result: pass 2144 | include-over-limit: 2145 | description: >- 2146 | SPF implementations MUST limit the number of mechanisms and modifiers 2147 | that do DNS lookups to at most 10 per SPF check. 2148 | spec: 10.1/6 2149 | helo: mail.example.com 2150 | host: 1.2.3.4 2151 | mailfrom: foo@e9.example.com 2152 | result: permerror 2153 | zonedata: 2154 | mail.example.com: 2155 | - A: 1.2.3.4 2156 | e1.example.com: 2157 | - SPF: v=spf1 ip4:1.1.1.1 redirect=e1.example.com 2158 | e2.example.com: 2159 | - SPF: v=spf1 include:e3.example.com 2160 | e3.example.com: 2161 | - SPF: v=spf1 include:e2.example.com 2162 | e4.example.com: 2163 | - SPF: v=spf1 mx 2164 | - MX: [0, mail.example.com] 2165 | - MX: [1, mail.example.com] 2166 | - MX: [2, mail.example.com] 2167 | - MX: [3, mail.example.com] 2168 | - MX: [4, mail.example.com] 2169 | - MX: [5, mail.example.com] 2170 | - MX: [6, mail.example.com] 2171 | - MX: [7, mail.example.com] 2172 | - MX: [8, mail.example.com] 2173 | - MX: [9, mail.example.com] 2174 | - MX: [10, e4.example.com] 2175 | - A: 1.2.3.5 2176 | e5.example.com: 2177 | - SPF: v=spf1 ptr 2178 | - A: 1.2.3.5 2179 | 5.3.2.1.in-addr.arpa: 2180 | - PTR: e1.example.com. 2181 | - PTR: e2.example.com. 2182 | - PTR: e3.example.com. 2183 | - PTR: e4.example.com. 2184 | - PTR: example.com. 2185 | - PTR: e6.example.com. 2186 | - PTR: e7.example.com. 2187 | - PTR: e8.example.com. 2188 | - PTR: e9.example.com. 2189 | - PTR: e10.example.com. 2190 | - PTR: e5.example.com. 2191 | e6.example.com: 2192 | - SPF: v=spf1 a mx a mx a mx a mx a ptr ip4:1.2.3.4 -all 2193 | e7.example.com: 2194 | - SPF: v=spf1 a mx a mx a mx a mx a ptr a ip4:1.2.3.4 -all 2195 | e8.example.com: 2196 | - SPF: v=spf1 a include:inc.example.com ip4:1.2.3.4 mx -all 2197 | inc.example.com: 2198 | - SPF: v=spf1 a a a a a a a a 2199 | e9.example.com: 2200 | - SPF: v=spf1 a include:inc.example.com a ip4:1.2.3.4 -all 2201 | e10.example.com: 2202 | - SPF: v=spf1 a -all 2203 | - A: 1.2.3.1 2204 | - A: 1.2.3.2 2205 | - A: 1.2.3.3 2206 | - A: 1.2.3.4 2207 | - A: 1.2.3.5 2208 | - A: 1.2.3.6 2209 | - A: 1.2.3.7 2210 | - A: 1.2.3.8 2211 | - A: 1.2.3.9 2212 | - A: 1.2.3.10 2213 | - A: 1.2.3.11 2214 | - A: 1.2.3.12 2215 | -------------------------------------------------------------------------------- /spec/rfc4408_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | require File.expand_path(File.dirname(__FILE__) + '/spf_test_lib') 3 | 4 | # TODO: Make this work! 5 | #describe 'RFC4408 tests' do 6 | # SPFTestLib.run_spf_test_suite_file(File.dirname(__FILE__) + '/rfc4408-tests.yml') 7 | #end 8 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | require File.expand_path(File.dirname(__FILE__) + '/resolv_programmable') 3 | 4 | test_resolver_empty = Resolv::DNS::Programmable.new ({ 5 | records: {} 6 | }) 7 | 8 | test_resolver_1 = Resolv::DNS::Programmable.new ({ 9 | records: { 10 | 'example.com' => [ 11 | Resolv::DNS::Resource::IN::A.new('192.168.0.1') 12 | ] 13 | } 14 | }) 15 | 16 | test_resolver_redirect = Resolv::DNS::Programmable.new ({ 17 | records: { 18 | 'example.com' => [ 19 | Resolv::DNS::Resource::IN::TXT.new('v=spf1 redirect=foo.example.com') 20 | ], 21 | 'foo.example.com' => [ 22 | Resolv::DNS::Resource::IN::TXT.new('v=spf1 ~all') 23 | ] 24 | } 25 | }) 26 | 27 | test_resolver_nxdomain = Resolv::DNS::Programmable.new ({ 28 | resolver_code: lambda { |name, typeclass| 29 | next Resolv::DNS::RCode::NXDomain 30 | } 31 | }) 32 | 33 | test_resolver_servfail = Resolv::DNS::Programmable.new ({ 34 | resolver_code: lambda { |name, typeclass| 35 | next Resolv::DNS::RCode::ServFail 36 | } 37 | }) 38 | 39 | test_resolver_poorly_formed_record_address = Resolv::DNS::Programmable.new ({ 40 | records: { 41 | 'carminic.us' => [ 42 | Resolv::DNS::Resource::IN::TXT.new('v=spf1 ip6: 9C55::5E3D -all') 43 | ], 44 | } 45 | }) 46 | 47 | describe 'basic instantiation' do 48 | server = SPF::Server.new( 49 | dns_resolver: test_resolver_empty, 50 | max_dns_interactive_terms: 1, 51 | max_name_lookups_per_term: 2, 52 | max_name_lookups_per_mx_mech: 3, 53 | max_name_lookups_per_ptr_mech: 2, 54 | ) 55 | 56 | it 'has a basic dns_resolver' do 57 | expect(server.dns_resolver).to be_a Resolv::DNS::Programmable 58 | end 59 | it 'has correct max_dns_interactive_terms' do 60 | expect(server.max_dns_interactive_terms).to be 1 61 | end 62 | it 'has correct max_name_lookups_per_term' do 63 | expect(server.max_name_lookups_per_term).to be 2 64 | end 65 | it 'has correct max_name_lookups_per_mx_mech' do 66 | expect(server.max_name_lookups_per_mx_mech).to be 3 67 | end 68 | it 'has correct max_name_lookups_per_ptr_mech' do 69 | expect(server.max_name_lookups_per_ptr_mech).to be 2 70 | end 71 | end 72 | 73 | describe 'minimal parameterized server' do 74 | server = SPF::Server.new 75 | it 'has default dns_resolver' do 76 | expect(server.dns_resolver).to be_a Resolv::DNS 77 | end 78 | it 'has default max_dns_interactive_terms' do 79 | expect(server.max_dns_interactive_terms).to be 10 80 | end 81 | it 'has default max_name_lookups_per_term' do 82 | expect(server.max_name_lookups_per_term).to be 10 83 | end 84 | it 'has default max_name_lookups_per_mx_mech' do 85 | expect(server.max_name_lookups_per_mx_mech).to be 10 86 | end 87 | it 'has default max_name_lookups_per_ptr_mech' do 88 | expect(server.max_name_lookups_per_ptr_mech).to be 10 89 | end 90 | end 91 | 92 | describe 'dns_lookup' do 93 | server = SPF::Server.new( 94 | dns_resolver: test_resolver_empty 95 | ) 96 | it 'returns empty response' do 97 | expect { server.dns_lookup('example.com', 'A') }.to raise_error(SPF::DNSNXDomainError) 98 | end 99 | end 100 | 101 | describe 'A-record lookup' do 102 | server = SPF::Server.new( 103 | dns_resolver: test_resolver_1 104 | ) 105 | packet = server.dns_lookup('example.com', 'A') 106 | it 'returns 1-record result set' do 107 | expect(packet.length).to be 1 108 | end 109 | it 'returns correct address' do 110 | expect(packet[0]).to eq Resolv::DNS::Resource::IN::A.new('192.168.0.1') 111 | end 112 | end 113 | 114 | describe 'NXDOMAIN lookup' do 115 | server = SPF::Server.new( 116 | dns_resolver: test_resolver_nxdomain 117 | ) 118 | it 'returns empty result set' do 119 | expect { server.dns_lookup('example.com', 'A') }.to raise_error(SPF::DNSNXDomainError) 120 | end 121 | end 122 | 123 | describe 'SERVFAIL lookup' do 124 | server = SPF::Server.new( 125 | dns_resolver: test_resolver_servfail 126 | ) 127 | packet = server.dns_lookup('example.com', 'A') 128 | it 'returns empty result set??' do 129 | expect(packet).to eq [] 130 | end 131 | end 132 | 133 | describe 'redirect lookup' do 134 | server = SPF::Server.new( 135 | dns_resolver: test_resolver_redirect 136 | ) 137 | request = SPF::Request.new( 138 | versions: [1], 139 | scope: :mfrom, 140 | identity: 'example.com', 141 | ip_address: '10.0.0.1' 142 | ) 143 | it 'should give softfail result on redirect: -> ~all' do 144 | expect(server.process(request)).to be_a SPF::Result::SoftFail 145 | end 146 | end 147 | 148 | 149 | 150 | #### SPF Record Selection / select_record(), get_acceptable_records_from_packet() #### 151 | 152 | # See also the RFC 4408 test suite. 153 | 154 | describe 'bad record address' do 155 | server = SPF::Server.new( 156 | dns_resolver: test_resolver_poorly_formed_record_address, 157 | raise_exceptions: false, 158 | ) 159 | request = SPF::Request.new( 160 | versions: [1], 161 | scope: :mfrom, 162 | identity: 'carminic.us', 163 | ) 164 | it 'returns a record with errors' do 165 | spf_record_error_types = server.select_record(request).errors.map {|err| err.class } 166 | expect(spf_record_error_types).to include(SPF::TermIPv6AddressExpectedError) 167 | end 168 | 169 | end 170 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 5 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 6 | require 'rspec' 7 | require 'spf' 8 | 9 | # Requires supporting files with custom matchers and macros, etc, 10 | # in ./support/ and its subdirectories. 11 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 12 | 13 | RSpec.configure do |config| 14 | end 15 | -------------------------------------------------------------------------------- /spec/spf_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | 3 | describe 'SPF' do 4 | end 5 | -------------------------------------------------------------------------------- /spec/spf_test_lib.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/resolv_programmable') 2 | 3 | require 'spf/test' 4 | 5 | class SPFTestLib 6 | def self.run_spf_test_suite_file(file_name, test_case_overrides = nil) 7 | test_case_overrides ||= {} 8 | 9 | test_suite = SPF::Test::new_from_yaml_file(file_name) 10 | unless test_suite 11 | raise StandardError, "Unable to load test-suite data from file #{file_name}" 12 | end 13 | 14 | total_test_cases_count = 0 15 | test_suite.each do |scenario| 16 | total_test_cases_count += scenario.test_cases.size 17 | end 18 | 19 | # plan(tests => $total_test_cases_count * 2) 20 | 21 | test_suite.each do |scenario| 22 | server = SPF::Server.new( 23 | dns_resolver: Resolv::DNS::Programmable.new( 24 | resolver_code: lambda { |domain, rr_type| 25 | rcode = 'NOERROR' 26 | rrs = scenario.records_for_domain(domain, rr_type) 27 | if rrs.empty? and rr_type != 'CNAME' 28 | rrs = scenario.records_for_domain(domain, 'CNAME') 29 | end 30 | if rrs.empty? 31 | rcode = 'NXDOMAIN' 32 | elsif rrs[0] == 'TIMEOUT' 33 | return 'query timed out' 34 | end 35 | [rcode, nil, rrs] 36 | }, 37 | default_authority_explanation: 'DEFAULT', 38 | max_void_dns_lookups: nil # Be RFC 4408 compliant during testing! 39 | ) 40 | ) 41 | 42 | scenario.test_cases.each do |test_case| 43 | test_base_name = sprintf("Test case '%s'", test_case.name) 44 | 45 | if ((test_case_override = test_case_overrides[test_case.name]) != nil) 46 | if test_case_override =~ /^SKIP(?:: (.*))/ 47 | puts "Skipping test '#{test_case.name}' due to override" + ($1 ? " #{$1}" : "") 48 | next 49 | end 50 | end 51 | 52 | request = SPF::Request.new( 53 | scope: test_case.scope, 54 | identity: test_case.identity, 55 | ip_address: test_case.ip_address, 56 | helo_identity: test_case.helo_identity 57 | ) 58 | result = server.process(request) 59 | overall_ok = true 60 | result_is_ok = test_case.is_expected_result(result.code) 61 | if not result_is_ok 62 | puts "#{test_base_name}:\n" + \ 63 | "Expected: " + test_case.expected_results.join(' or ') + "\n" + \ 64 | " Got: " + "'#{result.code}'" 65 | end 66 | overall_ok &&= result_is_ok 67 | if not result.is_code('fail') 68 | print "#{test_base_name} explanation not applicable" 69 | elsif not test_case['expected_explanation'] 70 | print "#{test_base_name} explanation not relevant" 71 | else 72 | overall_ok &&= ( 73 | result.authority_explanation.downcase == 74 | test_case['expected_explanation'].downcase 75 | ) 76 | end 77 | if not overall_ok and test_case['description'] 78 | puts "Test case description: " + test_case['description'] 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/util_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 2 | 3 | require 'ip' 4 | 5 | ipv4_address = IP.new('192.168.0.1') 6 | ipv6_address_v4mapped = IP.new('::ffff:192.168.0.1') 7 | ipv6_address = IP.new('2001:db8::1') 8 | 9 | describe "SPF::Util.ipv4_address_to_ipv6" do 10 | it "returns IP object" do 11 | expect(SPF::Util.ipv4_address_to_ipv6(ipv4_address).is_a?(IP)).to be_truthy 12 | end 13 | 14 | it "yields correct IPv4-mapped IPv6 address" do 15 | expect(SPF::Util.ipv4_address_to_ipv6(ipv4_address)).to eq ipv6_address_v4mapped 16 | end 17 | 18 | it "ipv4_address_to_ipv6(string) exception" do 19 | expect {SPF::Util.ipv4_address_to_ipv6('192.168.0.1')}.to raise_error(SPF::InvalidOptionValueError) 20 | end 21 | 22 | it "ipv4_address_to_ipv6(ipv6_address) exception" do 23 | expect {SPF::Util.ipv4_address_to_ipv6(ipv6_address_v4mapped)}.to raise_error(SPF::InvalidOptionValueError) 24 | end 25 | end 26 | 27 | describe "SPF::Util.ipv6_address_to_ipv4" do 28 | it "returns IP object" do 29 | expect(SPF::Util.ipv6_address_to_ipv4(ipv6_address_v4mapped).is_a?(IP)).to be_truthy 30 | end 31 | 32 | it "yields correct IPv4 address" do 33 | expect(SPF::Util.ipv6_address_to_ipv4(ipv6_address_v4mapped)).to eq ipv4_address 34 | end 35 | 36 | it "ipv6_address_to_ipv4(string) exception" do 37 | expect {SPF::Util.ipv6_address_to_ipv4('2001:db8::1')}.to raise_error(SPF::InvalidOptionValueError) 38 | end 39 | 40 | it "ipv6_address_to_ipv4(ipv4_address) exception" do 41 | expect {SPF::Util.ipv6_address_to_ipv4(ipv4_address)}.to raise_error(SPF::InvalidOptionValueError) 42 | end 43 | end 44 | 45 | describe "SPF::Util:::ip_address_reverse" do 46 | it "reverses IPv4 address" do 47 | expect(SPF::Util.ip_address_reverse(ipv4_address)).to eq '1.0.168.192.in-addr.arpa.' 48 | end 49 | 50 | it "reverses IPv6 address mapped from IPv4 address" do 51 | expect(SPF::Util.ip_address_reverse(ipv6_address_v4mapped)).to eq '1.0.168.192.in-addr.arpa.' 52 | end 53 | 54 | it "reverses IPv6 address" do 55 | expect(SPF::Util.ip_address_reverse(ipv6_address)).to eq( 56 | '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.') 57 | end 58 | end 59 | 60 | 61 | -------------------------------------------------------------------------------- /spf.gemspec: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path("../lib", __FILE__) 2 | require 'spf/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "spf" 6 | s.version = SPF::VERSION 7 | 8 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 9 | s.require_paths = ["lib"] 10 | s.authors = ["Andrew Flury", "Julian Mehnle", "Jacob Rideout"] 11 | s.date = "2015-04-29" 12 | s.description = " An object-oriented Ruby implementation of the Sender Policy Framework (SPF)\n e-mail sender authentication system, fully compliant with RFC 4408.\n" 13 | s.email = ["code@agari.com", "aflury@agari.com", "jmehnle@agari.com", "jrideout@agari.com"] 14 | s.extra_rdoc_files = [ 15 | "README.rdoc" 16 | ] 17 | s.files = [ 18 | ".document", 19 | ".rspec", 20 | "Gemfile", 21 | "Gemfile.lock", 22 | "README.rdoc", 23 | "Rakefile", 24 | "lib/spf.rb", 25 | "lib/spf/error.rb", 26 | "lib/spf/eval.rb", 27 | "lib/spf/macro_string.rb", 28 | "lib/spf/model.rb", 29 | "lib/spf/request.rb", 30 | "lib/spf/result.rb", 31 | "lib/spf/util.rb", 32 | "lib/spf/version.rb", 33 | "lib/spf/ext/resolv.rb", 34 | "spec/spec_helper.rb", 35 | "spec/spf_spec.rb", 36 | "spf.gemspec" 37 | ] 38 | s.homepage = "https://github.com/agaridata/spf-ruby" 39 | s.licenses = ["Apache-2.0"] 40 | s.rubygems_version = "2.4.6" 41 | s.summary = "Implementation of the Sender Policy Framework" 42 | 43 | if s.respond_to? :specification_version then 44 | s.specification_version = 4 45 | 46 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 47 | s.add_runtime_dependency(%q, ["~> 0.9.1"]) 48 | s.add_development_dependency(%q, [">= 10"]) 49 | s.add_development_dependency(%q, ["~> 3.10"]) 50 | s.add_development_dependency(%q, [">= 6.3.0"]) 51 | s.add_development_dependency(%q, [">= 2.4.13"]) 52 | s.add_development_dependency(%q) 53 | else 54 | s.add_dependency(%q, ["~> 0.9.1"]) 55 | s.add_dependency(%q, [">= 10"]) 56 | s.add_dependency(%q, ["~> 3.10"]) 57 | s.add_dependency(%q, [">= 6.3.0"]) 58 | s.add_dependency(%q, [">= 2.4.13"]) 59 | s.add_dependency(%q) 60 | end 61 | else 62 | s.add_dependency(%q, ["~> 0.9.1"]) 63 | s.add_dependency(%q, [">= 10"]) 64 | s.add_dependency(%q, ["~> 3.10"]) 65 | s.add_dependency(%q, [">= 6.3.0"]) 66 | s.add_dependency(%q, [">= 2.4.13"]) 67 | s.add_dependency(%q) 68 | end 69 | end 70 | 71 | --------------------------------------------------------------------------------