├── .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 |
--------------------------------------------------------------------------------