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