mailauth 0.3.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +211 -0
- data/lib/mailauth/arc/chain.rb +100 -0
- data/lib/mailauth/arc/message_signature.rb +158 -0
- data/lib/mailauth/arc/seal.rb +97 -0
- data/lib/mailauth/arc/set.rb +74 -0
- data/lib/mailauth/arc.rb +143 -0
- data/lib/mailauth/authentication_results.rb +164 -0
- data/lib/mailauth/dkim/algorithm.rb +28 -0
- data/lib/mailauth/dkim/canonicalization.rb +40 -0
- data/lib/mailauth/dkim/public_key.rb +54 -0
- data/lib/mailauth/dkim/signer.rb +122 -0
- data/lib/mailauth/dkim.rb +270 -0
- data/lib/mailauth/dmarc/alignment.rb +32 -0
- data/lib/mailauth/dmarc/organizational_domain.rb +22 -0
- data/lib/mailauth/dmarc/record.rb +120 -0
- data/lib/mailauth/dmarc.rb +64 -0
- data/lib/mailauth/domain.rb +34 -0
- data/lib/mailauth/message.rb +76 -0
- data/lib/mailauth/resolver.rb +92 -0
- data/lib/mailauth/result.rb +100 -0
- data/lib/mailauth/spf/budget.rb +111 -0
- data/lib/mailauth/spf/macro.rb +121 -0
- data/lib/mailauth/spf/record.rb +188 -0
- data/lib/mailauth/spf.rb +243 -0
- data/lib/mailauth/version.rb +3 -0
- data/lib/mailauth.rb +31 -0
- data/mailauth.gemspec +39 -0
- metadata +116 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "dnsruby"
|
|
2
|
+
require "ipaddr"
|
|
3
|
+
|
|
4
|
+
module MailAuth
|
|
5
|
+
# DNS for authentication. Injectable so tests can stand a hash in for a network.
|
|
6
|
+
# NotFound vs Timeout/ServerFailure matters: SPF counts a void lookup against a
|
|
7
|
+
# limit but abandons evaluation when the server didn't answer. Dnsruby rather
|
|
8
|
+
# than Resolv, which discards non-timeout rcodes and collapses SERVFAIL/REFUSED
|
|
9
|
+
# into the same empty answer as NXDOMAIN.
|
|
10
|
+
class Resolver
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class NotFound < Error; end # NXDOMAIN or NODATA
|
|
13
|
+
class Timeout < Error; end
|
|
14
|
+
class ServerFailure < Error; end # SERVFAIL, REFUSED, or anything else we couldn't tell
|
|
15
|
+
|
|
16
|
+
QUERY_TIMEOUT = 5
|
|
17
|
+
|
|
18
|
+
def initialize(timeout: QUERY_TIMEOUT)
|
|
19
|
+
@timeout = timeout
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# TXT character-strings are joined into one value (RFC 7208 §3.3).
|
|
23
|
+
def txt(name)
|
|
24
|
+
records("TXT", name).map { |record| record.strings.join }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def a(name)
|
|
28
|
+
records("A", name).map { |record| record.address.to_s }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Dnsruby prints IPv6 in capitals; RFC 5952 §4.3 asks for lower case.
|
|
32
|
+
def aaaa(name)
|
|
33
|
+
records("AAAA", name).map { |record| record.address.to_s.downcase }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mx(name)
|
|
37
|
+
records("MX", name).sort_by(&:preference).map { |record| record.exchange.to_s }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Dnsruby puts the question name in `name` and the host in `domainname`.
|
|
41
|
+
def ptr(address)
|
|
42
|
+
records("PTR", IPAddr.new(address).reverse).map { |record| record.domainname.to_s }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
# Filter by type: a CNAME answer carries the alias beside the records it leads to.
|
|
47
|
+
def records(type, name)
|
|
48
|
+
answers = resolver.query(name.to_s, type).answer.select { |record| record.type == type }
|
|
49
|
+
|
|
50
|
+
if answers.any?
|
|
51
|
+
answers
|
|
52
|
+
else
|
|
53
|
+
raise NotFound, "no #{type} records for #{name}"
|
|
54
|
+
end
|
|
55
|
+
rescue Dnsruby::NXDomain
|
|
56
|
+
raise NotFound, "no such name #{name}"
|
|
57
|
+
rescue Dnsruby::ResolvTimeout
|
|
58
|
+
# Descends from Timeout::Error, not Dnsruby::ResolvError.
|
|
59
|
+
raise Timeout, "timed out resolving #{type} #{name}"
|
|
60
|
+
rescue Dnsruby::ResolvError => error
|
|
61
|
+
# Fall through to ServerFailure, never NotFound: "we could not tell"
|
|
62
|
+
# must not become "there is nothing there".
|
|
63
|
+
raise ServerFailure, "couldn't resolve #{type} #{name}: #{reason(error)}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Dnsruby rcode classes often carry no message (message == class name).
|
|
67
|
+
# `class.name` is nil on an anonymous class — don't call methods on that.
|
|
68
|
+
def reason(error)
|
|
69
|
+
rcode = error.class.name&.split("::")&.last
|
|
70
|
+
|
|
71
|
+
case
|
|
72
|
+
when rcode.nil? then error.message
|
|
73
|
+
when error.message == error.class.name then rcode
|
|
74
|
+
else "#{rcode}, #{error.message}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def resolver
|
|
79
|
+
@resolver ||= Dnsruby::Resolver.new(**options).tap do |dns|
|
|
80
|
+
dns.dnssec = false
|
|
81
|
+
# Dnsruby caches by default in a process-wide store.
|
|
82
|
+
dns.do_caching = false
|
|
83
|
+
dns.query_timeout = @timeout
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Override in tests to point at a loopback nameserver.
|
|
88
|
+
def options
|
|
89
|
+
{}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
# The vocabulary, matching what RFC 8601 registers so a verdict travels
|
|
3
|
+
# without translation.
|
|
4
|
+
module Status
|
|
5
|
+
PASS = "pass"
|
|
6
|
+
FAIL = "fail"
|
|
7
|
+
SOFTFAIL = "softfail"
|
|
8
|
+
NEUTRAL = "neutral"
|
|
9
|
+
NONE = "none"
|
|
10
|
+
TEMPERROR = "temperror"
|
|
11
|
+
PERMERROR = "permerror"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# `fail` says the check failed; `policy` says what the domain asked us to do
|
|
15
|
+
# about it. Folding those together is a consumer's decision, not ours.
|
|
16
|
+
DmarcResult = Data.define(:status, :policy, :pct, :alignment, :record) do
|
|
17
|
+
def initialize(status:, policy: nil, pct: nil, alignment: nil, record: nil)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
SpfResult = Data.define(:status, :domain, :comment, :lookups) do
|
|
23
|
+
def initialize(status:, domain: nil, comment: nil, lookups: 0)
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# One per DKIM-Signature header, whatever each one's fate.
|
|
29
|
+
Signature = Data.define(:status, :domain, :selector, :algorithm, :canonicalization, :comment, :aligned, :under_sized) do
|
|
30
|
+
def initialize(status:, domain: nil, selector: nil, algorithm: nil, canonicalization: nil, comment: nil, aligned: false, under_sized: nil)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
DkimResult = Data.define(:signatures) do
|
|
36
|
+
# Precedence, not position: one good signature vindicates a message however
|
|
37
|
+
# many broken ones travel beside it.
|
|
38
|
+
PRECEDENCE = [ Status::PASS, Status::FAIL, Status::TEMPERROR, Status::PERMERROR, Status::NEUTRAL, Status::NONE ].freeze
|
|
39
|
+
|
|
40
|
+
def status
|
|
41
|
+
PRECEDENCE.find { |status| signatures.any? { |signature| signature.status == status } } || Status::NONE
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def passing_domains
|
|
45
|
+
signatures.select { |signature| signature.status == Status::PASS }.map(&:domain).compact
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One ARC-Seal and one ARC-Message-Signature, reported apart because RFC 8617
|
|
50
|
+
# places no requirement that a set sign both with the same domain or selector.
|
|
51
|
+
ArcSeal = Data.define(:status, :domain, :selector, :algorithm, :comment) do
|
|
52
|
+
def initialize(status:, domain: nil, selector: nil, algorithm: nil, comment: nil)
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
ArcSignature = Data.define(:status, :domain, :selector, :algorithm, :comment) do
|
|
58
|
+
def initialize(status:, domain: nil, selector: nil, algorithm: nil, comment: nil)
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# One ARC Set (RFC 8617 §4.2): what one hop sealed, and what it asserted about
|
|
64
|
+
# the message when it did. `claimed` is that hop's own account of the message's
|
|
65
|
+
# authentication, which a valid chain proves unmodified since sealing and
|
|
66
|
+
# proves nothing else about.
|
|
67
|
+
ArcSet = Data.define(:instance, :chain_status, :seal, :message_signature, :authentication_results, :claimed) do
|
|
68
|
+
def initialize(instance:, chain_status: nil, seal: nil, message_signature: nil, authentication_results: nil, claimed: {})
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether the chain holds, and who sealed it — never whether any of them is to
|
|
74
|
+
# be believed. A chain is cheap to forge over a message of one's own, so trust
|
|
75
|
+
# is the caller's decision and deliberately has no answer here.
|
|
76
|
+
ArcResult = Data.define(:status, :sets, :oldest_pass, :comment) do
|
|
77
|
+
def initialize(status:, sets: [], oldest_pass: nil, comment: nil)
|
|
78
|
+
super
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def newest
|
|
82
|
+
sets.max_by(&:instance)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Oldest hop first, the order they sealed in.
|
|
86
|
+
def sealers
|
|
87
|
+
sets.sort_by(&:instance).filter_map { |set| set.seal&.domain }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def intact?
|
|
91
|
+
status == Status::PASS
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Result = Data.define(:spf, :dkim, :dmarc, :arc, :authentication_results) do
|
|
96
|
+
def initialize(spf:, dkim:, dmarc:, authentication_results:, arc: nil)
|
|
97
|
+
super
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
module Spf
|
|
3
|
+
# RFC 7208 §4.6.4's allowance per evaluation, spent one query at a time.
|
|
4
|
+
# Ten lookups caps a stranger's record chaining `include`s forever; two
|
|
5
|
+
# voids catches a record whose targets mostly don't exist.
|
|
6
|
+
class Budget
|
|
7
|
+
MAX_LOOKUPS = 10
|
|
8
|
+
MAX_VOID_LOOKUPS = 2
|
|
9
|
+
MAX_NAME_LENGTH = 253
|
|
10
|
+
MAX_LABEL_LENGTH = 63
|
|
11
|
+
|
|
12
|
+
attr_reader :lookups
|
|
13
|
+
|
|
14
|
+
# `charging: false` lets the opening record fetch through free: it belongs
|
|
15
|
+
# to no mechanism, so nobody is billed for it.
|
|
16
|
+
def initialize(resolver, charging: true)
|
|
17
|
+
@resolver = resolver
|
|
18
|
+
@charging = charging
|
|
19
|
+
@lookups = 0
|
|
20
|
+
@voids = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def txt(name)
|
|
24
|
+
query(:txt, name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def a(name)
|
|
28
|
+
query(:a, name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def aaaa(name)
|
|
32
|
+
query(:aaaa, name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mx(name)
|
|
36
|
+
query(:mx, name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Takes the client address, not a name: the reverse zone is the
|
|
40
|
+
# resolver's business.
|
|
41
|
+
def ptr(address)
|
|
42
|
+
resolve(:ptr, address)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `mx` and `ptr` fan out into an address lookup per name. RFC 7208 bounds
|
|
46
|
+
# that separately, so it gets its own allowance.
|
|
47
|
+
def branch
|
|
48
|
+
Budget.new(@resolver)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
# A name that can't be put on the wire isn't an error — treated as a
|
|
53
|
+
# name that simply isn't there. Length alone doesn't make one: an
|
|
54
|
+
# over-long name is shortened and then asked for.
|
|
55
|
+
def query(type, name)
|
|
56
|
+
name = truncate(name)
|
|
57
|
+
|
|
58
|
+
if queryable?(name)
|
|
59
|
+
resolve(type, name)
|
|
60
|
+
else
|
|
61
|
+
spend
|
|
62
|
+
void
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# RFC 7208 §7.3: a macro expansion past 253 octets loses whole labels
|
|
67
|
+
# from the left until it fits. The query is still made, so this costs a
|
|
68
|
+
# lookup and never a void.
|
|
69
|
+
def truncate(name)
|
|
70
|
+
name = name.to_s
|
|
71
|
+
name = name.split(".", 2).last while name.length > MAX_NAME_LENGTH && name.include?(".")
|
|
72
|
+
name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve(type, name)
|
|
76
|
+
spend
|
|
77
|
+
@resolver.public_send(type, name)
|
|
78
|
+
rescue Resolver::NotFound
|
|
79
|
+
void
|
|
80
|
+
rescue Resolver::Timeout, Resolver::ServerFailure => error
|
|
81
|
+
raise TempError, error.message
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def spend
|
|
85
|
+
if @charging
|
|
86
|
+
@lookups += 1
|
|
87
|
+
raise PermError, "more than #{MAX_LOOKUPS} DNS lookups" if @lookups > MAX_LOOKUPS
|
|
88
|
+
else
|
|
89
|
+
@charging = true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def void
|
|
94
|
+
@voids += 1
|
|
95
|
+
|
|
96
|
+
if @voids > MAX_VOID_LOOKUPS
|
|
97
|
+
raise PermError, "more than #{MAX_VOID_LOOKUPS} void DNS lookups"
|
|
98
|
+
else
|
|
99
|
+
[]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def queryable?(name)
|
|
104
|
+
labels = name.to_s.chomp(".").split(".", -1)
|
|
105
|
+
|
|
106
|
+
name.to_s.length <= MAX_NAME_LENGTH && !labels.empty? &&
|
|
107
|
+
labels.all? { |label| (1..MAX_LABEL_LENGTH).cover?(label.length) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
module Spf
|
|
3
|
+
# RFC 7208 §7. The little templating language a record uses to build a name
|
|
4
|
+
# out of the message in front of it — `exists:%{ir}.%{d}` asks a question
|
|
5
|
+
# about this particular client rather than about the domain in general.
|
|
6
|
+
module Macro
|
|
7
|
+
# Whatever a transformer splits on, the pieces are rejoined with dots —
|
|
8
|
+
# the result is going into a domain name.
|
|
9
|
+
DELIMITERS = ".-+,/_="
|
|
10
|
+
|
|
11
|
+
# The expansion form, plus the three escapes, ordered so a lone `%` only
|
|
12
|
+
# matches once nothing legal does — exactly when it's a syntax error.
|
|
13
|
+
EXPANSION = /%\{([^}]*)\}|%%|%_|%-|%/
|
|
14
|
+
|
|
15
|
+
# macro-letter transformers *delimiter
|
|
16
|
+
BODY = /\A([a-z])(\d*)([r]?)([#{Regexp.escape(DELIMITERS)}]*)\z/i
|
|
17
|
+
|
|
18
|
+
# RFC 3986's unreserved set. An uppercase macro letter expands the same as
|
|
19
|
+
# its lowercase twin and then URL-escapes everything outside this.
|
|
20
|
+
RESERVED = /[^A-Za-z0-9\-._~]/
|
|
21
|
+
|
|
22
|
+
# toplabel = ( *alphanum ALPHA *alphanum ) / ( 1*alphanum "-" *( alphanum / "-" ) alphanum )
|
|
23
|
+
TOPLABEL = /(?:[a-z0-9]*[a-z][a-z0-9]*|[a-z0-9]+-[a-z0-9\-]*[a-z0-9])/i
|
|
24
|
+
|
|
25
|
+
# The tail of a domain-spec must be a real label rather than something a
|
|
26
|
+
# macro happened to produce, or a macro expansion standing alone.
|
|
27
|
+
DOMAIN_END = /\.#{TOPLABEL}\.?\z/o
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Raises PermError on anything the ABNF doesn't allow: a record that
|
|
31
|
+
# can't be parsed can't be obeyed.
|
|
32
|
+
def expand(string, context)
|
|
33
|
+
string.to_s.gsub(EXPANSION) do |match|
|
|
34
|
+
case match
|
|
35
|
+
when "%%" then "%"
|
|
36
|
+
when "%_" then " "
|
|
37
|
+
when "%-" then "%20"
|
|
38
|
+
when "%" then raise PermError, "stray % in #{string}"
|
|
39
|
+
else expansion(Regexp.last_match(1), context)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Checked before expansion, not after — a macro may expand into
|
|
45
|
+
# something no resolver will answer for, but that fails the
|
|
46
|
+
# mechanism, not the record.
|
|
47
|
+
def domain_spec(string, context)
|
|
48
|
+
if string.to_s.empty?
|
|
49
|
+
raise PermError, "empty domain-spec"
|
|
50
|
+
elsif !ends_well?(string)
|
|
51
|
+
raise PermError, "domain-spec #{string} does not end in a macro or a top-level label"
|
|
52
|
+
else
|
|
53
|
+
expand(string, context).downcase.chomp(".")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
def ends_well?(string)
|
|
59
|
+
literal_tail = string.sub(/\A.*(?:%\{[^}]*\}|%%|%_|%-)/m, "")
|
|
60
|
+
literal_tail.empty? || literal_tail.match?(DOMAIN_END)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def expansion(body, context)
|
|
64
|
+
letter, digits, reverse, delimiters = body.match(BODY)&.captures
|
|
65
|
+
|
|
66
|
+
if letter
|
|
67
|
+
value = transform(value_for(letter.downcase, context), digits, !reverse.empty?, delimiters)
|
|
68
|
+
letter == letter.upcase ? escape(value) : value
|
|
69
|
+
else
|
|
70
|
+
raise PermError, "invalid macro %{#{body}}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def value_for(letter, context)
|
|
75
|
+
case letter
|
|
76
|
+
when "s" then context.sender
|
|
77
|
+
when "l" then context.local
|
|
78
|
+
when "o" then context.sender_domain
|
|
79
|
+
when "d" then context.domain
|
|
80
|
+
when "i" then dotted(context.ip)
|
|
81
|
+
when "v" then context.ip.ipv4? ? "in-addr" : "ip6"
|
|
82
|
+
when "h" then context.helo.to_s
|
|
83
|
+
# %{p} needs the PTR lookup and forward confirmation of §5.5, which
|
|
84
|
+
# §7.2 discourages anyone from relying on and we don't do. "unknown"
|
|
85
|
+
# is what §7.2 prescribes when no name validates, and it matches
|
|
86
|
+
# nothing — where a real domain here would let `exists:%{p}.…` hit
|
|
87
|
+
# by accident.
|
|
88
|
+
when "p" then "unknown"
|
|
89
|
+
# c, r and t are legal in an explanation string and nowhere else,
|
|
90
|
+
# and we don't produce explanations.
|
|
91
|
+
else raise PermError, "macro letter %{#{letter}} is not allowed here"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Dotted-quad for IPv4; one hex nibble per label for IPv6.
|
|
96
|
+
def dotted(ip)
|
|
97
|
+
if ip.ipv4?
|
|
98
|
+
ip.to_s
|
|
99
|
+
else
|
|
100
|
+
ip.to_string.delete(":").chars.join(".")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def transform(value, digits, reverse, delimiters)
|
|
105
|
+
if digits.empty? && !reverse && (delimiters.empty? || delimiters == ".")
|
|
106
|
+
value
|
|
107
|
+
else
|
|
108
|
+
parts = value.split(/[#{Regexp.escape(delimiters.empty? ? "." : delimiters)}]/)
|
|
109
|
+
parts = parts.reverse if reverse
|
|
110
|
+
parts = parts.last(digits.to_i) if digits.to_i > 0
|
|
111
|
+
parts.join(".")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def escape(value)
|
|
116
|
+
value.gsub(RESERVED) { |char| char.bytes.map { |byte| format("%%%02X", byte) }.join }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
module Spf
|
|
3
|
+
# `target` is already macro-expanded at parse time, so a broken macro is
|
|
4
|
+
# caught even when the mechanism it sits in is never reached.
|
|
5
|
+
Term = Data.define(:qualifier, :mechanism, :target, :network, :cidr4, :cidr6) do
|
|
6
|
+
def initialize(qualifier:, mechanism:, target: nil, network: nil, cidr4: nil, cidr6: nil)
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# A `v=spf1` record, read and taken apart. Parsing is complete before
|
|
12
|
+
# evaluation begins, so `v=spf1 -all ip6` is a broken record — not a fail
|
|
13
|
+
# — even though nothing after `-all` can ever be evaluated.
|
|
14
|
+
class Record
|
|
15
|
+
VERSION = "v=spf1"
|
|
16
|
+
|
|
17
|
+
# A modifier is a name and an "=" reached before any ":" or "/".
|
|
18
|
+
# `redirect:example.com` therefore isn't one — it's a mechanism named
|
|
19
|
+
# "redirect", which is to say a typo. The name stops at the first "=":
|
|
20
|
+
# every one after it belongs to the macro-string, where "=" is both a
|
|
21
|
+
# legal delimiter and a legal literal.
|
|
22
|
+
MODIFIER = /\A(?<name>[^:\/=]*)=(?<value>.*)\z/
|
|
23
|
+
MODIFIER_NAME = /\A[a-z][a-z0-9\-_.]*\z/i
|
|
24
|
+
|
|
25
|
+
MECHANISM = /\A(?<qualifier>[+\-~?])?(?<name>[a-z0-9_.\-]+)(?<argument>[:\/].*)?\z/i
|
|
26
|
+
|
|
27
|
+
# domain-spec followed by an optional dual-cidr-length. The domain is
|
|
28
|
+
# matched lazily so that a trailing "/24" is read as a prefix length,
|
|
29
|
+
# while the slashes a domain-spec is allowed to contain stay in the name.
|
|
30
|
+
ARGUMENT = /\A(?<spec>.*?)(?:\/(?<cidr4>\d+))?(?:\/\/(?<cidr6>\d+))?\z/
|
|
31
|
+
DUAL_CIDR = /\A(?:\/(?<cidr4>\d+))?(?:\/\/(?<cidr6>\d+))?\z/
|
|
32
|
+
ADDRESS = /\A(?<address>.*?)(?:\/(?<length>\d+))?\z/
|
|
33
|
+
|
|
34
|
+
# SPF is defined over 7-bit ASCII, and a record carrying anything else has
|
|
35
|
+
# been mangled somewhere between its author and us.
|
|
36
|
+
PRINTABLE = /\A[\x20-\x7e]*\z/
|
|
37
|
+
|
|
38
|
+
# Zero is not an error but an answer — the domain publishes no policy.
|
|
39
|
+
# Two is: nothing chooses between them.
|
|
40
|
+
def self.fetch(domain, budget:, context:)
|
|
41
|
+
records = budget.txt(domain).select { |record| record.strip.split(/\s+/).first&.downcase == VERSION }
|
|
42
|
+
|
|
43
|
+
case records.length
|
|
44
|
+
when 0 then raise NoRecord, "no SPF record for #{domain}"
|
|
45
|
+
when 1 then new(records.first, context)
|
|
46
|
+
else raise PermError, "#{records.length} SPF records for #{domain}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :terms, :redirect
|
|
51
|
+
|
|
52
|
+
def initialize(text, context)
|
|
53
|
+
@text = text
|
|
54
|
+
@context = context
|
|
55
|
+
@terms = []
|
|
56
|
+
@modifiers = {}
|
|
57
|
+
|
|
58
|
+
parse
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
def parse
|
|
63
|
+
raise PermError, "non-ASCII characters in record" unless @text.match?(PRINTABLE)
|
|
64
|
+
|
|
65
|
+
@text.strip.split(/\s+/).drop(1).each do |term|
|
|
66
|
+
if modifier = term.match(MODIFIER)
|
|
67
|
+
parse_modifier(modifier[:name], modifier[:value])
|
|
68
|
+
else
|
|
69
|
+
@terms << parse_mechanism(term)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@redirect = @modifiers["redirect"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_modifier(name, value)
|
|
77
|
+
raise PermError, "invalid modifier name #{name}" unless name.match?(MODIFIER_NAME)
|
|
78
|
+
|
|
79
|
+
case name.downcase
|
|
80
|
+
when "redirect", "exp"
|
|
81
|
+
if @modifiers.key?(name.downcase)
|
|
82
|
+
raise PermError, "#{name} given more than once"
|
|
83
|
+
else
|
|
84
|
+
@modifiers[name.downcase] = Macro.domain_spec(value, @context)
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
# An unknown modifier does nothing, but its value is still a
|
|
88
|
+
# macro-string, and a record that can't state it correctly is
|
|
89
|
+
# broken however little we care about the answer.
|
|
90
|
+
Macro.expand(value, @context)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_mechanism(term)
|
|
95
|
+
mechanism = term.match(MECHANISM)
|
|
96
|
+
raise PermError, "invalid term #{term}" unless mechanism
|
|
97
|
+
|
|
98
|
+
qualifier = mechanism[:qualifier] || "+"
|
|
99
|
+
argument = mechanism[:argument]
|
|
100
|
+
|
|
101
|
+
case name = mechanism[:name].downcase
|
|
102
|
+
when "all" then all_term(qualifier, argument)
|
|
103
|
+
when "include", "exists" then named_term(qualifier, name.to_sym, argument)
|
|
104
|
+
when "ptr" then ptr_term(qualifier, argument)
|
|
105
|
+
when "a", "mx" then address_term(qualifier, name.to_sym, argument)
|
|
106
|
+
when "ip4", "ip6" then ip_term(qualifier, name.to_sym, argument)
|
|
107
|
+
else raise PermError, "unknown mechanism #{name}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def all_term(qualifier, argument)
|
|
112
|
+
if argument
|
|
113
|
+
raise PermError, "all takes no argument"
|
|
114
|
+
else
|
|
115
|
+
Term.new(qualifier: qualifier, mechanism: :all)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def named_term(qualifier, mechanism, argument)
|
|
120
|
+
if argument&.start_with?(":")
|
|
121
|
+
Term.new(qualifier: qualifier, mechanism: mechanism, target: Macro.domain_spec(argument[1..], @context))
|
|
122
|
+
else
|
|
123
|
+
raise PermError, "#{mechanism} needs a domain"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A bare `ptr` asks about the domain the record was found at.
|
|
128
|
+
def ptr_term(qualifier, argument)
|
|
129
|
+
if argument.nil?
|
|
130
|
+
Term.new(qualifier: qualifier, mechanism: :ptr, target: @context.domain)
|
|
131
|
+
elsif argument.start_with?(":")
|
|
132
|
+
Term.new(qualifier: qualifier, mechanism: :ptr, target: Macro.domain_spec(argument[1..], @context))
|
|
133
|
+
else
|
|
134
|
+
raise PermError, "ptr takes no prefix length"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def address_term(qualifier, mechanism, argument)
|
|
139
|
+
if argument.nil?
|
|
140
|
+
Term.new(qualifier: qualifier, mechanism: mechanism, target: @context.domain)
|
|
141
|
+
elsif argument.start_with?(":")
|
|
142
|
+
parsed = argument[1..].match(ARGUMENT)
|
|
143
|
+
|
|
144
|
+
Term.new(qualifier: qualifier, mechanism: mechanism, target: Macro.domain_spec(parsed[:spec], @context),
|
|
145
|
+
cidr4: prefix(parsed[:cidr4], 32), cidr6: prefix(parsed[:cidr6], 128))
|
|
146
|
+
else
|
|
147
|
+
parsed = argument.match(DUAL_CIDR) or raise PermError, "invalid prefix length #{argument}"
|
|
148
|
+
|
|
149
|
+
Term.new(qualifier: qualifier, mechanism: mechanism, target: @context.domain,
|
|
150
|
+
cidr4: prefix(parsed[:cidr4], 32), cidr6: prefix(parsed[:cidr6], 128))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ip_term(qualifier, mechanism, argument)
|
|
155
|
+
raise PermError, "#{mechanism} needs an address" unless argument&.start_with?(":")
|
|
156
|
+
|
|
157
|
+
parsed = argument[1..].match(ADDRESS)
|
|
158
|
+
bits = mechanism == :ip4 ? 32 : 128
|
|
159
|
+
# We have taken the prefix length off ourselves; IPAddr would happily
|
|
160
|
+
# read a second one, and `ip4:1.2.3.4//32` is not a subtlety, it's a
|
|
161
|
+
# mistake.
|
|
162
|
+
raise PermError, "#{parsed[:address]} is not an address" if parsed[:address].include?("/")
|
|
163
|
+
|
|
164
|
+
network = IPAddr.new(parsed[:address])
|
|
165
|
+
raise PermError, "#{parsed[:address]} is not an #{mechanism} address" unless network.family == family(mechanism)
|
|
166
|
+
|
|
167
|
+
Term.new(qualifier: qualifier, mechanism: mechanism, network: network.mask(prefix(parsed[:length], bits) || bits))
|
|
168
|
+
rescue IPAddr::Error
|
|
169
|
+
raise PermError, "#{parsed[:address]} is not an address"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def family(mechanism)
|
|
173
|
+
mechanism == :ip4 ? Socket::AF_INET : Socket::AF_INET6
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Leading zeros and prefix lengths past the end of the address are
|
|
177
|
+
# both rejected.
|
|
178
|
+
def prefix(digits, limit)
|
|
179
|
+
if digits
|
|
180
|
+
raise PermError, "prefix length /#{digits} has a leading zero" if digits.length > 1 && digits.start_with?("0")
|
|
181
|
+
raise PermError, "prefix length /#{digits} is out of range" if digits.to_i > limit
|
|
182
|
+
|
|
183
|
+
digits.to_i
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|