spf 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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