spf 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,6 @@
1
+ lib/**/*.rb
2
+ lib/*.rb
3
+ bin/*
4
+ -
5
+ features/**/*.feature
6
+ LICENSE.txt
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 1.9
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ source "http://rubygems.org"
2
+ # Add dependencies required to use your gem here.
3
+ # Example:
4
+ # gem "activesupport", ">= 2.3.5"
5
+
6
+ # Add dependencies to develop your gem here.
7
+ # Include everything needed to run rake, tests, features, etc.
8
+ group :development do
9
+ gem "rspec", "~> 2.8.0"
10
+ gem "rdoc", "~> 3.12"
11
+ gem "bundler", "~> 1.0"
12
+ gem "jeweler", "~> 1.8.7"
13
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,63 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ addressable (2.3.5)
5
+ builder (3.2.2)
6
+ diff-lcs (1.1.3)
7
+ faraday (0.8.8)
8
+ multipart-post (~> 1.2.0)
9
+ git (1.2.6)
10
+ github_api (0.10.1)
11
+ addressable
12
+ faraday (~> 0.8.1)
13
+ hashie (>= 1.2)
14
+ multi_json (~> 1.4)
15
+ nokogiri (~> 1.5.2)
16
+ oauth2
17
+ hashie (2.0.5)
18
+ highline (1.6.19)
19
+ httpauth (0.2.0)
20
+ jeweler (1.8.8)
21
+ builder
22
+ bundler (~> 1.0)
23
+ git (>= 1.2.5)
24
+ github_api (= 0.10.1)
25
+ highline (>= 1.6.15)
26
+ nokogiri (= 1.5.10)
27
+ rake
28
+ rdoc
29
+ json (1.8.0)
30
+ jwt (0.1.8)
31
+ multi_json (>= 1.5)
32
+ multi_json (1.8.2)
33
+ multi_xml (0.5.5)
34
+ multipart-post (1.2.0)
35
+ nokogiri (1.5.10)
36
+ oauth2 (0.9.2)
37
+ faraday (~> 0.8)
38
+ httpauth (~> 0.2)
39
+ jwt (~> 0.1.4)
40
+ multi_json (~> 1.0)
41
+ multi_xml (~> 0.5)
42
+ rack (~> 1.2)
43
+ rack (1.5.2)
44
+ rake (10.1.0)
45
+ rdoc (3.12.2)
46
+ json (~> 1.4)
47
+ rspec (2.8.0)
48
+ rspec-core (~> 2.8.0)
49
+ rspec-expectations (~> 2.8.0)
50
+ rspec-mocks (~> 2.8.0)
51
+ rspec-core (2.8.0)
52
+ rspec-expectations (2.8.0)
53
+ diff-lcs (~> 1.1.2)
54
+ rspec-mocks (2.8.0)
55
+
56
+ PLATFORMS
57
+ ruby
58
+
59
+ DEPENDENCIES
60
+ bundler (~> 1.0)
61
+ jeweler (~> 1.8.7)
62
+ rdoc (~> 3.12)
63
+ rspec (~> 2.8.0)
data/README.rdoc ADDED
@@ -0,0 +1,13 @@
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 authentication system.
4
+
5
+ See <http://www.openspf.org> for more information about SPF.
6
+
7
+ == Contributing
8
+
9
+ Agari currently does not accept contributions of code to spf-ruby.
10
+
11
+ == Copyright
12
+
13
+ (C) 2013 Agari Data, Inc. All rights reserved. Distribution prohibited.
data/Rakefile ADDED
@@ -0,0 +1,56 @@
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
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ require './lib/spf/version.rb'
16
+ Jeweler::Tasks.new do |gem|
17
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
18
+ gem.name = 'spf'
19
+ gem.version = SPF::VERSION
20
+ gem.homepage = 'https://github.com/agaridata/spf-ruby'
21
+ gem.authors = ['Andrew Flury', 'Julian Mehnle']
22
+ gem.email = ['code@agari.com', 'aflury@agari.com', 'jmehnle@agari.com']
23
+ gem.license = 'none (all rights reserved)'
24
+ gem.summary = 'Implementation of the Sender Policy Framework'
25
+ gem.description = <<-DESCRIPTION
26
+ An object-oriented Ruby implementation of the Sender Policy Framework (SPF)
27
+ e-mail sender authentication system, fully compliant with RFC 4408.
28
+ DESCRIPTION
29
+ # dependencies defined in Gemfile
30
+ end
31
+ Jeweler::RubygemsDotOrgTasks.new
32
+
33
+ require 'rspec/core'
34
+ require 'rspec/core/rake_task'
35
+ RSpec::Core::RakeTask.new(:spec) do |spec|
36
+ spec.pattern = FileList['spec/**/*_spec.rb']
37
+ end
38
+
39
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
40
+ spec.pattern = 'spec/**/*_spec.rb'
41
+ spec.rcov = true
42
+ end
43
+
44
+ task :default => :spec
45
+
46
+ require 'rdoc/task'
47
+ Rake::RDocTask.new do |rdoc|
48
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
49
+
50
+ rdoc.rdoc_dir = 'rdoc'
51
+ rdoc.title = "spf-ruby #{version}"
52
+ rdoc.rdoc_files.include('README*')
53
+ rdoc.rdoc_files.include('lib/**/*.rb')
54
+ end
55
+
56
+ # vim:sw=2 sts=2
data/lib/spf/error.rb ADDED
@@ -0,0 +1,50 @@
1
+ module SPF
2
+
3
+ # Generic Exceptions
4
+ ##############################################################################
5
+
6
+ class Error < StandardError; end
7
+ class OptionRequiredError < Error; end # Missing required method option
8
+ # XXX Replace with ArgumentError?
9
+ class InvalidOptionValueError < Error; end # Invalid value for method option
10
+ # XXX Replace with ArgumentError!
11
+
12
+ # Miscellaneous Errors
13
+ ##############################################################################
14
+
15
+ class DNSError < Error; end # DNS error
16
+ class DNSTimeoutError < DNSError; end # DNS timeout
17
+ class RecordSelectionError < Error; end # Record selection error
18
+ class NoAcceptableRecordError < RecordSelectionError; end # No acceptable record found
19
+ class RedundantAcceptableRecordsError < RecordSelectionError; end # Redundant acceptable records found
20
+ class NoUnparsedTextError < Error; end # No unparsed text available
21
+ class UnexpectedTermObjectError < Error; end # Unexpected term object encountered
22
+ class ProcessingLimitExceededError < Error; end # Processing limit exceeded
23
+ class MacroExpansionCtxRequiredError < OptionRequiredError; end # Missing required context for macro expansion
24
+
25
+ # Parser Errors
26
+ ##############################################################################
27
+
28
+ class NothingToParseError < Error; end # Nothing to parse
29
+ class SyntaxError < Error; end # Generic syntax error
30
+ class InvalidRecordVersionError < SyntaxError; end # Invalid record version
31
+ class InvalidScopeError < SyntaxError; end # Invalid scope
32
+ class JunkInRecordError < SyntaxError; end # Junk encountered in record
33
+ class InvalidModError < SyntaxError; end # Invalid modifier
34
+ class InvalidTermError < SyntaxError; end # Invalid term
35
+ class JunkInTermError < SyntaxError; end # Junk encountered in term
36
+ class DuplicateGlobalMod < InvalidModError; end # Duplicate global modifier
37
+ class InvalidMechError < InvalidTermError; end # Invalid mechanism
38
+ class InvalidMechQualifierError < InvalidMechError; end # Invalid mechanism qualifier
39
+ class TermDomainSpecExpectedError < SyntaxError; end # Missing required <domain-spec> in term
40
+ class TermIPv4AddressExpectedError < SyntaxError; end # Missing required <ip4-network> in term
41
+ class TermIPv4PrefixLengthExpected < SyntaxError; end # Missing required <ip4-cidr-length> in term
42
+ class TermIPv6AddressExpected < SyntaxError; end # Missing required <ip6-network> in term
43
+ class TermIPv6PrefixLengthExpected < SyntaxError; end # Missing required <ip6-cidr-length> in term
44
+ class InvalidMacroStringError < SyntaxError; end # Invalid macro string
45
+ class InvalidMacroError < InvalidMacroStringError
46
+ end # Invalid macro
47
+
48
+ end
49
+
50
+ # vim:sw=2 sts=2
data/lib/spf/eval.rb ADDED
@@ -0,0 +1,285 @@
1
+ require 'ip'
2
+ require 'resolv'
3
+
4
+ require 'spf/model'
5
+ require 'spf/result'
6
+
7
+ class Resolv::DNS::Resource::IN::SPF < Resolv::DNS::Resource::IN::TXT
8
+ # resolv.rb doesn't define an SPF resource type.
9
+ TypeValue = 99
10
+ end
11
+
12
+ class SPF::Server
13
+
14
+ attr_accessor \
15
+ :default_authority_explanation,
16
+ :hostname,
17
+ :dns_resolver,
18
+ :query_rr_types,
19
+ :max_dns_interactive_terms,
20
+ :max_name_lookups_per_term,
21
+ :max_name_lookups_per_mx_mech,
22
+ :max_name_lookups_per_ptr_mech,
23
+ :max_void_dns_lookups
24
+
25
+ RECORD_CLASSES_BY_VERSION = {
26
+ 1 => SPF::Record::V1,
27
+ 2 => SPF::Record::V2
28
+ }
29
+
30
+ RESULT_BASE_CLASS = SPF::Result
31
+
32
+ QUERY_RR_TYPE_ALL = 0
33
+ QUERY_RR_TYPE_TXT = 1
34
+ QUERY_RR_TYPE_SPF = 2
35
+
36
+ DEFAULT_DEFAULT_AUTHORITY_EXPLANATION =
37
+ 'Please see http://www.openspf.org/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}'
38
+
39
+ DEFAULT_MAX_DNS_INTERACTIVE_TERMS = 10 # RFC 4408, 10.1/6
40
+ DEFAULT_MAX_NAME_LOOKUPS_PER_TERM = 10 # RFC 4408, 10.1/7
41
+ DEFAULT_QUERY_RR_TYPES = QUERY_RR_TYPE_TXT
42
+ DEFAULT_MAX_NAME_LOOKUPS_PER_MX_MECH = DEFAULT_MAX_NAME_LOOKUPS_PER_TERM
43
+ DEFAULT_MAX_NAME_LOOKUPS_PER_PTR_MECH = DEFAULT_MAX_NAME_LOOKUPS_PER_TERM
44
+ DEFAULT_MAX_VOID_DNS_LOOKUPS = 2
45
+
46
+ def initialize(options = {})
47
+ @default_authority_explanation = options[:default_authority_explanation] ||
48
+ DEFAULT_DEFAULT_AUTHORITY_EXPLANATION
49
+ unless @default_authority_explanation.is_a?(SPF::MacroString)
50
+ @default_authority_explanation = SPF::MacroString.new({
51
+ :text => @default_authority_explanation,
52
+ :server => self,
53
+ :is_explanation => true
54
+ })
55
+ end
56
+ @hostname = options[:hostname] || SPF::Util.hostname
57
+ @dns_resolver = options[:dns_resolver] || Resolv::DNS.new
58
+ @query_rr_types = options[:query_rr_types] ||
59
+ DEFAULT_QUERY_RR_TYPES
60
+ @max_dns_interactive_terms = options[:max_dns_interactive_terms] ||
61
+ DEFAULT_MAX_DNS_INTERACTIVE_TERMS
62
+ @max_name_lookups_per_term = options[:max_name_lookups_per_term] ||
63
+ DEFAULT_MAX_NAME_LOOKUPS_PER_TERM
64
+ @max_name_lookups_per_mx_mech = options[:max_name_lookups_per_mx_mech] ||
65
+ DEFAULT_MAX_NAME_LOOKUPS_PER_MX_MECH
66
+ @max_name_lookups_per_ptr_mech = options[:max_name_lookups_per_ptr_mech] ||
67
+ DEFAULT_MAX_NAME_LOOKUPS_PER_PTR_MECH
68
+ @max_void_dns_lookups = options[:max_void_dns_lookups] ||
69
+ DEFAULT_MAX_VOID_DNS_LOOKUPS
70
+
71
+ end
72
+
73
+ def result_class(name = nil)
74
+ if name
75
+ return RESULT_BASE_CLASS::RESULT_CLASSES[name]
76
+ else
77
+ return RESULT_BASE_CLASS
78
+ end
79
+ end
80
+
81
+ def throw_result(name, request, text)
82
+ raise self.result_class(name).new(self, request, text)
83
+ end
84
+
85
+ def process(request)
86
+ request.state(:authority_explanation, nil)
87
+ request.state(:dns_interactive_term_count, 0)
88
+ request.state(:void_dns_lookups_count, 0)
89
+
90
+ result = nil
91
+
92
+ begin
93
+ record = self.select_record(request)
94
+ request.record = record
95
+ record.eval(self, request)
96
+ rescue SPF::Result => r
97
+ result = r
98
+ rescue SPF::DNSError => e
99
+ result = self.result_class(:temperror).new(self, request, e.message)
100
+ rescue SPF::NoAcceptableRecordError => e
101
+ result = self.result_class(:none ).new(self, request, e.message)
102
+ rescue SPF::RedundantAcceptableRecordsError, SPF::SyntaxError, SPF::ProcessingLimitExceededError => e
103
+ result = self.result_class(:permerror).new([self, request, e.message])
104
+ end
105
+ # Propagate other, unknown errors.
106
+ # This should not happen, but if it does, it helps exposing the bug!
107
+
108
+ return result
109
+ end
110
+
111
+ def resource_typeclass_for_rr_type(rr_type)
112
+ return case rr_type
113
+ when 'TXT' then Resolv::DNS::Resource::IN::TXT
114
+ when 'SPF' then Resolv::DNS::Resource::IN::SPF
115
+ when 'ANY' then Resolv::DNS::Resource::IN::ANY
116
+ when 'A' then Resolv::DNS::Resource::IN::A
117
+ when 'AAAA' then Resolv::DNS::Resource::IN::AAAA
118
+ when 'PTR' then Resolv::DNS::Resource::IN::PTR
119
+ else
120
+ raise ArgumentError, "Uknown RR type: #{rr_type}"
121
+ end
122
+ end
123
+
124
+ def dns_lookup(domain, rr_type)
125
+ if domain.is_a?(SPF::MacroString)
126
+ domain = domain.expand
127
+ # Truncate overlong labels at 63 bytes (RFC 4408, 8.1/27)
128
+ domain.gsub!(/([^.]{63})[^.]+/, "#{$1}")
129
+ # Drop labels from the head of domain if longer than 253 bytes (RFC 4408, 8.1/25):
130
+ domain.sub!(/^[^.]+\.(.*)$/, "#{$1}") while domain.length > 253
131
+ end
132
+
133
+ rr_type = self.resource_typeclass_for_rr_type(rr_type)
134
+
135
+ domain.sub(/^(.*?)\.?$/, $1 ? "#{$1}".downcase : '')
136
+
137
+ packet = @dns_resolver.getresources(domain, rr_type)
138
+
139
+ # Raise DNS exception unless an answer packet with RCODE 0 or 3 (NXDOMAIN)
140
+ # was received (thereby treating NXDOMAIN as an acceptable but empty answer packet):
141
+ #if @dns_resolver.errorstring =~ /^(timeout|query timed out)$/
142
+ # raise SPF::DNSTimeoutError.new(
143
+ # "Time-out on DNS '#{rr_type}' lookup of '#{domain}'")
144
+ #end
145
+
146
+ unless packet
147
+ raise SPF::DNSError.new(
148
+ "Unknown error on DNS '#{rr_type}' lookup of '#{domain}'")
149
+ end
150
+
151
+ #unless packet.header.rcode =~ /^(NOERROR|NXDOMAIN)$/
152
+ # raise SPF::DNSError.new(
153
+ # "'#{packet.header.rcode}' error on DNS '#{rr_type}' lookup of '#{domain}'")
154
+ #end
155
+ return packet
156
+ end
157
+
158
+ def select_record(request)
159
+ domain = request.authority_domain
160
+ versions = request.versions
161
+ scope = request.scope
162
+
163
+ # Employ identical behavior for 'v=spf1' and 'spf2.0' records, both of
164
+ # which support SPF (code 99) and TXT type records (this may be different
165
+ # in future revisions of SPF):
166
+ # Query for SPF type records first, then fall back to TXT type records.
167
+
168
+ records = []
169
+ query_count = 0
170
+ dns_errors = []
171
+
172
+ # Query for SPF-type RRs first:
173
+ if (@query_rr_types == QUERY_RR_TYPE_ALL or
174
+ @query_rr_types & QUERY_RR_TYPE_SPF)
175
+ begin
176
+ query_count += 1
177
+ packet = self.dns_lookup(domain, 'SPF')
178
+ records << self.get_acceptable_records_from_packet(
179
+ packet, 'SPF', versions, scope, domain)
180
+ rescue SPF::DNSError => e
181
+ dns_errors << e
182
+ #rescue SPF::DNSTimeout => e
183
+ # # FIXME: Ignore DNS timeouts on SPF type lookups?
184
+ # # Apparently some brain-dead DNS servers time out on SPF-type queries.
185
+ end
186
+ end
187
+
188
+ if (not records.any? and
189
+ @query_rr_types == QUERY_RR_TYPES_ALL or
190
+ @query_rr_types & QUERY_RR_TYPE_TXT)
191
+ # NOTE:
192
+ # This deliberately violates RFC 4406 (Sender ID), 4.4/3 (4.4.1):
193
+ # TXT-type RRs are still tried if there _are_ SPF-type RRs but all
194
+ # of them are inapplicable (e.g. "Hi!", or even "spf2/pra" for an
195
+ # 'mfrom' scope request). This conforms to the spirit of the more
196
+ # sensible algorithm in RFC 4408 (SPF), 4.5.
197
+ # Implication: Sender ID processing may make use of existing TXT-
198
+ # type records where a result of "None" would normally be returned
199
+ # under a strict interpretation of RFC 4406.
200
+
201
+ begin
202
+ query_count += 1
203
+ packet = self.dns_lookup(domain, 'TXT')
204
+ records << self.get_acceptable_records_from_packet(
205
+ packet, 'TXT', versions, scope, domain)
206
+ rescue SPF::DNSError => e
207
+ dns_errors << e
208
+ end
209
+
210
+ # Unless at least one query succeeded, re-raise the first DNS error that occured.
211
+ raise dns_errors[0] unless dns_errors.length < query_count
212
+
213
+ records.flatten!
214
+
215
+ if records.empty?
216
+ # RFC 4408, 4.5/7
217
+ raise SPF::NoAcceptableRecordError('No applicable sender policy available')
218
+ end
219
+
220
+ # Discard all records but the highest acceptable version:
221
+ preferred_record_class = records[0].class
222
+
223
+ records = records.select { |record| record.is_a?(preferred_record_class) }
224
+
225
+ if records.length != 1
226
+ # RFC 4408, 4.5/6
227
+ raise SPF::RedundantAcceptableRecordsError.new(
228
+ "Redundant applicable '#{preferred_record_class.version_tag}' sender policies found"
229
+ )
230
+ end
231
+
232
+ return records[0]
233
+ end
234
+ end
235
+
236
+ def get_acceptable_records_from_packet(packet, rr_type, versions, scope, domain)
237
+
238
+ # Try higher record versions first.
239
+ # (This may be too simplistic for future revisions of SPF.)
240
+ versions = versions.sort { |x, y| y <=> x }
241
+
242
+ rr_type = resource_typeclass_for_rr_type(rr_type)
243
+ records = []
244
+ packet.each do |rr|
245
+ next unless rr.is_a?(rr_type)
246
+ text = rr.strings.join('')
247
+ record = false
248
+ versions.each do |version|
249
+ klass = RECORD_CLASSES_BY_VERSION[version]
250
+ begin
251
+ record = klass.new_from_string(text)
252
+ rescue SPF::InvalidRecordVersionError
253
+ # Ignore non-SPF and unknown-version records.
254
+ # Propagate other errors (including syntax errors), though.
255
+ end
256
+ end
257
+ if record
258
+ if record.scopes.select{|x| scope == x}.any?
259
+ # Record covers requested scope.
260
+ records << record
261
+ end
262
+ break
263
+ end
264
+ end
265
+ return records
266
+ end
267
+
268
+ def count_dns_interactive_term(request)
269
+ dns_interactive_terms_count = request.root_request.state(:dns_interactive_terms_count, 1)
270
+ if (@max_dns_interactive_terms and
271
+ dns_interactive_terms_count > @max_dns_interactive_terms)
272
+ raise SPF::ProcessingLimitExceededError.new(
273
+ "Maximum DNS-interactive terms limit (#{@max_dns_interactive_terms}) exceeded")
274
+ end
275
+ end
276
+
277
+ def count_void_dns_lookup(request)
278
+ void_dns_lookups_count = request.root_request.state(:void_dns_lookups_count, 1)
279
+ if (@max_void_dns_lookups and
280
+ void_dns_lookups_count > @max_void_dns_lookups)
281
+ raise SPF::ProcessingLimitExceeded.new(
282
+ "Maximum void DNS look-ups limit (#{@max_void_dns_lookups}) exceeded")
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,73 @@
1
+ require 'spf/util'
2
+
3
+ module SPF
4
+ class MacroString
5
+
6
+ def self.default_split_delimiters
7
+ '.'
8
+ end
9
+
10
+ def self.default_join_delimiter
11
+ '.'
12
+ end
13
+
14
+ def self.uri_unreserved_chars
15
+ 'A-Za-z0-9\-._~'
16
+ end
17
+
18
+ def initialize(options = {})
19
+ super()
20
+ @text = options[:text] \
21
+ or raise ArgumentError, "Missing required 'text' option"
22
+ @server = options[:server]
23
+ @request = options[:request]
24
+ @expanded = nil
25
+ self.expand
26
+ end
27
+
28
+ attr_reader :text, :server, :request
29
+
30
+ def context(server, request)
31
+ valid_context(true, server, request)
32
+ @server = server
33
+ @request = request
34
+ @expanded = nil
35
+ return
36
+ end
37
+
38
+ def expand(context = nil)
39
+ return @expanded if @expanded
40
+
41
+ return nil unless @text
42
+ return (@expanded = @text) unless @text =~ /%/
43
+ # Short-circuit expansion if text has no '%' characters.
44
+
45
+ expanded = ''
46
+ # TODO
47
+ return (@expanded = @text)
48
+ end
49
+
50
+ def to_s
51
+ if valid_context(false)
52
+ return expand
53
+ else
54
+ return @text
55
+ end
56
+ end
57
+
58
+ def valid_context(required, server = self.server, request = self.request)
59
+ if not SPF::Server === server
60
+ raise MacroExpansionCtxRequired, 'SPF server object required' if required
61
+ return false
62
+ end
63
+ if not SPF::Request === request
64
+ raise MacroExpansionCtxRequired, 'SPF request object required' if required
65
+ return false
66
+ end
67
+ return true
68
+ end
69
+
70
+ end
71
+ end
72
+
73
+ # vim:sw=2 sts=2