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.
@@ -0,0 +1,140 @@
1
+ require 'ip'
2
+
3
+ require 'spf/error'
4
+
5
+ class SPF::Request
6
+
7
+ attr_reader :scope, :identity, :domain, :localpart, :ip_address, :ip_address_v6, :helo_identity, :versions
8
+ attr_accessor :record, :opt, :root_request, :super_request
9
+
10
+ VERSIONS_FOR_SCOPE = {
11
+ :helo => [1 ],
12
+ :mfrom => [1, 2],
13
+ :pra => [ 2]
14
+ }
15
+
16
+ SCOPES_BY_VERSION = {
17
+ 1 => [:helo, :mfrom ],
18
+ 2 => [ :mfrom, :pra]
19
+ }
20
+
21
+ DEFAULT_LOCALPART = 'postmaster'
22
+
23
+ def initialize(options = {})
24
+ @opt = options
25
+ @state = {}
26
+ @versions = options[:versions]
27
+ @scope = options[:scope] || :mfrom
28
+ @scope = @scope.to_sym if @scope.is_a?(String)
29
+ @_authority_domain = options[:authority_domain]
30
+ @identity = options[:identity]
31
+ @ip_address = options[:ip_address]
32
+ @helo_identity = options[:helo_identity]
33
+ @root_request = self
34
+ @super_request = self
35
+ @record = nil
36
+
37
+ # Scope:
38
+ versions_for_scope = VERSIONS_FOR_SCOPE[@scope] or
39
+ raise SPF::InvalidScopeError.new("Invalid scope '#{@scope}'")
40
+
41
+ # Versions:
42
+ if self.instance_variable_defined?(:@versions)
43
+ if @versions.is_a?(Symbol)
44
+ # Single version specified as a symbol:
45
+ @versions = [@versions]
46
+ elsif not @versions.is_a?(Array)
47
+ # Something other than symbol or array specified:
48
+ raise SPF::InvalidOptionValueError.new(
49
+ "'versions' option must be symbol or array")
50
+ end
51
+
52
+ # All requested record versions must be supported:
53
+ unsupported_versions = @versions.select { |x|
54
+ not SCOPES_BY_VERSION[x]
55
+ }
56
+ if unsupported_versions.any?
57
+ raise SPF::InvalidOptionValueError.new(
58
+ "Unsupported record version(s): " +
59
+ unsupported_versions.map { |x| "'#{x}'" }.join(', '))
60
+ end
61
+ else
62
+ # No versions specified, use all versions relevant to scope:
63
+ @versions = versions_for_scope
64
+ end
65
+
66
+ # Identity:
67
+ raise SPF::OptionRequiredError.new(
68
+ "Missing required 'identity' option") unless @identity
69
+ raise SPF::InvalidOptionValueError.new(
70
+ "'identity' option must not be empty") if @identity.empty?
71
+
72
+ # Extract domain and localpart from identity:
73
+ if ((@scope == :mfrom or @scope == :pra) and
74
+ @identity =~ /^(.*)@(.*?)$/)
75
+ @localpart = $1
76
+ @domain = $2
77
+ else
78
+ @domain = @identity
79
+ end
80
+ # Lower-case domain and removee eventual trailing dot.
81
+ @domain.downcase!
82
+ @domain.chomp!('.')
83
+ if (not self.instance_variable_defined?(:@localpart) or
84
+ not @localpart or not @localpart.length > 0)
85
+ @localpart = DEFAULT_LOCALPART
86
+ end
87
+
88
+ # HELO identity:
89
+ if @scope == :helo
90
+ @helo_identity ||= @identity
91
+ end
92
+
93
+ # IP address:
94
+ if [:helo, :mfrom, :pra].find(@scope) and not self.instance_variable_defined?(:@ip_address)
95
+ raise SPF::OptionRequiredError.new("Missing required 'ip_address' option")
96
+ end
97
+
98
+ # Ensure ip_address is an IP object:
99
+ unless @ip_address.is_a?(IP)
100
+ @ip_address = IP.new(@ip_address)
101
+ end
102
+
103
+ # Convert IPv4 address to IPv4-mapped IPv6 address:
104
+
105
+ if SPF::Util.ipv6_address_is_ipv4_mapped(self.ip_address)
106
+ @ip_address_v6 = @ip_address # Accept as IPv6 address as-is
107
+ @ip_address = SPF::Util.ipv6_address_to_ipv4(@ip_address)
108
+ elsif @ip_address.is_a?(IP::V4)
109
+ @ip_address_v6 = SPF::Util.ipv4_address_to_ipv6(@ip_address)
110
+ elsif @ip_address.is_a?(IP::V6)
111
+ @ip_address_v6 = @ip_address
112
+ else
113
+ raise SPF::InvalidOptionValueError.new("Unexpected IP address version");
114
+ end
115
+ end
116
+
117
+ def new_sub_request(options)
118
+ obj = self.class.new(opt.merge(options))
119
+ obj.super_request = self
120
+ obj.root_request = super_request.root_request
121
+ return obj
122
+ end
123
+
124
+ def authority_domain
125
+ return (@_authority_domain or @domain)
126
+ end
127
+
128
+ def state(field, value = nil)
129
+ unless field
130
+ raise SPF::OptionRequiredError.new('Field name required')
131
+ end
132
+ if value and value === Fixnum
133
+ @state[field] = 0 unless @state[field]
134
+ @state[field] += value
135
+ else
136
+ @state[field] = value
137
+ end
138
+ end
139
+ end
140
+
data/lib/spf/result.rb ADDED
@@ -0,0 +1,218 @@
1
+ require 'spf/model'
2
+ require 'spf/util'
3
+
4
+ class SPF::Result < Exception
5
+
6
+ attr_reader :server, :request
7
+
8
+ class SPF::Result::Pass < SPF::Result
9
+ def code
10
+ :pass
11
+ end
12
+ end
13
+
14
+ class SPF::Result::Fail < SPF::Result
15
+ def code
16
+ :fail
17
+ end
18
+
19
+ def authority_explanation
20
+ if self.instance_variable_defined?(:@authority_explanation)
21
+ return @authority_explanation
22
+ end
23
+
24
+ @authority_explanation = nil
25
+
26
+ server = @server
27
+ request = @request
28
+
29
+ authority_explanation_macrostring = request.state('authority_explanation')
30
+
31
+ # If an explicit explanation was specified by the authority domain...
32
+ if authority_explanation_macrostring
33
+ begin
34
+ # ... then try to expand it:
35
+ @authority_explanation = authority_explanation_macrostring.expand
36
+ rescue SPF::InvalidMacroString
37
+ # Igonre expansion errors and leave authority explanation undefined.
38
+ end
39
+ end
40
+
41
+ # If no authority explanation could be determined so far...
42
+ unless @authority_explanation
43
+ @authority_explanation = server.default_authority_explanation.new({:request => request}).expand
44
+ end
45
+ return @authority_explanation
46
+ end
47
+ end
48
+
49
+ class SPF::Result::SoftFail < SPF::Result
50
+ def code
51
+ :softfail
52
+ end
53
+ end
54
+
55
+ class SPF::Result::Neutral < SPF::Result
56
+ def code
57
+ :neutral
58
+ end
59
+ end
60
+
61
+ class SPF::Result::NeutralByDefault < SPF::Result::Neutral
62
+ # This is a special-case of the Neutral result that is thrown as a default
63
+ # when "falling off" the end of the record. See SPF::Record.eval().
64
+ NAME = :neutral_by_default
65
+ def code
66
+ :neutral_by_default
67
+ end
68
+ end
69
+
70
+ class SPF::Result::None < SPF::Result
71
+ def code
72
+ :none
73
+ end
74
+ end
75
+
76
+ class SPF::Result::Error < SPF::Result
77
+ def code
78
+ :error
79
+ end
80
+ end
81
+
82
+ class SPF::Result::TempError < SPF::Result::Error
83
+ def code
84
+ :temperror
85
+ end
86
+ end
87
+
88
+ class SPF::Result::PermError < SPF::Result::Error
89
+ def code
90
+ :permerror
91
+ end
92
+ end
93
+
94
+
95
+ RESULT_CLASSES = {
96
+ :pass => SPF::Result::Pass,
97
+ :fail => SPF::Result::Fail,
98
+ :softfail => SPF::Result::SoftFail,
99
+ :neutral => SPF::Result::Neutral,
100
+ :neutral_by_default => SPF::Result::NeutralByDefault,
101
+ :none => SPF::Result::None,
102
+ :error => SPF::Result::Error,
103
+ :permerror => SPF::Result::PermError,
104
+ :temperror => SPF::Result::TempError
105
+ }
106
+
107
+ RECEIVED_SPF_HEADER_NAME = 'Received-SPF'
108
+
109
+ RECEIVED_SPF_HEADER_SCOPE_NAMES_BY_SCOPE = {
110
+ :helo => 'helo',
111
+ :mfrom => 'envelope-from',
112
+ :pra => 'pra'
113
+ }
114
+
115
+ RECEIVED_SPF_HEADER_IDENTITY_KEY_NAMES_BY_SCOPE = {
116
+ :helo => 'helo',
117
+ :mfrom => 'envelope-from',
118
+ :pra => 'pra'
119
+ }
120
+
121
+ ATEXT_PATTERN = /[[:alnum:]!#\$%&'*+\-\/=?^_`{|}~]/
122
+ DOT_ATOM_PATTERN = /
123
+ (#{ATEXT_PATTERN})+ ( \. (#{ATEXT_PATTERN})+ )*
124
+ /x
125
+
126
+ def initialize(args = [])
127
+ @server = args.shift if args.any?
128
+ unless self.instance_variable_defined?(:@server)
129
+ raise SPF::OptionRequiredError.new('SPF server object required')
130
+ end
131
+ @request = args.shift if args.any?
132
+ unless self.instance_variable_defined?(:@request)
133
+ raise SPF::OptionRequiredError.new('Request object required')
134
+ end
135
+ end
136
+
137
+ def name
138
+ return self.code
139
+ end
140
+
141
+ def klass(name=nil)
142
+ if name
143
+ name = name.to_sym if name.is_a?(String)
144
+ return self.RESULT_CLASSES[name]
145
+ else
146
+ return name
147
+ end
148
+ end
149
+
150
+ def isa_by_name(name)
151
+ suspect_class = self.klass(name)
152
+ return false unless suspect_class
153
+ return self.is_a?(suspect_class)
154
+ end
155
+
156
+ def is_code(code)
157
+ return self.isa_by_name(code)
158
+ end
159
+
160
+ def to_s
161
+ return sprintf('%s (%s)', self.name, SPF::Util.sanitize_string(super.to_s))
162
+ end
163
+
164
+ def local_explanation
165
+ return @local_explanation if self.instance_variable_defined?(:@local_explanation)
166
+
167
+ # Prepare local explanation:
168
+ request = self.request
169
+ local_explanation = request.state(:local_explanation)
170
+ if local_explanation
171
+ local_explanation = sprintf('%s (%s)', local_explanation.expand, @text)
172
+ else
173
+ local_explanation = @text
174
+ end
175
+
176
+ # Resolve authority domains of root-request and bottom sub-requests:
177
+ root_request = request.root_request
178
+ local_explanation = (request == root_request or not root_request) ?
179
+ sprintf('%s: %s', request.authority_domain, local_explanation) :
180
+ sprintf('%s ... %s: %s', root_request.authority_domain, request.authority_domain, local_explanation)
181
+
182
+ return @local_explanation = SPF::Util.sanitize_string(local_explanation)
183
+ end
184
+
185
+ def received_spf_header
186
+ return @received_spf_header if self.instance_variable_defined?(:@received_spf_header)
187
+ scope_name = self.received_spf_header_scope_names_by_scope[@request.scope]
188
+ identify_key_name = self.received_spf_header_identity_key_names_by_scope[@request.scope]
189
+ info_pairs = [
190
+ :receiver => @server.hostname || 'unknown',
191
+ :identity => scope_name,
192
+ identity_key_name.to_sym => @request.identity,
193
+ :client_ip => SPF::Util.ip_address_to_string(@request.ip_address)
194
+ ]
195
+ if @request.scope != :helo and @request.helo_identity
196
+ info_pairs[:helo] = @request.helo_identity
197
+ end
198
+ info_string = ''
199
+ while info_pairs.any?
200
+ key = info_pairs.shift
201
+ value = info_pairs.shift
202
+ info_string += '; ' unless info_string.blank?
203
+ if value !~ /^#{DOT_ATOM_PATTERN}$/o
204
+ value.gsub!(/(["\\])/, "\\#{$1}") # Escape '\' and '"' characters.
205
+ value = "\"#{value}\"" # Double-quote value.
206
+ end
207
+ info_string += "#{key}=#{value}"
208
+ end
209
+ return @received_spf_header = sprintf(
210
+ '%s: %s (%s) %s',
211
+ @received_spf_header_name,
212
+ self.code,
213
+ self.local_explanation,
214
+ info_string
215
+ )
216
+ end
217
+
218
+ end
data/lib/spf/util.rb ADDED
@@ -0,0 +1,110 @@
1
+ require 'ip'
2
+ require 'socket'
3
+
4
+ require 'spf/error'
5
+
6
+ #
7
+ # == SPF utility class
8
+ #
9
+
10
+ # Interface:
11
+ # ##############################################################################
12
+
13
+
14
+ # == SYNOPSIS
15
+ #
16
+ # require 'spf/util'
17
+ #
18
+ #
19
+ # hostname = SPF::Util.hostname
20
+ #
21
+ # ipv6_address_v4mapped = SPF::Util.ipv4_address_to_ipv6(ipv4_address)
22
+ #
23
+ # ipv4_address = SPF::Util->ipv6_address_to_ipv4($ipv6_address_v4mapped)
24
+ #
25
+ # is_v4mapped = SPF::Util->ipv6_address_is_ipv4_mapped(ipv6_address)
26
+ #
27
+ # ip_address_string = SPF::Util->ip_address_to_string(ip_address)
28
+ #
29
+ # reverse_name = SPF::Util->ip_address_reverse(ip_address)
30
+ #
31
+ # validated_domain = SPF::Util->valid_domain_for_ip_address(
32
+ # spf_server, request, ip_address, domain,
33
+ # find_best_match, # Defaults to false
34
+ # accept_any_domain # Defaults to false
35
+ # )
36
+ # sanitized_string = SPF::Util->sanitize_string(string)
37
+ #
38
+
39
+ module SPF
40
+ module Util
41
+
42
+ def self.ipv4_mapped_ipv6_address_pattern
43
+ /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})/i
44
+ end
45
+
46
+ def self.hostname
47
+ return @hostname ||= Socket.gethostbyname(Socket.gethostname).first
48
+ end
49
+
50
+ def self.ipv4_address_to_ipv6(ipv4_address)
51
+ unless IP::V4 === ipv4_address
52
+ raise SPF::InvalidOptionValueError.new('IP::V4 address expected')
53
+ end
54
+ return IP.new("::ffff:#{ipv4_address.to_addr}/#{ipv4_address.pfxlen - 32 + 128}")
55
+ end
56
+
57
+ def self.ipv6_address_to_ipv4(ipv6_address)
58
+ unless IP::V6 === ipv6_address and ipv6_address.ipv4_mapped?
59
+ raise SPF::InvalidOptionValueError, 'IPv4-mapped IP::V6 address expected'
60
+ end
61
+ return ipv6_address.native
62
+ end
63
+
64
+ def self.ipv6_address_is_ipv4_mapped(ipv6_address)
65
+ return ipv6_address.ipv4_mapped?
66
+ end
67
+
68
+ def self.ip_address_to_string(ip_address)
69
+ unless IP::V4 === ip_address or IP::V6 === ip_address
70
+ raise SPF::InvalidOptionValueError.new('IP::V4 or IP::V6 address expected')
71
+ end
72
+ return ip_address.to_addr
73
+ end
74
+
75
+ def self.ip_address_reverse(ip_address)
76
+ unless IP::V4 === ip_address or IP::V6 === ip_address
77
+ raise SPF::InvalidOptionValueError.new('IP::V4 or IP::V6 address expected')
78
+ end
79
+ # Treat IPv4-mapped IPv6 addresses as IPv4 addresses:
80
+ ip_address = ipv6_address_to_ipv4(ip_address) if ip_address.ipv4_mapped?
81
+ case ip_address
82
+ when IP::V4
83
+ octets = ip_address.to_addr.split('.').first(ip_address.pfxlen / 8)
84
+ return "#{octets .reverse.join('.')}.in-addr.arpa."
85
+ when IP::V6
86
+ nibbles = ip_address.to_hex .split('') .first(ip_address.pfxlen / 4)
87
+ return "#{nibbles.reverse.join('.')}.ip6.arpa."
88
+ end
89
+ end
90
+
91
+ def self.valid_domain_for_ip_address(
92
+ sever, request, ip_address, domain,
93
+ find_best_match = false,
94
+ accept_any_domain = false
95
+ )
96
+ # TODO
97
+ end
98
+
99
+ def self.sanitize_string(string)
100
+ return \
101
+ string &&
102
+ string.
103
+ gsub(/([\x00-\x1f\x7f-\xff])/) { |c| sprintf('\x%02x', c.ord) }.
104
+ gsub(/([\u{0100}-\u{ffff}])/) { |u| sprintf('\x{%04x}', u.ord) }
105
+ end
106
+
107
+ end
108
+ end
109
+
110
+ # vim:sw=2 sts=2
@@ -0,0 +1,5 @@
1
+ module SPF
2
+ VERSION = '0.0.0'
3
+ end
4
+
5
+ # vim:sw=2 sts=2
data/lib/spf.rb ADDED
@@ -0,0 +1,48 @@
1
+ require 'spf/error'
2
+ require 'spf/model'
3
+ require 'spf/request'
4
+ require 'spf/eval'
5
+ require 'spf/macro_string'
6
+ require 'spf/util'
7
+
8
+ #
9
+ # == SPF - An object-oriented implementation of Sender Policy Framework
10
+ #
11
+ # == SYNOPSIS
12
+ #
13
+ # <tt>
14
+ #
15
+ # require 'spf'
16
+ #
17
+ # spf_server = SPF::Server.new
18
+ #
19
+ # request = SPF::Request.new({
20
+ # :versions => [1, 2], # optional
21
+ # :scope => 'mfrom', # or 'helo', 'pra'
22
+ # :identity => 'fred@example.com',
23
+ # :ip_address => '192.168.0.1',
24
+ # :helo_identity => 'mta.example.com' # optional,
25
+ # # for %{h} macro expansion
26
+ # })
27
+ #
28
+ # result = spf_server.process(request)
29
+ # puts result
30
+ # result_code = result.code
31
+ # local_exp = result.local_explanation
32
+ # authority_exp = result.authority_explanation
33
+ # if result.is_code(:fail)
34
+ # spf_header = result.received_spf_header
35
+ #
36
+ # </tt>
37
+ #
38
+ # == DESCRIPTION
39
+ #
40
+ # <b>SPF</b> is an object-oriented implementation of Sender Policy Framework
41
+ # (SPF). See http://www.openspf.org for more information about SPF.
42
+ #
43
+ # This class collection aims to fully conform to the SPF specification (RFC
44
+ # 4408 so as to serve both as a production quality SPF implementation and as a
45
+ # reference for other developers of SPF implementations.
46
+ #
47
+ #
48
+ # vim:sw=2 sts=2
@@ -0,0 +1,12 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
3
+ require 'rspec'
4
+ require 'spf-ruby'
5
+
6
+ # Requires supporting files with custom matchers and macros, etc,
7
+ # in ./support/ and its subdirectories.
8
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
9
+
10
+ RSpec.configure do |config|
11
+
12
+ end
data/spec/spf_spec.rb ADDED
@@ -0,0 +1,7 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe "SPF" do
4
+ it "fails" do
5
+ fail "hey buddy, you should probably rename this file and start specing for real"
6
+ end
7
+ end
data/spf.gemspec ADDED
@@ -0,0 +1,66 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "spf"
8
+ s.version = "0.0.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Andrew Flury", "Julian Mehnle"]
12
+ s.date = "2013-10-14"
13
+ 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"
14
+ s.email = ["code@agari.com", "aflury@agari.com", "jmehnle@agari.com"]
15
+ s.extra_rdoc_files = [
16
+ "README.rdoc"
17
+ ]
18
+ s.files = [
19
+ ".document",
20
+ ".rspec",
21
+ ".ruby-version",
22
+ "Gemfile",
23
+ "Gemfile.lock",
24
+ "README.rdoc",
25
+ "Rakefile",
26
+ "lib/spf.rb",
27
+ "lib/spf/error.rb",
28
+ "lib/spf/eval.rb",
29
+ "lib/spf/macro_string.rb",
30
+ "lib/spf/model.rb",
31
+ "lib/spf/request.rb",
32
+ "lib/spf/result.rb",
33
+ "lib/spf/util.rb",
34
+ "lib/spf/version.rb",
35
+ "spec/spec_helper.rb",
36
+ "spec/spf_spec.rb",
37
+ "spf.gemspec"
38
+ ]
39
+ s.homepage = "https://github.com/agaridata/spf-ruby"
40
+ s.licenses = ["none (all rights reserved)"]
41
+ s.require_paths = ["lib"]
42
+ s.rubygems_version = "1.8.23"
43
+ s.summary = "Implementation of the Sender Policy Framework"
44
+
45
+ if s.respond_to? :specification_version then
46
+ s.specification_version = 3
47
+
48
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
49
+ s.add_development_dependency(%q<rspec>, ["~> 2.8.0"])
50
+ s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
51
+ s.add_development_dependency(%q<bundler>, ["~> 1.0"])
52
+ s.add_development_dependency(%q<jeweler>, ["~> 1.8.7"])
53
+ else
54
+ s.add_dependency(%q<rspec>, ["~> 2.8.0"])
55
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
56
+ s.add_dependency(%q<bundler>, ["~> 1.0"])
57
+ s.add_dependency(%q<jeweler>, ["~> 1.8.7"])
58
+ end
59
+ else
60
+ s.add_dependency(%q<rspec>, ["~> 2.8.0"])
61
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
62
+ s.add_dependency(%q<bundler>, ["~> 1.0"])
63
+ s.add_dependency(%q<jeweler>, ["~> 1.8.7"])
64
+ end
65
+ end
66
+