spf 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+