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 +6 -0
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +63 -0
- data/README.rdoc +13 -0
- data/Rakefile +56 -0
- data/lib/spf/error.rb +50 -0
- data/lib/spf/eval.rb +285 -0
- data/lib/spf/macro_string.rb +73 -0
- data/lib/spf/model.rb +985 -0
- data/lib/spf/request.rb +140 -0
- data/lib/spf/result.rb +218 -0
- data/lib/spf/util.rb +110 -0
- data/lib/spf/version.rb +5 -0
- data/lib/spf.rb +48 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/spf_spec.rb +7 -0
- data/spf.gemspec +66 -0
- metadata +134 -0
data/lib/spf/model.rb
ADDED
@@ -0,0 +1,985 @@
|
|
1
|
+
require 'ip'
|
2
|
+
|
3
|
+
require 'spf/util'
|
4
|
+
|
5
|
+
|
6
|
+
class IP
|
7
|
+
def contains?(ip_address)
|
8
|
+
return (
|
9
|
+
self.to_irange.first <= ip_address.to_i and
|
10
|
+
self.to_irange.last >= ip_address.to_i)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
class SPF::Record
|
16
|
+
DEFAULT_QUALIFIER = '+';
|
17
|
+
end
|
18
|
+
|
19
|
+
class SPF::Term
|
20
|
+
|
21
|
+
NAME_PATTERN = '[[:alpha:]] [[:alnum:]\\-_\\.]*'
|
22
|
+
|
23
|
+
MACRO_LITERAL_PATTERN = "[!-$&-~]"
|
24
|
+
MACRO_DELIMITER = "[\\.\\-+,\\/_=]"
|
25
|
+
MACRO_TRANSFORMERS_PATTERN = "\\d*r?"
|
26
|
+
MACRO_EXPAND_PATTERN = "
|
27
|
+
%
|
28
|
+
(?:
|
29
|
+
{ [[:alpha:]] } #{MACRO_TRANSFORMERS_PATTERN} #{MACRO_DELIMITER}* } |
|
30
|
+
[%_-]
|
31
|
+
)
|
32
|
+
"
|
33
|
+
|
34
|
+
MACRO_STRING_PATTERN = "
|
35
|
+
(?:
|
36
|
+
#{MACRO_EXPAND_PATTERN} |
|
37
|
+
#{MACRO_LITERAL_PATTERN}
|
38
|
+
)*
|
39
|
+
"
|
40
|
+
|
41
|
+
TOPLABEL_PATTERN = "
|
42
|
+
[[:alnum:]_-]+ - [[:alnum:]-]* [[:alnum:]] |
|
43
|
+
[[:alnum:]]* [[:alpha:]] [[:alnum:]]*
|
44
|
+
"
|
45
|
+
|
46
|
+
DOMAIN_END_PATTERN = "
|
47
|
+
(?: \\. #{TOPLABEL_PATTERN} \\.? |
|
48
|
+
#{MACRO_EXPAND_PATTERN}
|
49
|
+
)
|
50
|
+
"
|
51
|
+
|
52
|
+
DOMAIN_SPEC_PATTERN = " #{MACRO_STRING_PATTERN} #{DOMAIN_END_PATTERN} "
|
53
|
+
|
54
|
+
QNUM_PATTERN = " (?: 25[0-5] | 2[0-4]\\d | 1\\d\\d | [1-9]\\d | \\d ) "
|
55
|
+
IPV4_ADDRESS_PATTERN = " #{QNUM_PATTERN} (?: \\. #{QNUM_PATTERN}){3} "
|
56
|
+
|
57
|
+
HEXWORD_PATTERN = "[[:xdigit:]]{1,4}"
|
58
|
+
|
59
|
+
TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN = /
|
60
|
+
#{HEXWORD_PATTERN} : #{HEXWORD_PATTERN} | #{IPV4_ADDRESS_PATTERN}
|
61
|
+
/x
|
62
|
+
|
63
|
+
IPV6_ADDRESS_PATTERN = "
|
64
|
+
# x:x:x:x:x:x:x:x | x:x:x:x:x:x:n.n.n.n
|
65
|
+
(?: #{HEXWORD_PATTERN} : ){6} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} |
|
66
|
+
# x::x:x:x:x:x:x | x::x:x:x:x:n.n.n.n
|
67
|
+
(?: #{HEXWORD_PATTERN} : ){1} : (?: #{HEXWORD_PATTERN} : ){4} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} |
|
68
|
+
# x[:x]::x:x:x:x:x | x[:x]::x:x:x:n.n.n.n
|
69
|
+
(?: #{HEXWORD_PATTERN} : ){1,2} : (?: #{HEXWORD_PATTERN} : ){3} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} |
|
70
|
+
# x[:...]::x:x:x:x | x[:...]::x:x:n.n.n.n
|
71
|
+
(?: #{HEXWORD_PATTERN} : ){1,3} : (?: #{HEXWORD_PATTERN} : ){2} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} |
|
72
|
+
# x[:...]::x:x:x | x[:...]::x:n.n.n.n
|
73
|
+
(?: #{HEXWORD_PATTERN} : ){1,4} : (?: #{HEXWORD_PATTERN} : ){1} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} |
|
74
|
+
# x[:...]::x:x | x[:...]::n.n.n.n
|
75
|
+
(?: #{HEXWORD_PATTERN} : ){1,5} : #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} |
|
76
|
+
# x[:...]::x | -
|
77
|
+
(?: #{HEXWORD_PATTERN} : ){1,6} : #{HEXWORD_PATTERN} |
|
78
|
+
# x[:...]:: |
|
79
|
+
(?: #{HEXWORD_PATTERN} : ){1,7} : |
|
80
|
+
# ::[...:]x | -
|
81
|
+
:: (?: #{HEXWORD_PATTERN} : ){0,6} #{HEXWORD_PATTERN} |
|
82
|
+
# - | ::[...:]n.n.n.n
|
83
|
+
:: (?: #{HEXWORD_PATTERN} : ){0,5} #{TWO_HEXWORDS_OR_IPV4_ADDRESS_PATTERN} |
|
84
|
+
# :: | -
|
85
|
+
::
|
86
|
+
"
|
87
|
+
|
88
|
+
def self.new_from_string(text, options = {})
|
89
|
+
#term = SPF::Term.new(options, {:text => text})
|
90
|
+
options[:text] = text
|
91
|
+
term = self.new(options)
|
92
|
+
term.parse
|
93
|
+
return term
|
94
|
+
end
|
95
|
+
|
96
|
+
def parse_domain_spec(required = false)
|
97
|
+
if @parse_text.sub!(/^(#{DOMAIN_SPEC_PATTERN})/x, '')
|
98
|
+
domain_spec = $1
|
99
|
+
domain_spec.sub!(/^(.*?)\.?$/, $1)
|
100
|
+
@domain_spec = SPF::MacroString.new({:text => domain_spec})
|
101
|
+
elsif required
|
102
|
+
raise SPF::TermDomainSpecExpectedError.new(
|
103
|
+
"Missing required domain-spec in '#{@text}'")
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def parse_ipv4_address(required = false)
|
108
|
+
if @parse_text.sub!(/^(#{IPV4_ADDRESS_PATTERN})/x, '')
|
109
|
+
@ip_address = $1
|
110
|
+
elsif required
|
111
|
+
raise SPF::TermIPv4AddressExpectedError.new(
|
112
|
+
"Missing required IPv4 address in '#{@text}'");
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def parse_ipv4_prefix_length(required = false)
|
117
|
+
if @parse_text.sub!(/^\/(\d+)/, '')
|
118
|
+
bits = $1.to_i
|
119
|
+
unless bits and bits >= 0 and bits <= 32 and $1 !~ /^0./
|
120
|
+
raise SPF::TermIPv4PrefixLengthExpected.new(
|
121
|
+
"Invalid IPv4 prefix length encountered in '#{@text}'")
|
122
|
+
end
|
123
|
+
@ipv4_prefix_length = bits
|
124
|
+
elsif required
|
125
|
+
raise SPF::TermIPv4PrefixLengthExpected.new(
|
126
|
+
"Missing required IPv4 prefix length in '#{@text}")
|
127
|
+
else
|
128
|
+
@ipv4_prefix_length = self.default_ipv4_prefix_length
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def parse_ipv4_network(required = false)
|
133
|
+
self.parse_ipv4_address(required)
|
134
|
+
self.parse_ipv4_prefix_length
|
135
|
+
@ip_network = IP.new("#{@ip_address}/#{@ipv4_prefix_length}")
|
136
|
+
end
|
137
|
+
|
138
|
+
def parse_ipv6_address(required = false)
|
139
|
+
if @parse_text.sub!(/(#{IPV6_ADDRESS_PATTERN})(?=\/|$)/x, '')
|
140
|
+
@ip_address = $1
|
141
|
+
elsif required
|
142
|
+
raise SPF::TermIPv6AddressExpected.new(
|
143
|
+
"Missing required IPv6 address in '#{@text}'")
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def parse_ipv6_prefix_length(required = false)
|
148
|
+
if @parse_text.sub!(/^\/(\d+)/, '')
|
149
|
+
bits = $1.to_i
|
150
|
+
unless bits and bits >= 0 and bits <= 128 and $1 !~ /^0./
|
151
|
+
raise SPF::TermIPv6PrefixLengthExpectedError.new(
|
152
|
+
"Invalid IPv6 prefix length encountered in '#{@text}'")
|
153
|
+
@ipv6_prefix_length = bits
|
154
|
+
end
|
155
|
+
elsif required
|
156
|
+
raise SPF::TermIPvPrefixLengthExpected.new(
|
157
|
+
"Missing required IPv6 prefix length in '#{@text}'")
|
158
|
+
else
|
159
|
+
@ipv6_prefix_length = self.default_ipv6_prefix_length
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def parse_ipv6_network(required = false)
|
164
|
+
self.parse_ipv6_address(required)
|
165
|
+
self.parse_ipv6_prefix_length
|
166
|
+
# XXX we shouldn't need to check for this.
|
167
|
+
@ipv6_prefix_length = self.default_ipv6_prefix_length unless @ipv6_prefix_length
|
168
|
+
@ip_network = IP.new("#{@ip_address}/#{@ipv6_prefix_length}")
|
169
|
+
end
|
170
|
+
|
171
|
+
def parse_ipv4_ipv6_prefix_lengths
|
172
|
+
self.parse_ipv4_prefix_length
|
173
|
+
if self.instance_variable_defined?(:@ipv4_prefix_length) and # An IPv4 prefix length has been parsed, and
|
174
|
+
@parse_text.sub!(/^\//, '') # another slash is following.
|
175
|
+
# Parse an IPv6 prefix length:
|
176
|
+
self.parse_ipv6_prefix_length(true)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def text
|
181
|
+
if self.instance_variable_defined?(:@text)
|
182
|
+
return @text
|
183
|
+
else
|
184
|
+
raise SPF::NoUnparsedTextError
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
end
|
189
|
+
|
190
|
+
class SPF::Mech < SPF::Term
|
191
|
+
|
192
|
+
DEFAULT_QUALIFIER = SPF::Record::DEFAULT_QUALIFIER
|
193
|
+
def default_ipv4_prefix_length; 32; end
|
194
|
+
def default_ipv6_prefix_length; 128; end
|
195
|
+
|
196
|
+
QUALIFIER_PATTERN = '[+\\-~\\?]'
|
197
|
+
NAME_PATTERN = "#{NAME_PATTERN} (?= [:\\/\\x20] | $ )"
|
198
|
+
|
199
|
+
EXPLANATION_TEMPLATES_BY_RESULT_CODE = {
|
200
|
+
:pass => "Sender is authorized to use '%{s}' in '%{_scope}' identity",
|
201
|
+
:fail => "Sender is not authorized to use '%{s}' in '%{_scope}' identity",
|
202
|
+
:softfail => "Sender is not authorized to use '%{s}' in '%{_scope}' identity, however domain is not currently prepared for false failures",
|
203
|
+
:neutral => "Domain does not state whether sender is authorized to use '%{s}' in '%{_scope}' identity"
|
204
|
+
}
|
205
|
+
|
206
|
+
def initialize(options)
|
207
|
+
super()
|
208
|
+
@text = options[:text]
|
209
|
+
if not self.instance_variable_defined?(:@parse_text)
|
210
|
+
@parse_text = @text
|
211
|
+
end
|
212
|
+
if self.instance_variable_defined?(:@domain_spec) and
|
213
|
+
not @domain_spec.is_a?(SPF::MacroString)
|
214
|
+
@domain_spec = SPF::MacroString.new({:text => @domain_spec})
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def parse
|
219
|
+
if not @parse_text
|
220
|
+
raise SPF::NothingToParseError.new('Nothing to parse for mechanism')
|
221
|
+
end
|
222
|
+
parse_qualifier
|
223
|
+
parse_name
|
224
|
+
parse_params
|
225
|
+
parse_end
|
226
|
+
end
|
227
|
+
|
228
|
+
def parse_qualifier
|
229
|
+
if @parse_text.sub!(/(#{QUALIFIER_PATTERN})?/x, '')
|
230
|
+
@qualifier = $1 or DEFAULT_QUALIFIER
|
231
|
+
else
|
232
|
+
raise SPF::InvalidMechQualifierError.new(
|
233
|
+
"Invalid qualifier encountered in '#{@text}'")
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def parse_name
|
238
|
+
if @parse_text.sub!(/^ (#{NAME_PATTERN}) (?: : (?=.) )? /x, '')
|
239
|
+
@name = $1
|
240
|
+
else
|
241
|
+
raise SPF::InvalidMech.new(
|
242
|
+
"Unexpected mechanism encountered in '#{@text}'")
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def parse_params
|
247
|
+
# Parse generic string of parameters text (should be overridden in sub-classes):
|
248
|
+
if @parse_text.sub!(/^(.*)/, '')
|
249
|
+
@params_text = $1
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
def parse_end
|
254
|
+
unless @parse_text == ''
|
255
|
+
raise SPF::JunkInTermError.new("Junk encountered in mechanism '#{@text}'")
|
256
|
+
end
|
257
|
+
@parse_text = nil
|
258
|
+
end
|
259
|
+
|
260
|
+
def qualifier
|
261
|
+
# Read-only!
|
262
|
+
return @qualifier if self.instance_variable_defined?(:@qualifier) and @qualifier
|
263
|
+
return DEFAULT_QUALIFIER
|
264
|
+
end
|
265
|
+
|
266
|
+
def to_s
|
267
|
+
@params = nil unless self.instance_variable_defined?(:@params)
|
268
|
+
|
269
|
+
return sprintf(
|
270
|
+
'%s%s%s',
|
271
|
+
@qualifier == DEFAULT_QUALIFIER ? '' : @qualifier,
|
272
|
+
@name,
|
273
|
+
@params ? @params : ''
|
274
|
+
)
|
275
|
+
end
|
276
|
+
|
277
|
+
def domain(server, request)
|
278
|
+
if self.instance_variable_defined?(:@domain_spec) and @domain_spec
|
279
|
+
return @domain_spec
|
280
|
+
end
|
281
|
+
return request.authority_domain
|
282
|
+
end
|
283
|
+
|
284
|
+
def match_in_domain(server, request, domain)
|
285
|
+
domain = self.domain(server, request) unless domain
|
286
|
+
|
287
|
+
ipv4_prefix_length = @ipv4_prefix_length
|
288
|
+
ipv6_prefix_length = @ipv6_prefix_length
|
289
|
+
addr_rr_type = request.ip_address.version == 4 ? 'A' : 'AAAA'
|
290
|
+
packet = server.dns_lookup(domain, addr_rr_type)
|
291
|
+
server.count_void_dns_lookup(request) unless (rrs = packet.answer)
|
292
|
+
|
293
|
+
rrs.each do |rr|
|
294
|
+
if rr.type == 'A'
|
295
|
+
network = IP.new("#{rr.address}/#{ipv4_prefix_length}")
|
296
|
+
return true if network.contains?(request.ip_address)
|
297
|
+
elsif rr.type == 'AAAA'
|
298
|
+
network = IP.new("#{rr.address}/#{ipv6_prefix_length}")
|
299
|
+
return true if network.contains?(request.ip_address_v6)
|
300
|
+
elsif rr.type == 'CNAME'
|
301
|
+
# Ignore -- we should have gotten the A/AAAA records anyway.
|
302
|
+
else
|
303
|
+
# Unexpected RR type.
|
304
|
+
# TODO: Generate debug info or ignore silently.
|
305
|
+
end
|
306
|
+
end
|
307
|
+
return false
|
308
|
+
end
|
309
|
+
|
310
|
+
def explain(server, request, result)
|
311
|
+
explanation_template = self.explanation_template(server, request, result)
|
312
|
+
return unless explanation_template
|
313
|
+
begin
|
314
|
+
explanation = SPF::MacroString.new({
|
315
|
+
:text => explanation_template,
|
316
|
+
:server => server,
|
317
|
+
:request => request,
|
318
|
+
:is_explanation => true
|
319
|
+
})
|
320
|
+
request.state(:local_explanation, explanation)
|
321
|
+
rescue SPF::Error
|
322
|
+
rescue SPF::Result
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
def explanation_template(server, request, result)
|
327
|
+
return EXPLANATION_TEMPLATES_BY_RESULT_CODE[result.code]
|
328
|
+
end
|
329
|
+
|
330
|
+
|
331
|
+
class SPF::Mech::A < SPF::Mech
|
332
|
+
|
333
|
+
NAME = 'a'
|
334
|
+
|
335
|
+
def parse_params
|
336
|
+
self.parse_domain_spec
|
337
|
+
self.parse_ipv4_ipv6_prefix_lengths
|
338
|
+
end
|
339
|
+
|
340
|
+
def params
|
341
|
+
params = ''
|
342
|
+
if @domain_spec
|
343
|
+
params += ':' + @domain_spec if @domain_spec
|
344
|
+
end
|
345
|
+
if @ipv4_prefix_length and @ipv4_prefix_length != self.default_ipv4_prefix_length
|
346
|
+
params += '/' + @ipv4_prefix_length
|
347
|
+
end
|
348
|
+
if @ipv6_prefix_length and @ipv6_prefix_length != DEFAULT_IPV6_PREFIX_LENGTH
|
349
|
+
params += '//' + @ipv6_prefix_length
|
350
|
+
end
|
351
|
+
return params
|
352
|
+
end
|
353
|
+
|
354
|
+
def match(server, request)
|
355
|
+
server.count_dns_interactive_term(request)
|
356
|
+
return self.match_in_domain(server, request)
|
357
|
+
end
|
358
|
+
|
359
|
+
end
|
360
|
+
|
361
|
+
class SPF::Mech::All < SPF::Mech
|
362
|
+
|
363
|
+
NAME = 'all'
|
364
|
+
|
365
|
+
def parse_params
|
366
|
+
# No parameters.
|
367
|
+
end
|
368
|
+
|
369
|
+
def match(server, request)
|
370
|
+
return true
|
371
|
+
end
|
372
|
+
|
373
|
+
end
|
374
|
+
|
375
|
+
class SPF::Mech::Exists < SPF::Mech
|
376
|
+
|
377
|
+
NAME = 'exists'
|
378
|
+
|
379
|
+
def parse_params
|
380
|
+
self.parse_domain_spec(true)
|
381
|
+
end
|
382
|
+
|
383
|
+
def params
|
384
|
+
return @domain_spec ? ':' + @domain_spec : nill
|
385
|
+
end
|
386
|
+
|
387
|
+
def match(server, request)
|
388
|
+
server.count_dns_interactive_term(request)
|
389
|
+
|
390
|
+
domain = self.domain(server, request)
|
391
|
+
packet = server.dns_lookup(domain, 'A')
|
392
|
+
rrs = (packet.answer or server.count_void_dns_lookup(request))
|
393
|
+
rrs.each do |rr|
|
394
|
+
return true if rr.type == 'A'
|
395
|
+
end
|
396
|
+
return false
|
397
|
+
end
|
398
|
+
|
399
|
+
end
|
400
|
+
|
401
|
+
class SPF::Mech::IP4 < SPF::Mech
|
402
|
+
|
403
|
+
NAME = 'ip4'
|
404
|
+
|
405
|
+
def parse_params
|
406
|
+
self.parse_ipv4_network(true)
|
407
|
+
end
|
408
|
+
|
409
|
+
def params
|
410
|
+
result = @ip_network.addr
|
411
|
+
if @ip_network.masklen != @default_ipv4_prefix_length
|
412
|
+
result += "/#{@ip_network.masklen}"
|
413
|
+
end
|
414
|
+
return result
|
415
|
+
end
|
416
|
+
|
417
|
+
def match(server, request)
|
418
|
+
ip_network_v6 = @ip_network.is_a?(IP::V4) ?
|
419
|
+
SPF::Util.ipv4_address_to_ipv6(@ip_network) :
|
420
|
+
@ip_network
|
421
|
+
return ip_network_v6.contains?(request.ip_address_v6)
|
422
|
+
end
|
423
|
+
|
424
|
+
end
|
425
|
+
|
426
|
+
class SPF::Mech::IP6 < SPF::Mech
|
427
|
+
|
428
|
+
NAME = 'ip6'
|
429
|
+
|
430
|
+
def parse_params
|
431
|
+
self.parse_ipv6_network(true)
|
432
|
+
end
|
433
|
+
|
434
|
+
def params
|
435
|
+
params = ':' + @ip_network.short
|
436
|
+
params += '/' + @ip_network.masklen if
|
437
|
+
@ip_network.masklen != DEFAULT_IPV6_PREFIX_LENGTH
|
438
|
+
return params
|
439
|
+
end
|
440
|
+
|
441
|
+
def match(server, request)
|
442
|
+
return @ip_network.contains?(request.ip_address_v6)
|
443
|
+
end
|
444
|
+
|
445
|
+
end
|
446
|
+
|
447
|
+
class SPF::Mech::Include < SPF::Mech
|
448
|
+
|
449
|
+
NAME = 'include'
|
450
|
+
|
451
|
+
def parse_params
|
452
|
+
self.parse_domain_spec(true)
|
453
|
+
end
|
454
|
+
|
455
|
+
def params
|
456
|
+
return @domain_spec ? ':' + @domain_spec : nil
|
457
|
+
end
|
458
|
+
|
459
|
+
def match(server, request)
|
460
|
+
server.count_dns_interactive_term(request)
|
461
|
+
|
462
|
+
# Create sub-request with mutated authority domain:
|
463
|
+
authority_domain = self.domain(server, request)
|
464
|
+
sub_request = request.new_sub_request({:authority_domain => authority_domain})
|
465
|
+
|
466
|
+
# Process sub-request:
|
467
|
+
result = server.process(sub_request)
|
468
|
+
|
469
|
+
# Translate result of sub-request (RFC 4408, 5.9):
|
470
|
+
|
471
|
+
return true if
|
472
|
+
result.is_a?(SPF::Result::Pass)
|
473
|
+
|
474
|
+
return false if
|
475
|
+
result.is_a?(SPF::Result::Fail) or
|
476
|
+
result.is_a?(SPF::Result::SoftFail) or
|
477
|
+
result.is_a?(SPF::Result::Neutral)
|
478
|
+
|
479
|
+
server.throw_result('permerror', request,
|
480
|
+
"Include domain '#{authority_domain}' has no applicable sender policy") if
|
481
|
+
result.is_a?(SPF::Result::None)
|
482
|
+
|
483
|
+
# Propagate any other results (including {Perm,Temp}Error) as-is:
|
484
|
+
raise result
|
485
|
+
end
|
486
|
+
end
|
487
|
+
|
488
|
+
class SPF::Mech::MX < SPF::Mech
|
489
|
+
|
490
|
+
NAME = 'mx'
|
491
|
+
|
492
|
+
def parse_params
|
493
|
+
self.parse_domain_spec
|
494
|
+
self.parse_ipv4_ipv6_prefix_lengths
|
495
|
+
end
|
496
|
+
|
497
|
+
def params
|
498
|
+
params = ''
|
499
|
+
if @domain_spec
|
500
|
+
params += ':' + @domain_spec
|
501
|
+
end
|
502
|
+
if @ipv4_prefix_length and @ipv4_prefix_length != self.default_ipv4_prefix_length
|
503
|
+
params += '/' + @ipv4_prefix_length
|
504
|
+
end
|
505
|
+
if @ipv6_prefix_length and @ipv6_prefix_length != DEFAULT_IPV6_PREFIX_LENGTH
|
506
|
+
params += '//' + @ipv6_prefix_length
|
507
|
+
end
|
508
|
+
return params
|
509
|
+
end
|
510
|
+
|
511
|
+
def match(server, request)
|
512
|
+
|
513
|
+
server.count_dns_interactive_term(request)
|
514
|
+
|
515
|
+
target_domain = self.domain(server, request)
|
516
|
+
mx_packet = server.dns_lookup(target_domain, 'MX')
|
517
|
+
mx_rrs = (mx_packet.answer or server.count_void_dns_lookup(request))
|
518
|
+
|
519
|
+
# Respect the MX mechanism lookups limit (RFC 4408, 5.4/3/4):
|
520
|
+
if server.max_name_lookups_per_mx_mech
|
521
|
+
mx_rrs = max_rrs[0, server.max_name_lookups_per_mx_mech]
|
522
|
+
end
|
523
|
+
|
524
|
+
# TODO: Use A records from packet's "additional" section? Probably not.
|
525
|
+
|
526
|
+
# Check MX records:
|
527
|
+
mx_rrs.each do |rr|
|
528
|
+
if rr.type == 'MX'
|
529
|
+
return true if
|
530
|
+
self.match_in_domain(server, request, rr.exchange)
|
531
|
+
else
|
532
|
+
# Unexpected RR type.
|
533
|
+
# TODO: Generate debug info or ignore silently.
|
534
|
+
end
|
535
|
+
end
|
536
|
+
return false
|
537
|
+
end
|
538
|
+
|
539
|
+
end
|
540
|
+
|
541
|
+
class SPF::Mech::PTR < SPF::Mech
|
542
|
+
NAME = 'ptr'
|
543
|
+
|
544
|
+
def parse_params
|
545
|
+
self.parse_domain_spec
|
546
|
+
end
|
547
|
+
|
548
|
+
def params
|
549
|
+
return @domain_spec ? ':' + @domain_spec : nil
|
550
|
+
end
|
551
|
+
|
552
|
+
def match(server, request)
|
553
|
+
return SPF::Util.valid_domain_for_ip_address(
|
554
|
+
server, request, request.ip_address, self.domain(server, request)) ?
|
555
|
+
true : false
|
556
|
+
end
|
557
|
+
end
|
558
|
+
end
|
559
|
+
|
560
|
+
class SPF::Mod < SPF::Term
|
561
|
+
|
562
|
+
def initialize(options = {})
|
563
|
+
@parse_text = options[:parse_text]
|
564
|
+
@text = options[:text]
|
565
|
+
@domain_spec = options[:domain_spec]
|
566
|
+
|
567
|
+
@parse_text = @text unless @parse_text
|
568
|
+
|
569
|
+
if @domain_spec and not @domain_spec.is_a?(SPF::MacroString)
|
570
|
+
@domain_spec = SPF::MacroString.new({:text => @domain_spec})
|
571
|
+
end
|
572
|
+
end
|
573
|
+
|
574
|
+
def parse
|
575
|
+
raise SPF::NothingToParseError('Nothing to parse for modifier') unless @parse_text
|
576
|
+
self.parse_name
|
577
|
+
self.parse_params(true)
|
578
|
+
self.parse_end
|
579
|
+
end
|
580
|
+
|
581
|
+
def parse_name
|
582
|
+
@parse_text.sub!(/^(#{NAME})=/i, '')
|
583
|
+
if $1
|
584
|
+
@name = $1
|
585
|
+
else
|
586
|
+
raise SPF::InvalidModError.new(
|
587
|
+
"Unexpected modifier name encoutered in #{@text}")
|
588
|
+
end
|
589
|
+
end
|
590
|
+
|
591
|
+
def parse_params(required = false)
|
592
|
+
# Parse generic macro string of parameters text (should be overridden in sub-classes):
|
593
|
+
@parse_text.sub!(/^(#{MACRO_STRING_PATTERN})$/x, '')
|
594
|
+
if $1
|
595
|
+
@params_text = $1
|
596
|
+
elsif required
|
597
|
+
raise SPF::InvalidMacroStringError.new(
|
598
|
+
"Invalid macro string encountered in #{@text}")
|
599
|
+
end
|
600
|
+
end
|
601
|
+
|
602
|
+
def parse_end
|
603
|
+
unless @parse_text == ''
|
604
|
+
raise SPF::JunkInTermError("Junk encountered in modifier #{@text}")
|
605
|
+
end
|
606
|
+
@parse_text = nil
|
607
|
+
end
|
608
|
+
|
609
|
+
def to_s
|
610
|
+
return sprintf(
|
611
|
+
'%s=%s',
|
612
|
+
@name,
|
613
|
+
@params ? @params : ''
|
614
|
+
)
|
615
|
+
end
|
616
|
+
|
617
|
+
class SPF::GlobalMod < SPF::Mod
|
618
|
+
end
|
619
|
+
|
620
|
+
class SPF::PositionalMod < SPF::Mod
|
621
|
+
end
|
622
|
+
|
623
|
+
class SPF::UnknownMod < SPF::Mod
|
624
|
+
end
|
625
|
+
|
626
|
+
class SPF::Mod::Exp < SPF::Mod
|
627
|
+
|
628
|
+
attr_reader :domain_spec
|
629
|
+
|
630
|
+
NAME = 'exp'
|
631
|
+
PRECEDENCE = 0.2
|
632
|
+
|
633
|
+
def parse_params
|
634
|
+
self.parse_domain_spec(true)
|
635
|
+
end
|
636
|
+
|
637
|
+
def params
|
638
|
+
return @domain_spec
|
639
|
+
end
|
640
|
+
|
641
|
+
def process(server, request, result)
|
642
|
+
begin
|
643
|
+
exp_domain = @domain_spec.new({:server => server, :request => request})
|
644
|
+
txt_packet = server.dns_lookup(exp_domain, 'TXT')
|
645
|
+
txt_rrs = txt_packet.answer.select {|x| x.type == 'TXT'}.map {|x| x.answer}
|
646
|
+
unless text_rrs.length > 0
|
647
|
+
server.throw_result(:permerror, request,
|
648
|
+
"No authority explanation string available at domain '#{exp_domain}'") # RFC 4408, 6.2/4
|
649
|
+
end
|
650
|
+
unless text_rrs.length == 1
|
651
|
+
server.throw_result(:permerror, request,
|
652
|
+
"Redundant authority explanation strings found at domain '#{exp_domain}'") # RFC 4408, 6.2/4
|
653
|
+
end
|
654
|
+
explanation = SPF::MacroString.new(
|
655
|
+
:text => txt_rrs[0].char_str_list.join(''),
|
656
|
+
:server => server,
|
657
|
+
:request => request,
|
658
|
+
:is_explanation => true
|
659
|
+
)
|
660
|
+
request.state(:authority_explanation, explanation)
|
661
|
+
rescue SPF::DNSError, SPF::Result::Error
|
662
|
+
# Ignore DNS and other errors.
|
663
|
+
end
|
664
|
+
return request
|
665
|
+
end
|
666
|
+
end
|
667
|
+
|
668
|
+
class SPF::Mod::Redirect < SPF::GlobalMod
|
669
|
+
|
670
|
+
attr_reader :domain_spec
|
671
|
+
|
672
|
+
NAME = 'redirect'
|
673
|
+
PRECEDENCE = 0.8
|
674
|
+
|
675
|
+
def parse_params
|
676
|
+
self.parse_domain_spec(true)
|
677
|
+
end
|
678
|
+
|
679
|
+
def params
|
680
|
+
return @domain_spec
|
681
|
+
end
|
682
|
+
|
683
|
+
def process(server, request, result)
|
684
|
+
server.count_dns_interactive_term(request)
|
685
|
+
|
686
|
+
# Only perform redirection if no mechanism matched (RFC 4408, 6.1/1):
|
687
|
+
return unless result.is_a?(SPF::Result::NeutralByDefault)
|
688
|
+
|
689
|
+
# Create sub-request with mutated authorithy domain:
|
690
|
+
authority_domain = @domain_spec.new({:server => server, :request => request})
|
691
|
+
sub_request = request.new_sub_request({:authority_domain => authority_domain})
|
692
|
+
|
693
|
+
# Process sub-request:
|
694
|
+
result = server.process(sub_request)
|
695
|
+
|
696
|
+
# Translate result of sub-request (RFC 4408, 6.1/4):
|
697
|
+
if result.is_a?(SPF::Result::None)
|
698
|
+
server.throw_result(:permerror, request,
|
699
|
+
"Redirect domain '#{authority_domain}' has no applicable sender policy")
|
700
|
+
end
|
701
|
+
|
702
|
+
# Propagate any other results as-is:
|
703
|
+
result.throw
|
704
|
+
end
|
705
|
+
end
|
706
|
+
end
|
707
|
+
|
708
|
+
class SPF::Record
|
709
|
+
|
710
|
+
attr_reader :terms, :text
|
711
|
+
|
712
|
+
RESULTS_BY_QUALIFIER = {
|
713
|
+
'' => :pass,
|
714
|
+
'+' => :pass,
|
715
|
+
'-' => :fail,
|
716
|
+
'~' => :softfail,
|
717
|
+
'?' => :neutral
|
718
|
+
}
|
719
|
+
|
720
|
+
def initialize(options)
|
721
|
+
super()
|
722
|
+
@parse_text = @text = options[:text] if not self.instance_variable_defined?(:@parse_text)
|
723
|
+
@terms ||= []
|
724
|
+
@global_mods ||= {}
|
725
|
+
end
|
726
|
+
|
727
|
+
def self.new_from_string(text, options = {})
|
728
|
+
options[:text] = text
|
729
|
+
record = new(options)
|
730
|
+
record.parse
|
731
|
+
return record
|
732
|
+
end
|
733
|
+
|
734
|
+
def parse
|
735
|
+
unless self.instance_variable_defined?(:@parse_text) and @parse_text
|
736
|
+
raise SPF::NothingToParseError.new('Nothing to parse for record')
|
737
|
+
end
|
738
|
+
self.parse_version_tag
|
739
|
+
self.parse_term while @parse_text.length > 0
|
740
|
+
#self.parse_end
|
741
|
+
end
|
742
|
+
|
743
|
+
def parse_version_tag
|
744
|
+
#@parse_text.sub!(self.version_tag_pattern, '')
|
745
|
+
@parse_text.sub!(/^#{self.version_tag_pattern}\s+/ix, '')
|
746
|
+
unless $1
|
747
|
+
raise SPF::InvalidRecordVersionError.new(
|
748
|
+
"Not a '#{self.version_tag}' record: '#{@text}'")
|
749
|
+
end
|
750
|
+
|
751
|
+
end
|
752
|
+
|
753
|
+
def parse_term
|
754
|
+
regex = /
|
755
|
+
^
|
756
|
+
(
|
757
|
+
#{SPF::Mech::QUALIFIER_PATTERN}?
|
758
|
+
(#{SPF::Mech::NAME_PATTERN})
|
759
|
+
[^\x20]*
|
760
|
+
)
|
761
|
+
(?: \x20+ | $ )
|
762
|
+
/x
|
763
|
+
|
764
|
+
|
765
|
+
if @parse_text.sub!(regex, '') and $&
|
766
|
+
# Looks like a mechanism:
|
767
|
+
mech_text = $1
|
768
|
+
mech_name = $2.downcase
|
769
|
+
mech_class = self.mech_classes[mech_name.to_sym]
|
770
|
+
unless mech_class
|
771
|
+
raise SPF::InvalidMech.new("Unknown mechanism type '#{mech_name}' in '#{@version_tag}' record")
|
772
|
+
end
|
773
|
+
mech = mech_class.new_from_string(mech_text)
|
774
|
+
@terms << mech
|
775
|
+
elsif (
|
776
|
+
@parse_text.sub!(/
|
777
|
+
^
|
778
|
+
(
|
779
|
+
(#{SPF::Mod::NAME_PATTERN}) =
|
780
|
+
[^\x20]*
|
781
|
+
)
|
782
|
+
(?: \x20+ | $ )
|
783
|
+
/x, '') and $&
|
784
|
+
)
|
785
|
+
# Looks like a modifier:
|
786
|
+
mod_text = $1
|
787
|
+
mod_name = $2.downcase
|
788
|
+
mod_class = MOD_CLASSES[mod_name]
|
789
|
+
if mod_class
|
790
|
+
# Known modifier.
|
791
|
+
mod = mod_class.new_from_string(mod_text)
|
792
|
+
if mod.is_a?(SPF::GlobalMod)
|
793
|
+
# Global modifier.
|
794
|
+
unless @global_mods[mod_name]
|
795
|
+
raise SPF::DuplicateGlobalMod.new("Duplicate global modifier '#{mod_name}' encountered")
|
796
|
+
end
|
797
|
+
@global_mods[mod_name] = mod
|
798
|
+
elsif mod.is_a?(SPF::PositionalMod)
|
799
|
+
# Positional modifier, queue normally:
|
800
|
+
@terms << mod
|
801
|
+
end
|
802
|
+
end
|
803
|
+
else
|
804
|
+
raise SPF::JunkInRecordError.new("Junk encountered in record '#{@text}'")
|
805
|
+
end
|
806
|
+
end
|
807
|
+
|
808
|
+
def global_mods
|
809
|
+
return @global_mods.values.sort {|a,b| a.precedence <=> b.precedence }
|
810
|
+
end
|
811
|
+
|
812
|
+
def global_mod(mod_name)
|
813
|
+
return @global_mods[mod_name]
|
814
|
+
end
|
815
|
+
|
816
|
+
def to_s
|
817
|
+
return [version_tag, @terms, @global_mods].join(' ')
|
818
|
+
end
|
819
|
+
|
820
|
+
def eval(server, request)
|
821
|
+
raise SPF::OptionRequiredError.new('SPF server object required for record evaluation') unless server
|
822
|
+
raise SPF::OptionRequiredError.new('Request object required for record evaluation') unless request
|
823
|
+
begin
|
824
|
+
@terms.each do |term|
|
825
|
+
if term.is_a?(SPF::Mech)
|
826
|
+
# Term is a mechanism.
|
827
|
+
mech = term
|
828
|
+
if mech.match(server, request)
|
829
|
+
result_name = RESULTS_BY_QUALIFIER[mech.qualifier]
|
830
|
+
result_class = server.result_class(result_name)
|
831
|
+
result = result_class.new([server, request, "Mechanism '#{term}' matched"])
|
832
|
+
mech.explain(server, request, result)
|
833
|
+
raise result
|
834
|
+
end
|
835
|
+
elsif term.is_a?(SPF::PositionalMod)
|
836
|
+
# Term is a positional modifier.
|
837
|
+
mod = term
|
838
|
+
mod.process(server, request)
|
839
|
+
elsif term.is_a?(SPF::UnknownMod)
|
840
|
+
# Term is an unknown modifier. Ignore it (RFC 4408, 6/3).
|
841
|
+
else
|
842
|
+
# Invalid term object encountered:
|
843
|
+
raise SPF::UnexpectedTermObjectError.new(
|
844
|
+
"Unexpected term object '#{term}' encountered.")
|
845
|
+
end
|
846
|
+
end
|
847
|
+
rescue SPF::Result => result
|
848
|
+
# Process global modifiers in ascending order of precedence:
|
849
|
+
@global_mods.each do |global_mod|
|
850
|
+
global_mod.process(server, request, result)
|
851
|
+
end
|
852
|
+
raise result
|
853
|
+
end
|
854
|
+
end
|
855
|
+
|
856
|
+
class SPF::Record::V1 < SPF::Record
|
857
|
+
|
858
|
+
MECH_CLASSES = {
|
859
|
+
:all => SPF::Mech::All,
|
860
|
+
:ip4 => SPF::Mech::IP4,
|
861
|
+
:ip6 => SPF::Mech::IP6,
|
862
|
+
:a => SPF::Mech::A,
|
863
|
+
:mx => SPF::Mech::MX,
|
864
|
+
:ptr => SPF::Mech::PTR,
|
865
|
+
:exists => SPF::Mech::Exists,
|
866
|
+
:include => SPF::Mech::Include
|
867
|
+
}
|
868
|
+
|
869
|
+
MOD_CLASSES = {
|
870
|
+
:redirect => SPF::Mod::Redirect,
|
871
|
+
:exp => SPF::Mod::Exp
|
872
|
+
}
|
873
|
+
|
874
|
+
|
875
|
+
def scopes
|
876
|
+
[:helo, :mfrom]
|
877
|
+
end
|
878
|
+
|
879
|
+
def version_tag
|
880
|
+
'v=spf1'
|
881
|
+
end
|
882
|
+
|
883
|
+
def version_tag_pattern
|
884
|
+
" v=spf(1) (?= \\x20+ | $ ) "
|
885
|
+
end
|
886
|
+
|
887
|
+
def mech_classes
|
888
|
+
MECH_CLASSES
|
889
|
+
end
|
890
|
+
|
891
|
+
def initialize(options = {})
|
892
|
+
super(options)
|
893
|
+
|
894
|
+
@scopes ||= options[:scopes]
|
895
|
+
if @scopes and scopes.any?
|
896
|
+
unless @scopes.length > 0
|
897
|
+
raise SPF::InvalidScopeError.new('No scopes for v=spf1 record')
|
898
|
+
end
|
899
|
+
if @scopes.length == 2
|
900
|
+
unless (
|
901
|
+
@scopes[0] == :helo and @scopes[1] == :mfrom or
|
902
|
+
@scopes[0] == :mfrom and @scopes[1] == :helo)
|
903
|
+
raise SPF::InvalidScope.new(
|
904
|
+
"Invalid set of scopes " + @scopes.map{|x| "'#{x}'"}.join(', ') + "for v=spf1 record")
|
905
|
+
end
|
906
|
+
end
|
907
|
+
end
|
908
|
+
end
|
909
|
+
end
|
910
|
+
|
911
|
+
class SPF::Record::V2 < SPF::Record
|
912
|
+
|
913
|
+
MECH_CLASSES = {
|
914
|
+
:all => SPF::Mech::All,
|
915
|
+
:ip4 => SPF::Mech::IP4,
|
916
|
+
:ip6 => SPF::Mech::IP6,
|
917
|
+
:a => SPF::Mech::A,
|
918
|
+
:mx => SPF::Mech::MX,
|
919
|
+
:ptr => SPF::Mech::PTR,
|
920
|
+
:exists => SPF::Mech::Exists,
|
921
|
+
:include => SPF::Mech::Include
|
922
|
+
}
|
923
|
+
|
924
|
+
MOD_CLASSES = {
|
925
|
+
:redirect => SPF::Mod::Redirect,
|
926
|
+
:exp => SPF::Mod::Exp
|
927
|
+
}
|
928
|
+
|
929
|
+
VALID_SCOPE = /^(?: mfrom | pra )$/x
|
930
|
+
def version_tag
|
931
|
+
'v=spf2.0'
|
932
|
+
end
|
933
|
+
|
934
|
+
def version_tag_pattern
|
935
|
+
"
|
936
|
+
spf(2\.0)
|
937
|
+
\/
|
938
|
+
( (?: mfrom | pra ) (?: , (?: mfrom | pra ) )* )
|
939
|
+
(?= \\x20 | $ )
|
940
|
+
"
|
941
|
+
end
|
942
|
+
|
943
|
+
def mech_classes
|
944
|
+
MECH_CLASSES
|
945
|
+
end
|
946
|
+
|
947
|
+
def initialize(options = {})
|
948
|
+
super(options)
|
949
|
+
unless @parse_text
|
950
|
+
scopes = @scopes || {}
|
951
|
+
raise SPF::InvalidScopeError.new('No scopes for spf2.0 record') if scopes.empty?
|
952
|
+
scopes.each do |scope|
|
953
|
+
if scope !~ VALID_SCOPE
|
954
|
+
raise SPF::InvalidScopeError.new("Invalid scope '#{scope}' for spf2.0 record")
|
955
|
+
end
|
956
|
+
end
|
957
|
+
end
|
958
|
+
end
|
959
|
+
|
960
|
+
def version_tag
|
961
|
+
return 'spf2.0' if not @scopes # no scopes parsed
|
962
|
+
return 'spf2.0/' + @scopes.join(',')
|
963
|
+
end
|
964
|
+
|
965
|
+
def parse_version_tag
|
966
|
+
|
967
|
+
@parse_text.sub!(/#{version_tag_pattern}(?:\x20+|$)/ix, '')
|
968
|
+
if $1
|
969
|
+
scopes = @scopes = "#{$2}".split(/,/)
|
970
|
+
if scopes.empty?
|
971
|
+
raise SPF::InvalidScopeError.new('No scopes for spf2.0 record')
|
972
|
+
end
|
973
|
+
scopes.each do |scope|
|
974
|
+
if scope !~ VALID_SCOPE
|
975
|
+
raise SPF::InvalidScopeError.new("Invalid scope '#{scope}' for spf2.0 record")
|
976
|
+
end
|
977
|
+
end
|
978
|
+
else
|
979
|
+
raise SPF::InvalidRecordVersionError.new(
|
980
|
+
"Not a 'spf2.0' record: '#{@text}'")
|
981
|
+
end
|
982
|
+
end
|
983
|
+
end
|
984
|
+
end
|
985
|
+
|