coppertone 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +34 -0
- data/.travis.yml +7 -0
- data/Gemfile +7 -0
- data/LICENSE +201 -0
- data/README.md +58 -0
- data/Rakefile +140 -0
- data/coppertone.gemspec +27 -0
- data/lib/coppertone/class_builder.rb +20 -0
- data/lib/coppertone/directive.rb +38 -0
- data/lib/coppertone/dns/error.rb +9 -0
- data/lib/coppertone/dns/mock_client.rb +106 -0
- data/lib/coppertone/dns/resolv_client.rb +110 -0
- data/lib/coppertone/dns.rb +3 -0
- data/lib/coppertone/domain_spec.rb +45 -0
- data/lib/coppertone/error.rb +29 -0
- data/lib/coppertone/ip_address_wrapper.rb +75 -0
- data/lib/coppertone/macro_context.rb +67 -0
- data/lib/coppertone/macro_string/macro_expand.rb +84 -0
- data/lib/coppertone/macro_string/macro_literal.rb +24 -0
- data/lib/coppertone/macro_string/macro_parser.rb +62 -0
- data/lib/coppertone/macro_string/macro_static_expand.rb +52 -0
- data/lib/coppertone/macro_string.rb +31 -0
- data/lib/coppertone/mechanism/a.rb +16 -0
- data/lib/coppertone/mechanism/all.rb +24 -0
- data/lib/coppertone/mechanism/cidr_parser.rb +14 -0
- data/lib/coppertone/mechanism/domain_spec_mechanism.rb +18 -0
- data/lib/coppertone/mechanism/domain_spec_optional.rb +46 -0
- data/lib/coppertone/mechanism/domain_spec_required.rb +37 -0
- data/lib/coppertone/mechanism/domain_spec_with_dual_cidr.rb +114 -0
- data/lib/coppertone/mechanism/exists.rb +14 -0
- data/lib/coppertone/mechanism/include.rb +18 -0
- data/lib/coppertone/mechanism/include_matcher.rb +34 -0
- data/lib/coppertone/mechanism/ip4.rb +13 -0
- data/lib/coppertone/mechanism/ip6.rb +13 -0
- data/lib/coppertone/mechanism/ip_mechanism.rb +48 -0
- data/lib/coppertone/mechanism/mx.rb +40 -0
- data/lib/coppertone/mechanism/ptr.rb +17 -0
- data/lib/coppertone/mechanism.rb +32 -0
- data/lib/coppertone/modifier/base.rb +24 -0
- data/lib/coppertone/modifier/exp.rb +34 -0
- data/lib/coppertone/modifier/redirect.rb +17 -0
- data/lib/coppertone/modifier/unknown.rb +16 -0
- data/lib/coppertone/modifier.rb +30 -0
- data/lib/coppertone/qualifier.rb +45 -0
- data/lib/coppertone/record.rb +86 -0
- data/lib/coppertone/record_evaluator.rb +63 -0
- data/lib/coppertone/record_finder.rb +34 -0
- data/lib/coppertone/request.rb +68 -0
- data/lib/coppertone/request_context.rb +67 -0
- data/lib/coppertone/request_count_limiter.rb +36 -0
- data/lib/coppertone/result.rb +50 -0
- data/lib/coppertone/sender_identity.rb +39 -0
- data/lib/coppertone/spf_service.rb +9 -0
- data/lib/coppertone/term.rb +13 -0
- data/lib/coppertone/utils/domain_utils.rb +59 -0
- data/lib/coppertone/utils/host_utils.rb +22 -0
- data/lib/coppertone/utils/ip_in_domain_checker.rb +53 -0
- data/lib/coppertone/utils/validated_domain_finder.rb +40 -0
- data/lib/coppertone/utils.rb +4 -0
- data/lib/coppertone/version.rb +3 -0
- data/lib/coppertone.rb +48 -0
- data/lib/resolv/dns/resource/in/spf.rb +15 -0
- data/spec/directive_spec.rb +41 -0
- data/spec/dns/resolv_client_spec.rb +307 -0
- data/spec/domain_spec_spec.rb +35 -0
- data/spec/ip_address_wrapper_spec.rb +67 -0
- data/spec/macro_context_spec.rb +69 -0
- data/spec/macro_string/macro_expand_spec.rb +79 -0
- data/spec/macro_string/macro_literal_spec.rb +27 -0
- data/spec/macro_string/macro_static_expand_spec.rb +67 -0
- data/spec/macro_string_spec.rb +20 -0
- data/spec/mechanism/a_spec.rb +198 -0
- data/spec/mechanism/all_spec.rb +22 -0
- data/spec/mechanism/exists_spec.rb +91 -0
- data/spec/mechanism/include_spec.rb +43 -0
- data/spec/mechanism/ip4_spec.rb +110 -0
- data/spec/mechanism/ip6_spec.rb +104 -0
- data/spec/mechanism/mx_spec.rb +51 -0
- data/spec/mechanism/ptr_spec.rb +43 -0
- data/spec/mechanism_spec.rb +4 -0
- data/spec/modifier_spec.rb +4 -0
- data/spec/open_spf/ALL_mechanism_syntax_spec.rb +38 -0
- data/spec/open_spf/A_mechanism_syntax_spec.rb +159 -0
- data/spec/open_spf/EXISTS_mechanism_syntax_spec.rb +46 -0
- data/spec/open_spf/IP4_mechanism_syntax_spec.rb +59 -0
- data/spec/open_spf/IP6_mechanism_syntax_spec.rb +60 -0
- data/spec/open_spf/Include_mechanism_semantics_and_syntax_spec.rb +56 -0
- data/spec/open_spf/Initial_processing_spec.rb +77 -0
- data/spec/open_spf/MX_mechanism_syntax_spec.rb +119 -0
- data/spec/open_spf/Macro_expansion_rules_spec.rb +154 -0
- data/spec/open_spf/PTR_mechanism_syntax_spec.rb +42 -0
- data/spec/open_spf/Processing_limits_spec.rb +72 -0
- data/spec/open_spf/Record_evaluation_spec.rb +75 -0
- data/spec/open_spf/Record_lookup_spec.rb +48 -0
- data/spec/open_spf/Selecting_records_spec.rb +61 -0
- data/spec/open_spf/Semantics_of_exp_and_other_modifiers_spec.rb +167 -0
- data/spec/open_spf/Test_cases_from_implementation_bugs_spec.rb +17 -0
- data/spec/qualifier_spec.rb +54 -0
- data/spec/record_evaluator_spec.rb +4 -0
- data/spec/record_finder_spec.rb +4 -0
- data/spec/record_spec.rb +100 -0
- data/spec/request_context_spec.rb +43 -0
- data/spec/request_count_limiter_spec.rb +28 -0
- data/spec/result_spec.rb +4 -0
- data/spec/rfc7208-tests.yml +2548 -0
- data/spec/sender_identity_spec.rb +69 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/term_spec.rb +38 -0
- data/spec/utils/domain_utils_spec.rb +60 -0
- data/spec/utils/host_utils_spec.rb +32 -0
- data/spec/utils/ip_in_domain_checker_spec.rb +4 -0
- data/spec/utils/validated_domain_finder_spec.rb +4 -0
- metadata +306 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# Represents an SPF record. Includes class level methods for parsing
|
|
3
|
+
# record from a text string.
|
|
4
|
+
class Record
|
|
5
|
+
VERSION_STR = 'v=spf1'
|
|
6
|
+
RECORD_REGEXP = /\A#{VERSION_STR}(\s|\z)/i
|
|
7
|
+
ALLOWED_CHARACTERS = /\A([\x21-\x7e ]+)\z/
|
|
8
|
+
|
|
9
|
+
attr_reader :text
|
|
10
|
+
def initialize(raw_text)
|
|
11
|
+
fail RecordParsingError if raw_text.blank?
|
|
12
|
+
fail RecordParsingError unless self.class.record?(raw_text)
|
|
13
|
+
fail RecordParsingError unless ALLOWED_CHARACTERS.match(raw_text)
|
|
14
|
+
@text = raw_text.dup
|
|
15
|
+
validate_and_parse
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.record?(record_text)
|
|
19
|
+
return false if record_text.blank?
|
|
20
|
+
RECORD_REGEXP.match(record_text.strip) ? true : false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.parse(text)
|
|
24
|
+
return nil unless record?(text)
|
|
25
|
+
new(text)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_and_parse
|
|
29
|
+
text_without_prefix = text[VERSION_STR.length..-1]
|
|
30
|
+
@term_tokens = text_without_prefix.strip.split(/ /)
|
|
31
|
+
parse_terms
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_terms
|
|
35
|
+
@terms = []
|
|
36
|
+
@term_tokens.each do |token|
|
|
37
|
+
term = Term.build_from_token(token)
|
|
38
|
+
fail RecordParsingError,
|
|
39
|
+
"Could not parse record with #{text}" unless term
|
|
40
|
+
@terms << term
|
|
41
|
+
end
|
|
42
|
+
normalize_terms
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_terms
|
|
46
|
+
# Discard any redirects if there is a directive with an
|
|
47
|
+
# all mechanism present
|
|
48
|
+
# Section 6.1
|
|
49
|
+
# TODO: PMG
|
|
50
|
+
find_redirect # Checks for duplicate redirect modifiers
|
|
51
|
+
exp # Checks for duplicate exp modifiers
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def directives
|
|
55
|
+
@directives ||= @terms.select { |t| t.is_a?(Coppertone::Directive) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def include_all?
|
|
59
|
+
directives.any? { |d| d.mechanism.is_a?(Coppertone::Mechanism::All) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def modifiers
|
|
63
|
+
@modifiers ||= @terms.select { |t| t.is_a?(Coppertone::Modifier) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def find_redirect
|
|
67
|
+
find_modifier(Coppertone::Modifier::Redirect)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def redirect
|
|
71
|
+
# Ignore if an 'all' modifier is present
|
|
72
|
+
return nil if include_all?
|
|
73
|
+
@redirect ||= find_redirect
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def exp
|
|
77
|
+
@exp ||= find_modifier(Coppertone::Modifier::Exp)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def find_modifier(klass)
|
|
81
|
+
arr = modifiers.select { |m| m.is_a?(klass) }
|
|
82
|
+
fail RecordParsingError if arr.size > 1
|
|
83
|
+
arr.first
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# A helper class for finding SPF records for a domain.
|
|
3
|
+
class RecordEvaluator
|
|
4
|
+
attr_reader :record
|
|
5
|
+
def initialize(record)
|
|
6
|
+
@record = record
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def evaluate(macro_context, request_context)
|
|
10
|
+
result = directive_result(macro_context, request_context)
|
|
11
|
+
return result unless result.none? || result.fail?
|
|
12
|
+
if result.fail?
|
|
13
|
+
evaluate_fail_result(result, macro_context, request_context)
|
|
14
|
+
else
|
|
15
|
+
# Evaluate redirect
|
|
16
|
+
evaluate_none_result(result, macro_context, request_context)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def directive_result(macro_context, request_context)
|
|
21
|
+
record.directives.reduce(Result.none) do |memo, d|
|
|
22
|
+
memo.none? ? d.evaluate(macro_context, request_context) : memo
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def evaluate_fail_result(result, macro_context, request_context)
|
|
27
|
+
add_exp_to_result(result, macro_context, request_context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add_exp_to_result(result, macro_context, request_context)
|
|
31
|
+
result = add_default_exp(result)
|
|
32
|
+
return result unless record.exp
|
|
33
|
+
computed_exp = record.exp.evaluate(macro_context, request_context)
|
|
34
|
+
result.explanation = computed_exp if computed_exp
|
|
35
|
+
result
|
|
36
|
+
rescue Coppertone::Error
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def add_default_exp(result)
|
|
41
|
+
result.explanation = Coppertone.config.default_explanation
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def follow_redirect?
|
|
46
|
+
# Ignore the redirect if there's an all
|
|
47
|
+
# mechanism in the record
|
|
48
|
+
record.redirect && !record.include_all?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def evaluate_none_result(result, macro_context, request_context)
|
|
52
|
+
return result unless follow_redirect?
|
|
53
|
+
redirect_target = record.redirect.evaluate(macro_context, request_context)
|
|
54
|
+
if redirect_target
|
|
55
|
+
redirect_record =
|
|
56
|
+
RecordFinder.new(request_context.dns_client, redirect_target).record
|
|
57
|
+
end
|
|
58
|
+
fail InvalidRedirectError unless redirect_record
|
|
59
|
+
rc = macro_context.with_domain(redirect_target)
|
|
60
|
+
RecordEvaluator.new(redirect_record).evaluate(rc, request_context)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# A helper class for finding SPF records for a domain.
|
|
3
|
+
class RecordFinder
|
|
4
|
+
attr_reader :dns_client, :domain
|
|
5
|
+
def initialize(dns_client, domain)
|
|
6
|
+
@dns_client = dns_client
|
|
7
|
+
@domain = domain
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def record
|
|
11
|
+
@record ||=
|
|
12
|
+
begin
|
|
13
|
+
validate_txt_records
|
|
14
|
+
Record.parse(txt_records.first)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def txt_records
|
|
19
|
+
@txt_records ||=
|
|
20
|
+
begin
|
|
21
|
+
if Coppertone::Utils::DomainUtils.valid?(domain)
|
|
22
|
+
dns_client.fetch_txt_records(domain).map { |r| r[:text] }
|
|
23
|
+
.select { |r| Record.record?(r) }
|
|
24
|
+
else
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_txt_records
|
|
31
|
+
fail AmbiguousSpfRecordError if txt_records.size > 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# Represents an SPF request.
|
|
3
|
+
class Request
|
|
4
|
+
attr_reader :ip_as_s, :sender, :helo_domain, :options, :result
|
|
5
|
+
attr_reader :helo_result, :mailfrom_result
|
|
6
|
+
def initialize(ip_as_s, sender, helo_domain, options = {})
|
|
7
|
+
@ip_as_s = ip_as_s
|
|
8
|
+
@sender = sender
|
|
9
|
+
@helo_domain = helo_domain
|
|
10
|
+
@options = options
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authenticate
|
|
14
|
+
check_spf_for_helo
|
|
15
|
+
return helo_result if helo_result && !helo_result.none?
|
|
16
|
+
|
|
17
|
+
check_spf_for_mailfrom
|
|
18
|
+
return mailfrom_result if mailfrom_result && !mailfrom_result.none?
|
|
19
|
+
|
|
20
|
+
no_matching_record? ? Result.none : Result.new(:neutral)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def no_matching_record?
|
|
24
|
+
helo_result.nil? && mailfrom_result.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check_spf_for_helo
|
|
28
|
+
@helo_result ||= check_spf_for_context(helo_context, 'helo')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check_spf_for_mailfrom
|
|
32
|
+
@mailfrom_result ||= check_spf_for_context(mailfrom_context, 'mailfrom')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def check_spf_for_context(macro_context, identity)
|
|
36
|
+
record = spf_record(macro_context)
|
|
37
|
+
@result = spf_request(macro_context, record, identity) if record
|
|
38
|
+
rescue Coppertone::TemperrorError => e
|
|
39
|
+
Result.temperror(e.message)
|
|
40
|
+
rescue Coppertone::PermerrorError => e
|
|
41
|
+
Result.permerror(e.message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def request_context
|
|
45
|
+
@request_context ||= RequestContext.new(options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def helo_context
|
|
49
|
+
MacroContext.new(helo_domain, ip_as_s, sender, helo_domain, options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def mailfrom_context
|
|
53
|
+
MacroContext.new(nil, ip_as_s, sender, helo_domain, options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def spf_record(macro_context)
|
|
57
|
+
RecordFinder.new(request_context.dns_client,
|
|
58
|
+
macro_context.domain).record
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def spf_request(macro_context, record, identity)
|
|
62
|
+
return Result.new(:none) if record.nil?
|
|
63
|
+
r = RecordEvaluator.new(record).evaluate(macro_context, request_context)
|
|
64
|
+
r.identity = identity
|
|
65
|
+
r
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require 'active_support/core_ext/module/delegation'
|
|
2
|
+
require 'coppertone/dns/resolv_client'
|
|
3
|
+
|
|
4
|
+
module Coppertone
|
|
5
|
+
# A container for information that should span the lifetime of
|
|
6
|
+
# an SPF check. This include the DNS client, the locale used
|
|
7
|
+
# for error messages, limits for DNS requests of different
|
|
8
|
+
# types, and limiters that ensure those limits are not exceeded
|
|
9
|
+
# across the lifetime of the request.
|
|
10
|
+
class RequestContext
|
|
11
|
+
def initialize(options = {})
|
|
12
|
+
@options = (options || {}).dup
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register_dns_lookup_term
|
|
16
|
+
dns_lookup_term_count_limiter.increment!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register_void_dns_result
|
|
20
|
+
void_dns_result_count_limiter.increment!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dns_lookups_per_mx_mechanism_limit
|
|
24
|
+
config_value(:dns_lookups_per_mx_mechanism_limit)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dns_lookups_per_ptr_mechanism_limit
|
|
28
|
+
config_value(:dns_lookups_per_ptr_mechanism_limit)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def message_locale
|
|
32
|
+
config_value(:message_locale)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def dns_client
|
|
36
|
+
@dns_client ||=
|
|
37
|
+
if @options[:dns_client]
|
|
38
|
+
@options[:dns_client]
|
|
39
|
+
elsif @options[:dns_client_class]
|
|
40
|
+
@options[:dns_client_class].new
|
|
41
|
+
elsif Coppertone.config.dns_client_class
|
|
42
|
+
Coppertone.config.dns_client_class.new
|
|
43
|
+
else
|
|
44
|
+
Coppertone::DNS::ResolvClient.new
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def dns_lookup_term_count_limiter
|
|
51
|
+
limit = config_value(:terms_requiring_dns_lookup_limit)
|
|
52
|
+
@dns_lookup_term_count_limiter ||=
|
|
53
|
+
Coppertone::RequestCountLimiter.new(limit, 'DNS lookup term')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def void_dns_result_count_limiter
|
|
57
|
+
limit = config_value(:void_dns_result_limit)
|
|
58
|
+
@void_dns_result_count_limiter ||=
|
|
59
|
+
Coppertone::RequestCountLimiter.new(limit, 'DNS lookup term')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def config_value(k)
|
|
63
|
+
return @options[k] if @options.key?(k)
|
|
64
|
+
Coppertone.config.send(k)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# A utility class that encapsulates counter and limit behavior. Primarily
|
|
3
|
+
# used to track and limit the number of DNS queries of various types.
|
|
4
|
+
class RequestCountLimiter
|
|
5
|
+
attr_accessor :count, :limit, :counter_description
|
|
6
|
+
def initialize(limit = nil, counter_description = nil)
|
|
7
|
+
self.limit = limit
|
|
8
|
+
self.counter_description = counter_description
|
|
9
|
+
self.count = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def increment!(num = 1)
|
|
13
|
+
self.count += num
|
|
14
|
+
check_if_limit_exceeded
|
|
15
|
+
count
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_if_limit_exceeded
|
|
19
|
+
return if limit.nil?
|
|
20
|
+
fail Coppertone::LimitExceededError, exception_message if exceeded?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def exception_message
|
|
24
|
+
"Maximum #{counter_description} limit of #{limit} exceeded."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exceeded?
|
|
28
|
+
return false unless limited?
|
|
29
|
+
count > limit
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def limited?
|
|
33
|
+
!limit.nil?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# The result of an SPF query. Includes a code, which indicates the
|
|
3
|
+
# overall result (pass, fail, softfail, etc.). For different
|
|
4
|
+
# results it may include the mechanism which led to the result,
|
|
5
|
+
# an error message, and/or an explanation string.
|
|
6
|
+
class Result
|
|
7
|
+
NONE = :none
|
|
8
|
+
|
|
9
|
+
PASS = :pass
|
|
10
|
+
FAIL = :fail
|
|
11
|
+
SOFTFAIL = :softfail
|
|
12
|
+
NEUTRAL = :neutral
|
|
13
|
+
|
|
14
|
+
TEMPERROR = :temperror
|
|
15
|
+
PERMERROR = :permerror
|
|
16
|
+
|
|
17
|
+
attr_reader :code, :mechanism
|
|
18
|
+
attr_accessor :explanation, :problem, :identity
|
|
19
|
+
def initialize(code, mechanism = nil)
|
|
20
|
+
@code = code
|
|
21
|
+
@mechanism = mechanism
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.from_directive(directive, code)
|
|
25
|
+
new(code, directive.mechanism)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.permerror(message)
|
|
29
|
+
r = Result.new(:permerror)
|
|
30
|
+
r.problem = message
|
|
31
|
+
r
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.temperror(message)
|
|
35
|
+
r = Result.new(:temperror)
|
|
36
|
+
r.problem = message
|
|
37
|
+
r
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.none
|
|
41
|
+
Result.new(:none)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
%w(none pass fail softfail neutral temperror permerror).each do |t|
|
|
45
|
+
define_method("#{t}?") do
|
|
46
|
+
self.class.const_get(t.upcase) == send(:code)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# A consolidated sender identity, suitable for use with an SPF request.
|
|
3
|
+
# Parses the identity and ensures validity. Also has accessor methods
|
|
4
|
+
# for the macro letters.
|
|
5
|
+
class SenderIdentity
|
|
6
|
+
DEFAULT_LOCALPART = 'postmaster'
|
|
7
|
+
EMAIL_ADDRESS_SPLIT_REGEXP = /^(.*)@(.*?)$/
|
|
8
|
+
|
|
9
|
+
attr_reader :sender, :localpart, :domain
|
|
10
|
+
def initialize(sender)
|
|
11
|
+
@sender = sender
|
|
12
|
+
initialize_localpart_and_domain
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
alias_method :s, :sender
|
|
16
|
+
alias_method :l, :localpart
|
|
17
|
+
alias_method :o, :domain
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def initialize_localpart(matches)
|
|
22
|
+
localpart_candidate = matches[1] if matches
|
|
23
|
+
@localpart =
|
|
24
|
+
localpart_candidate.blank? ? DEFAULT_LOCALPART : localpart_candidate
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize_domain(matches)
|
|
28
|
+
domain_candidate = matches[2] if matches
|
|
29
|
+
@domain =
|
|
30
|
+
domain_candidate.blank? ? sender : domain_candidate
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize_localpart_and_domain
|
|
34
|
+
matches = EMAIL_ADDRESS_SPLIT_REGEXP.match(sender)
|
|
35
|
+
initialize_localpart(matches)
|
|
36
|
+
initialize_domain(matches)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
# Instances of this class represent terms as defined in section 4.6.1 of
|
|
3
|
+
# the specification. The Term class should be considered abstract,
|
|
4
|
+
# and should only be instantiated as its concrete subclasses. Terms
|
|
5
|
+
# are generally parsed from text tokens in an SPF TXT record using the
|
|
6
|
+
# factory method in this class.
|
|
7
|
+
class Term
|
|
8
|
+
def self.build_from_token(token)
|
|
9
|
+
return nil unless token
|
|
10
|
+
Directive.matching_term(token) || Modifier.matching_term(token)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require 'addressable/idna'
|
|
2
|
+
|
|
3
|
+
module Coppertone
|
|
4
|
+
module Utils
|
|
5
|
+
# A utility class that includes methods for working with
|
|
6
|
+
# domain names.
|
|
7
|
+
class DomainUtils
|
|
8
|
+
def self.valid?(domain)
|
|
9
|
+
return false if domain.blank?
|
|
10
|
+
labels = to_labels(domain)
|
|
11
|
+
return false if labels.length <= 1
|
|
12
|
+
return false if domain.length > 253
|
|
13
|
+
return false if labels.any? { |l| !valid_label?(l) }
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.to_labels(domain)
|
|
18
|
+
Addressable::IDNA.to_ascii(domain).split('.')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
NO_DASH_REGEXP = /\A[a-zA-Z0-9]*[a-zA-Z]+[a-zA-Z0-9]*\z/
|
|
22
|
+
DASH_REGEXP = /\A[a-zA-Z0-9]+\-[a-zA-Z0-9\-]*[a-zA-Z0-9]+\z/
|
|
23
|
+
|
|
24
|
+
def self.valid_hostname_label?(l)
|
|
25
|
+
return false unless valid_label?(l)
|
|
26
|
+
NO_DASH_REGEXP.match(l) || DASH_REGEXP.match(l)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.valid_label?(l)
|
|
30
|
+
(l.length >= 0) && (l.length <= 63)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.macro_expanded_domain(domain)
|
|
34
|
+
return nil if domain.blank?
|
|
35
|
+
labels = to_labels(domain)
|
|
36
|
+
domain = labels.join('.')
|
|
37
|
+
while domain.length > 253
|
|
38
|
+
labels = labels.drop(1)
|
|
39
|
+
domain = labels.join('.')
|
|
40
|
+
end
|
|
41
|
+
domain
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.subdomain_of?(subdomain_candidate, domain)
|
|
45
|
+
subdomain_labels = to_labels(subdomain_candidate)
|
|
46
|
+
domain_labels = to_labels(domain)
|
|
47
|
+
num_labels_in_domain = domain_labels.length
|
|
48
|
+
return false if subdomain_labels.length <= domain_labels.length
|
|
49
|
+
subdomain_labels.last(num_labels_in_domain) == domain_labels
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.subdomain_or_same?(candidate, domain)
|
|
53
|
+
return false unless valid?(domain) && valid?(candidate)
|
|
54
|
+
return true if domain == candidate
|
|
55
|
+
subdomain_of?(candidate, domain)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require 'addressable/idna'
|
|
2
|
+
|
|
3
|
+
module Coppertone
|
|
4
|
+
module Utils
|
|
5
|
+
# A utility class that includes methods for working with
|
|
6
|
+
# data about the host.
|
|
7
|
+
class HostUtils
|
|
8
|
+
def self.hostname
|
|
9
|
+
@hostname ||=
|
|
10
|
+
begin
|
|
11
|
+
Socket.gethostbyname(Socket.gethostname).first
|
|
12
|
+
rescue SocketError
|
|
13
|
+
Socket.gethostname
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.clear_hostname
|
|
18
|
+
@hostname = nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
module Utils # rubocop:disable Style/Documentation
|
|
3
|
+
class IPInDomainChecker
|
|
4
|
+
def initialize(macro_context, request_context)
|
|
5
|
+
@macro_context = macro_context
|
|
6
|
+
@request_context = request_context
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def check(domain_name,
|
|
10
|
+
ip_v4_cidr_length = 32,
|
|
11
|
+
ip_v6_cidr_length = 128)
|
|
12
|
+
cidr_length = ip_v6? ? ip_v6_cidr_length : ip_v4_cidr_length
|
|
13
|
+
networks = ip_networks(domain_name, cidr_length)
|
|
14
|
+
@request_context.register_void_dns_result if networks.empty?
|
|
15
|
+
|
|
16
|
+
matching_network =
|
|
17
|
+
networks.find do |network|
|
|
18
|
+
network.include?(ip)
|
|
19
|
+
end
|
|
20
|
+
!matching_network.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ip_v6?
|
|
24
|
+
@macro_context.original_ipv6?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ip
|
|
28
|
+
ip_v6? ? @macro_context.ip_v6 : @macro_context.ip_v4
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ip_networks(domain_name, cidr_length)
|
|
32
|
+
ip_records =
|
|
33
|
+
if ip_v6?
|
|
34
|
+
dns_client.fetch_aaaa_records(domain_name)
|
|
35
|
+
else
|
|
36
|
+
dns_client.fetch_a_records(domain_name)
|
|
37
|
+
end
|
|
38
|
+
filtered_records(ip_records, cidr_length)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dns_client
|
|
42
|
+
@request_context.dns_client
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def filtered_records(recs, cidr_length)
|
|
46
|
+
ips = recs.map do |r|
|
|
47
|
+
IPAddr.new(r[:address]).mask(cidr_length.to_i)
|
|
48
|
+
end
|
|
49
|
+
ips.select { |i| !i.nil? }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Coppertone
|
|
2
|
+
module Utils # rubocop:disable Style/Documentation
|
|
3
|
+
# A class used to find validated domains as defined in
|
|
4
|
+
# section 5.5 of the RFC.
|
|
5
|
+
class ValidatedDomainFinder
|
|
6
|
+
def initialize(macro_context, request_context)
|
|
7
|
+
@mc = macro_context
|
|
8
|
+
@request_context = request_context
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def find(target_name)
|
|
12
|
+
ip = @mc.original_ipv6? ? @mc.ip_v6 : @mc.ip_v4
|
|
13
|
+
ptr_names = fetch_ptr_names(ip)
|
|
14
|
+
ip_checker = IPInDomainChecker.new(@mc, @request_context)
|
|
15
|
+
ptr_names.find { |n| ptr_record_matches?(ip_checker, target_name, n) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch_ptr_names(ip)
|
|
19
|
+
dns_client = @request_context.dns_client
|
|
20
|
+
names = dns_client.fetch_ptr_records(ip.reverse).map do |ptr|
|
|
21
|
+
ptr[:name]
|
|
22
|
+
end
|
|
23
|
+
record_limit =
|
|
24
|
+
@request_context.dns_lookups_per_ptr_mechanism_limit
|
|
25
|
+
record_limit ? names.slice(0, record_limit) : names
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ptr_record_matches?(ip_checker,
|
|
29
|
+
target_name, ptr_name)
|
|
30
|
+
is_candidate =
|
|
31
|
+
DomainUtils.subdomain_or_same?(ptr_name, target_name)
|
|
32
|
+
is_candidate && ip_checker.check(ptr_name)
|
|
33
|
+
rescue Coppertone::DNS::Error
|
|
34
|
+
# If a DNS error occurs when looking up a domain, treat it
|
|
35
|
+
# as a non match
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|