mailertogo-spf 0.1.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 +33 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/lib/mailertogo/spf/authorization.rb +390 -0
- data/lib/mailertogo/spf/merge_plan.rb +246 -0
- data/lib/mailertogo/spf/plan.rb +92 -0
- data/lib/mailertogo/spf/record.rb +84 -0
- data/lib/mailertogo/spf/resolver.rb +97 -0
- data/lib/mailertogo/spf/result.rb +72 -0
- data/lib/mailertogo/spf/sender.rb +53 -0
- data/lib/mailertogo/spf/version.rb +7 -0
- data/lib/mailertogo/spf.rb +133 -0
- metadata +67 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "mailertogo/spf/record"
|
|
5
|
+
require "mailertogo/spf/result"
|
|
6
|
+
require "mailertogo/spf/sender"
|
|
7
|
+
|
|
8
|
+
module MailerToGo
|
|
9
|
+
module SPF
|
|
10
|
+
# Answers one question the way a receiving MTA would: does <hostname>'s
|
|
11
|
+
# published SPF actually authorize this sender?
|
|
12
|
+
#
|
|
13
|
+
# Why resolve at all, instead of string-matching the record? Because SPF is
|
|
14
|
+
# a TREE, not a string. The obvious implementation — "does the apex record
|
|
15
|
+
# contain the literal token `include:_spf.mailertogo.net`" — reports a false
|
|
16
|
+
# failure against every customer who publishes an outer alias:
|
|
17
|
+
#
|
|
18
|
+
# example.com TXT v=spf1 include:spf.hosting.example include:mailertogo.net ~all
|
|
19
|
+
# mailertogo.net TXT v=spf1 include:_spf.mailertogo.net ~all
|
|
20
|
+
# _spf.mailertogo.net TXT v=spf1 ip4:… ip4:… ~all
|
|
21
|
+
#
|
|
22
|
+
# That record passes SPF at every real receiver. A literal match calls it a
|
|
23
|
+
# failure, and anything gated on the answer — verification state, drift
|
|
24
|
+
# alerts, the ability to send — goes wrong with it. So we RESOLVE the chain:
|
|
25
|
+
# follow `include:` (and the `redirect=` modifier) until we reach a name the
|
|
26
|
+
# sender owns, or an ip4/ip6 mechanism covering the sender's addresses.
|
|
27
|
+
#
|
|
28
|
+
# RFC 7208 §4.6.4 caps an evaluation at 10 DNS-querying mechanisms; past
|
|
29
|
+
# that a receiver returns PERMERROR and the record does NOT pass. We enforce
|
|
30
|
+
# the same cap rather than silently passing a record real receivers reject —
|
|
31
|
+
# and it doubles as the bound that stops a hostile record walking us into an
|
|
32
|
+
# unbounded crawl.
|
|
33
|
+
#
|
|
34
|
+
# The cap counts the terms an evaluation actually EVALUATES, and §4.6.2 ends
|
|
35
|
+
# the evaluation at the first mechanism that MATCHES — so we walk terms in
|
|
36
|
+
# record order and stop at the match, exactly where a receiver stops.
|
|
37
|
+
# Counting the whole tree instead reports a false permerror for the very
|
|
38
|
+
# common record whose match lands on term 10 of an 11-term record: inside
|
|
39
|
+
# budget, and passing at every real receiver.
|
|
40
|
+
class Authorization
|
|
41
|
+
# RFC 7208 §4.6.4 — mechanisms that cost a DNS query (include/a/mx/ptr/
|
|
42
|
+
# exists) plus the redirect modifier, summed over the terms EVALUATED
|
|
43
|
+
# before the match. "MUST limit ... to 10" makes exactly 10 legal:
|
|
44
|
+
# 10 passes, 11 permerrors.
|
|
45
|
+
MAX_DNS_LOOKUPS = 10
|
|
46
|
+
|
|
47
|
+
# Belt-and-suspenders against a pathological tree; the lookup budget above
|
|
48
|
+
# is the real bound, this just stops runaway recursion on a wide shallow
|
|
49
|
+
# record.
|
|
50
|
+
MAX_DEPTH = 10
|
|
51
|
+
|
|
52
|
+
# Mechanisms that consume one DNS lookup from the budget above.
|
|
53
|
+
QUERYING_MECHANISMS = %w[include a mx ptr exists].freeze
|
|
54
|
+
|
|
55
|
+
# RFC 7208 §4.6.2 — the qualifier on a mechanism, here only ever read off
|
|
56
|
+
# the record's terminal `all`. See Result#all_qualifier.
|
|
57
|
+
ALL_QUALIFIERS = { "+" => :pass, "-" => :fail, "~" => :softfail, "?" => :neutral }.freeze
|
|
58
|
+
|
|
59
|
+
# hostname — the domain whose SPF we are reading.
|
|
60
|
+
# sender — a Sender: the names that mean "me".
|
|
61
|
+
# resolver — anything responding to #call(name); see Resolver.
|
|
62
|
+
# published — evaluate this record AS IF it were published at hostname,
|
|
63
|
+
# instead of whatever DNS says is there. The only way to ask
|
|
64
|
+
# "what would this cost a receiver?" of a line that is not
|
|
65
|
+
# published yet; MergePlan prices its merged record with it.
|
|
66
|
+
# Everything below the apex still resolves from real DNS.
|
|
67
|
+
# logger — optional, anything responding to #warn.
|
|
68
|
+
def self.call(hostname, sender:, resolver:, published: nil, logger: nil)
|
|
69
|
+
new(hostname, sender: sender, resolver: resolver, published: published, logger: logger).run
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The sending addresses a sender authorizes, read from its own leaf record
|
|
73
|
+
# — the same record `include:_spf.mailertogo.net` resolves to. Used to
|
|
74
|
+
# DETECT a customer who hardcoded those addresses (:pinned), not to bless
|
|
75
|
+
# it. Resolved separately from the customer's walk so it never costs them
|
|
76
|
+
# lookup budget.
|
|
77
|
+
def self.sending_nets(sender, resolver)
|
|
78
|
+
txts = resolver.call(sender.include_name)
|
|
79
|
+
record = Array(txts).map { |t| Record.normalize_txt(t) }.find { |t| Record.spf_record?(t) }
|
|
80
|
+
return [] if record.nil? || record.empty?
|
|
81
|
+
|
|
82
|
+
Record.ip_nets(Record.terms(record))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(hostname, sender:, resolver:, published: nil, logger: nil)
|
|
86
|
+
@host = Record.normalize_name(hostname)
|
|
87
|
+
@sender = sender
|
|
88
|
+
@lookup = resolver
|
|
89
|
+
@logger = logger
|
|
90
|
+
@published = published.to_s.empty? ? nil : published.to_s
|
|
91
|
+
@want = sender.include_name
|
|
92
|
+
@seen = Set.new([@host])
|
|
93
|
+
@lookups = 0
|
|
94
|
+
@dns_error = false
|
|
95
|
+
@permerror = nil
|
|
96
|
+
@matched = nil
|
|
97
|
+
@matched_directly = false
|
|
98
|
+
@match_at = nil
|
|
99
|
+
# [IPAddr, lookups spent before that term] — the second half is what
|
|
100
|
+
# lets us charge a pinned record only the lookups a receiver does
|
|
101
|
+
# before it stops.
|
|
102
|
+
@customer_nets = []
|
|
103
|
+
@ip_pinned = nil
|
|
104
|
+
@ip_coverage = nil
|
|
105
|
+
@ip_match_at = nil
|
|
106
|
+
@all_qualifier = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
records, err = apex_records
|
|
111
|
+
return unknown("DNS lookup for #{@host} failed") if err == :error
|
|
112
|
+
|
|
113
|
+
if records.size > 1
|
|
114
|
+
# RFC 7208 §4.5: more than one v=spf1 record is a PERMERROR — no
|
|
115
|
+
# evaluation happens at all. We still scan every one of them for the
|
|
116
|
+
# sender's include so the result can say "broken, but my record IS
|
|
117
|
+
# published" (see #matched).
|
|
118
|
+
records.each { |r| note_sender_names(Record.terms(r), 0) }
|
|
119
|
+
return permerror(:duplicate_records,
|
|
120
|
+
"#{@host} publishes more than one v=spf1 record — receivers " \
|
|
121
|
+
"return permerror (RFC 7208 §4.5) and no SPF passes for this domain")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
record = records.first
|
|
125
|
+
return failure("No v=spf1 record on #{@host}") if record.nil?
|
|
126
|
+
|
|
127
|
+
walk(record, depth: 0, authoritative: true)
|
|
128
|
+
match_by_ip! if @match_at.nil?
|
|
129
|
+
|
|
130
|
+
# Over budget beats everything: receivers PERMERROR such a record, so it
|
|
131
|
+
# does not pass even when the sender's include is sitting right there in
|
|
132
|
+
# it. What counts is the budget spent up to the MATCH (see #budget_used),
|
|
133
|
+
# not the size of the tree — terms after the match are never evaluated
|
|
134
|
+
# by anyone.
|
|
135
|
+
if budget_used > MAX_DNS_LOOKUPS
|
|
136
|
+
return permerror(:lookup_limit,
|
|
137
|
+
"#{@host}'s SPF needs more than #{MAX_DNS_LOOKUPS} DNS lookups " \
|
|
138
|
+
"(RFC 7208 §4.6.4) — receivers return permerror and the record never passes")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return permerror(*@permerror) if @permerror
|
|
142
|
+
return pass if @match_at
|
|
143
|
+
return pinned if @ip_pinned
|
|
144
|
+
return unknown("SPF chain for #{@host} could not be fully resolved (DNS lookup failed)") if @dns_error
|
|
145
|
+
|
|
146
|
+
failure("#{@host}'s SPF does not authorize #{@want} — resolved the full include chain " \
|
|
147
|
+
"(#{pluralize(@lookups, "DNS lookup")}) and #{@want} is not in it#{all_clause}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# A supplied record stands in for the apex answer (see .call's `published:`);
|
|
153
|
+
# its includes still resolve against live DNS below.
|
|
154
|
+
def apex_records
|
|
155
|
+
return spf_records_at(@host) if @published.nil?
|
|
156
|
+
|
|
157
|
+
[[Record.normalize_txt(@published)].select { |t| Record.spf_record?(t) }, nil]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Runaway guard for the crawl itself. The verdict uses #budget_used.
|
|
161
|
+
def over_budget? = @lookups > MAX_DNS_LOOKUPS
|
|
162
|
+
|
|
163
|
+
# The number a receiver measures against the §4.6.4 cap: DNS-querying
|
|
164
|
+
# terms evaluated up to and including the one that matched (§4.6.2 ends
|
|
165
|
+
# evaluation there). Only when nothing matches does the whole walk count.
|
|
166
|
+
# A record whose match lands on term 10 of an 11-term record spends 10 and
|
|
167
|
+
# passes; counting every node in the tree would spend 11 and permerror it.
|
|
168
|
+
def budget_used = @match_at || @ip_match_at || @lookups
|
|
169
|
+
|
|
170
|
+
# Walk one record's terms IN ORDER, descending into includes/redirects,
|
|
171
|
+
# exactly as far as a receiver would: evaluation stops at the first
|
|
172
|
+
# mechanism that matches (@match_at), at `all` (which always matches), and
|
|
173
|
+
# at the lookup budget. Collects ip4/ip6 terms as we go so a record that
|
|
174
|
+
# authorizes the sender's addresses directly still matches.
|
|
175
|
+
#
|
|
176
|
+
# `authoritative` marks the records whose `all` decides the domain's
|
|
177
|
+
# answer — the apex and anything it redirect=s to, never an include's (an
|
|
178
|
+
# included record's `all` never leaves that include, §5.2).
|
|
179
|
+
def walk(record, depth:, authoritative: false)
|
|
180
|
+
terms = Record.terms(record)
|
|
181
|
+
note_sender_names(terms, depth)
|
|
182
|
+
redirect = nil
|
|
183
|
+
|
|
184
|
+
terms.each do |term|
|
|
185
|
+
break if @match_at # §4.6.2 — evaluation ends at the first matching mechanism
|
|
186
|
+
|
|
187
|
+
qualifier = Record.qualifier_of(term)
|
|
188
|
+
t = Record.strip_qualifier(term)
|
|
189
|
+
|
|
190
|
+
if (target = t[/\Aredirect=(.+)\z/i, 1])
|
|
191
|
+
# A modifier, not a mechanism: it is evaluated only after every
|
|
192
|
+
# mechanism has failed to match (§6.1), so hold it until the loop
|
|
193
|
+
# is done.
|
|
194
|
+
redirect = target
|
|
195
|
+
next
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
mechanism = Record.mechanism_of(t)
|
|
199
|
+
case mechanism
|
|
200
|
+
when "all"
|
|
201
|
+
# `all` matches everything, so nothing after it is ever evaluated
|
|
202
|
+
# and any redirect= in the same record is ignored outright (§6.1).
|
|
203
|
+
@all_qualifier ||= ALL_QUALIFIERS.fetch(qualifier || "+", :pass) if authoritative
|
|
204
|
+
# Leaves #walk, not just the loop: a pending redirect= must be
|
|
205
|
+
# abandoned too, which a `break` would not do.
|
|
206
|
+
return # rubocop:disable Lint/NonLocalExitFromIterator
|
|
207
|
+
when "ip4", "ip6"
|
|
208
|
+
@customer_nets.concat(Record.ip_nets([t]).map { |net| [net, @lookups] })
|
|
209
|
+
when *QUERYING_MECHANISMS
|
|
210
|
+
break if over_budget?
|
|
211
|
+
|
|
212
|
+
if mechanism == "include"
|
|
213
|
+
descend(t.split(":", 2).last, depth)
|
|
214
|
+
else
|
|
215
|
+
# a / mx / ptr / exists cost budget but cannot name the sender —
|
|
216
|
+
# a sending service publishes an include target, never an a/mx
|
|
217
|
+
# mechanism a customer would borrow.
|
|
218
|
+
@lookups += 1
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
return if redirect.nil? || @match_at || over_budget?
|
|
224
|
+
|
|
225
|
+
# Nothing matched: the redirect target's evaluation replaces this
|
|
226
|
+
# record's, its `all` included.
|
|
227
|
+
descend(redirect, depth, authoritative: authoritative)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Free pass over a record's terms (no DNS) noting whether it names the
|
|
231
|
+
# sender. Done before the budget-limited expansion below so that a record
|
|
232
|
+
# which blows the 10-lookup cap STILL tells us the customer published the
|
|
233
|
+
# include — gating callers need that distinction so they do not un-verify
|
|
234
|
+
# a domain whose only sin is an over-long chain.
|
|
235
|
+
def note_sender_names(terms, depth)
|
|
236
|
+
terms.each do |term|
|
|
237
|
+
t = Record.strip_qualifier(term)
|
|
238
|
+
target = t[/\Ainclude:(.+)\z/i, 1] || t[/\Aredirect=(.+)\z/i, 1]
|
|
239
|
+
next if target.nil? || target.empty?
|
|
240
|
+
|
|
241
|
+
name = Record.normalize_name(target)
|
|
242
|
+
next unless @sender.covers?(name)
|
|
243
|
+
next unless @matched.nil?
|
|
244
|
+
|
|
245
|
+
@matched = name
|
|
246
|
+
@matched_directly = depth.zero?
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def descend(target, depth, authoritative: false)
|
|
251
|
+
@lookups += 1
|
|
252
|
+
name = Record.normalize_name(target.to_s)
|
|
253
|
+
return if name.empty?
|
|
254
|
+
|
|
255
|
+
if @sender.covers?(name)
|
|
256
|
+
if @matched.nil?
|
|
257
|
+
@matched = name
|
|
258
|
+
@matched_directly = depth.zero?
|
|
259
|
+
end
|
|
260
|
+
# The mechanism that ended the evaluation, and the budget it had spent
|
|
261
|
+
# by then — the only count §4.6.4 measures.
|
|
262
|
+
@match_at = @lookups
|
|
263
|
+
return # no need to resolve the sender's own record; we know what it authorizes
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Macro expansion (%{i} etc., §7) is per-message and cannot be evaluated
|
|
267
|
+
# here; the lookup is counted, the branch is simply not followed.
|
|
268
|
+
return if name.include?("%")
|
|
269
|
+
return if depth >= MAX_DEPTH
|
|
270
|
+
return unless @seen.add?(name) # loop guard (RFC 7208 §11.1 include loops)
|
|
271
|
+
return if over_budget?
|
|
272
|
+
|
|
273
|
+
records, err = spf_records_at(name)
|
|
274
|
+
@dns_error = true if err == :error
|
|
275
|
+
|
|
276
|
+
if records.size > 1
|
|
277
|
+
# A duplicate-record PERMERROR anywhere in the chain permerrors the
|
|
278
|
+
# whole evaluation for the receiver, so it fails the domain too.
|
|
279
|
+
records.each { |r| note_sender_names(Record.terms(r), depth + 1) }
|
|
280
|
+
@permerror ||= [:duplicate_records,
|
|
281
|
+
"#{name} (included by #{@host}) publishes more than one v=spf1 record, " \
|
|
282
|
+
"which makes the whole evaluation permerror (RFC 7208 §4.5)",]
|
|
283
|
+
return
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
return if records.first.nil?
|
|
287
|
+
|
|
288
|
+
walk(records.first, depth: depth + 1, authoritative: authoritative)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# [records_array, error_or_nil] — error is :error when DNS itself failed.
|
|
292
|
+
# More than one record is left for the caller to classify (RFC 7208 §4.5).
|
|
293
|
+
def spf_records_at(name)
|
|
294
|
+
txts = @lookup.call(name)
|
|
295
|
+
return [[], :error] if txts.nil?
|
|
296
|
+
|
|
297
|
+
[Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }, nil]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# A customer who lists the sender's addresses directly instead of
|
|
301
|
+
# including it is NOT equivalently authorized, and reporting that as a
|
|
302
|
+
# clean pass is a trap. Sending addresses move — relay nodes rotate,
|
|
303
|
+
# address ranges are added and retired — and the entire point of
|
|
304
|
+
# publishing an include target is that an include inherits those changes
|
|
305
|
+
# for free. A pinned record instead (a) silently stops authenticating the
|
|
306
|
+
# day an address moves, with no signal to anyone, and (b) keeps
|
|
307
|
+
# authorizing an address after the sender releases it, which may later
|
|
308
|
+
# belong to somebody else entirely. So: detect it, give it its own
|
|
309
|
+
# :pinned status, and tell them to switch.
|
|
310
|
+
def match_by_ip!
|
|
311
|
+
return if @customer_nets.empty?
|
|
312
|
+
|
|
313
|
+
ours = self.class.sending_nets(@sender, @lookup)
|
|
314
|
+
return if ours.empty?
|
|
315
|
+
|
|
316
|
+
# "Covered" = the customer's term authorizes one of the sender's ranges
|
|
317
|
+
# in full. "Touched" also counts a term that names only part of a range
|
|
318
|
+
# (one address out of a /24, say) — already-partial pinning, the most
|
|
319
|
+
# brittle shape of all, and the one most worth surfacing.
|
|
320
|
+
covered = ours.count { |o| @customer_nets.any? { |c, _at| covers?(c, o) } }
|
|
321
|
+
touched = ours.count { |o| @customer_nets.any? { |c, _at| covers?(c, o) || covers?(o, c) } }
|
|
322
|
+
return if touched.zero?
|
|
323
|
+
|
|
324
|
+
@ip_pinned = covered == ours.size ? :pinned_full : :pinned_partial
|
|
325
|
+
@ip_coverage = [touched, ours.size]
|
|
326
|
+
# An ip mechanism covering the sending address ends the receiver's
|
|
327
|
+
# evaluation right there (§4.6.2), so a pinned record is charged only
|
|
328
|
+
# the lookups spent BEFORE that term — a long chain sitting behind the
|
|
329
|
+
# pin never makes it permerror.
|
|
330
|
+
@ip_match_at = @customer_nets.filter_map do |c, at|
|
|
331
|
+
at if ours.any? { |o| covers?(c, o) || covers?(o, c) }
|
|
332
|
+
end.min
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def covers?(outer, inner)
|
|
336
|
+
outer.include?(inner)
|
|
337
|
+
rescue StandardError
|
|
338
|
+
false # mismatched families (ip4 vs ip6) etc.
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def pass
|
|
342
|
+
detail = if @matched_directly
|
|
343
|
+
"SPF includes #{@matched}"
|
|
344
|
+
else
|
|
345
|
+
"SPF reaches #{@matched} through its include chain (#{pluralize(@lookups, "DNS lookup")})"
|
|
346
|
+
end
|
|
347
|
+
result(:pass, detail: detail)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def pinned
|
|
351
|
+
touched, total = @ip_coverage
|
|
352
|
+
detail = if @ip_pinned == :pinned_full
|
|
353
|
+
"SPF lists #{@want}'s sending IP ranges directly (all #{total}) but never includes " \
|
|
354
|
+
"#{@want} — it authenticates today and stops silently the next time those IPs change"
|
|
355
|
+
else
|
|
356
|
+
"SPF lists #{@want}'s sending IPs directly and covers only #{touched} of the #{total} " \
|
|
357
|
+
"current ranges, and never includes #{@want} — mail from the rest already fails SPF"
|
|
358
|
+
end
|
|
359
|
+
# A pinned record's `matched` stays nil: the include is NOT published in it.
|
|
360
|
+
Result.new(status: :pinned, reason: @ip_pinned, detail: detail,
|
|
361
|
+
lookups: budget_used, all_qualifier: @all_qualifier, partial: @dns_error)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def failure(detail) = result(:fail, detail: detail)
|
|
365
|
+
def unknown(detail) = result(:unknown, detail: detail)
|
|
366
|
+
def permerror(reason, detail) = result(:permerror, detail: detail, reason: reason)
|
|
367
|
+
|
|
368
|
+
def result(status, detail:, reason: nil)
|
|
369
|
+
Result.new(status: status, reason: reason, detail: detail, matched: @matched,
|
|
370
|
+
lookups: budget_used, all_qualifier: @all_qualifier, partial: @dns_error)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# RFC 7208 §5.1: the terminal `all` decides what a receiver does with mail
|
|
374
|
+
# from a domain whose record does not authorize the sender. `-all` is an
|
|
375
|
+
# instruction to REJECT it; `~all` (much more common) only marks it. Same
|
|
376
|
+
# defect, materially different urgency — so say which one they published.
|
|
377
|
+
def all_clause
|
|
378
|
+
case @all_qualifier
|
|
379
|
+
when :fail then ". Its -all tells receivers to reject that mail outright"
|
|
380
|
+
when :softfail then ". Its ~all means receivers mark that mail rather than rejecting it"
|
|
381
|
+
else ""
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def pluralize(count, word)
|
|
386
|
+
"#{count} #{word}#{"s" unless count == 1}"
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mailertogo/spf/record"
|
|
4
|
+
require "mailertogo/spf/plan"
|
|
5
|
+
require "mailertogo/spf/authorization"
|
|
6
|
+
|
|
7
|
+
module MailerToGo
|
|
8
|
+
module SPF
|
|
9
|
+
# What a domain owner should PUBLISH for SPF, given what is ALREADY at that
|
|
10
|
+
# name in DNS.
|
|
11
|
+
#
|
|
12
|
+
# Why this exists: the standard setup instruction — "add a TXT record:
|
|
13
|
+
# v=spf1 include:_spf.mailertogo.net ~all" — is correct only for a domain
|
|
14
|
+
# with no SPF at all. A domain that already has SPF (Google Workspace, a
|
|
15
|
+
# registrar's default, Microsoft 365, another ESP) and follows that
|
|
16
|
+
# instruction literally ends up publishing a SECOND v=spf1 record beside the
|
|
17
|
+
# first. RFC 7208 §3.2 says a domain MUST NOT have more than one, and §4.5
|
|
18
|
+
# makes the pair a permerror — which does not merely fail to authorize the
|
|
19
|
+
# new sender, it breaks SPF for every sender the domain had. The customer's
|
|
20
|
+
# mail gets worse, and they did exactly what they were told.
|
|
21
|
+
#
|
|
22
|
+
# The only correct action for a domain that already has SPF is to MERGE the
|
|
23
|
+
# new mechanism into the existing record. So: before rendering the
|
|
24
|
+
# instruction, look at the name. If SPF is already there, hand them ONE
|
|
25
|
+
# merged record to REPLACE it with.
|
|
26
|
+
#
|
|
27
|
+
# Three things this is careful about, because each is a way to make a
|
|
28
|
+
# customer's mail worse rather than better:
|
|
29
|
+
#
|
|
30
|
+
# * Their `all` qualifier is THEIR policy (§5.1) — `-all` rejects
|
|
31
|
+
# unauthorized mail, `~all` only marks it. Carry it across verbatim
|
|
32
|
+
# rather than quietly relaxing (or tightening) what receivers do with
|
|
33
|
+
# mail nobody asked us about.
|
|
34
|
+
# * The new include goes LAST, immediately before the terminal `all`,
|
|
35
|
+
# because a mechanism after `all` is never evaluated (§5.1/§6.1).
|
|
36
|
+
# * Merging costs DNS lookups, and §4.6.4 caps an evaluation at 10. A
|
|
37
|
+
# record already near the cap can be pushed over it by one more include,
|
|
38
|
+
# and a record over the cap permerrors for everyone. Measure the merged
|
|
39
|
+
# record and say so, rather than handing over a line that breaks on
|
|
40
|
+
# arrival.
|
|
41
|
+
#
|
|
42
|
+
# Reads DNS, changes nothing. When DNS does not answer we fall back to the
|
|
43
|
+
# standalone instruction rather than guessing — better to under-help than to
|
|
44
|
+
# tell someone to replace a record we could not read.
|
|
45
|
+
class MergePlan
|
|
46
|
+
# A term that ends evaluation: `all`, with its optional qualifier, plus
|
|
47
|
+
# any junk glued onto it. The junk is real and surprisingly common —
|
|
48
|
+
# records ending `~all;google-site-verification=…` exist in the wild,
|
|
49
|
+
# where the `;`-joined fragment is not a valid SPF term at all.
|
|
50
|
+
ALL_TERM = /\A([+\-~?])?all([^a-z0-9].*)?\z/i
|
|
51
|
+
|
|
52
|
+
# A modifier (`redirect=`, `exp=`, or an unknown one) rather than a
|
|
53
|
+
# mechanism. Modifiers are position-independent (§4.6.1), so they survive
|
|
54
|
+
# the merge even when they trail the record's `all`.
|
|
55
|
+
MODIFIER_TERM = /\A([a-z][a-z0-9\-_.]*)=/i
|
|
56
|
+
|
|
57
|
+
# name — the DNS name the record goes at.
|
|
58
|
+
# record — the standalone record you would otherwise have told them
|
|
59
|
+
# to publish, e.g. Sender#record. It is what a :publish
|
|
60
|
+
# plan hands back; the merged line is built from `sender`.
|
|
61
|
+
# sender — a Sender: the names that mean "me".
|
|
62
|
+
# resolver — anything responding to #call(name); see Resolver.
|
|
63
|
+
# authorization — an already-resolved Result for this same name, when the
|
|
64
|
+
# caller has one. Saves walking the chain twice; nil means
|
|
65
|
+
# we resolve it ourselves.
|
|
66
|
+
# logger — optional, anything responding to #warn.
|
|
67
|
+
def self.call(name, record:, sender:, resolver:, authorization: nil, logger: nil)
|
|
68
|
+
new(name, record: record, sender: sender, resolver: resolver,
|
|
69
|
+
authorization: authorization, logger: logger).run
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(name, record:, sender:, resolver:, authorization: nil, logger: nil)
|
|
73
|
+
@name = Record.normalize_name(name)
|
|
74
|
+
@record = record.to_s
|
|
75
|
+
@sender = sender
|
|
76
|
+
@include_name = sender.include_name
|
|
77
|
+
@lookup = resolver
|
|
78
|
+
@authorization = authorization
|
|
79
|
+
@logger = logger
|
|
80
|
+
@notes = []
|
|
81
|
+
@all_qualifier = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run
|
|
85
|
+
txts = @lookup.call(@name)
|
|
86
|
+
return plan(:publish, resolved: false) if txts.nil? # DNS did not answer
|
|
87
|
+
|
|
88
|
+
records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
|
|
89
|
+
return plan(:publish) if records.empty?
|
|
90
|
+
|
|
91
|
+
# A single record that already reaches the sender — directly or through
|
|
92
|
+
# an outer alias — is healthy. Never rewrite a working record.
|
|
93
|
+
return plan(:satisfied, existing_records: records) if records.one? && authorized?
|
|
94
|
+
|
|
95
|
+
merged = merge(records)
|
|
96
|
+
|
|
97
|
+
# Nothing of ours to add (a single record that already names the sender
|
|
98
|
+
# but has an unrelated defect of its own — that is a different warning,
|
|
99
|
+
# not a merge instruction).
|
|
100
|
+
return plan(:satisfied, existing_records: records) if records.one? && same_terms?(merged, records.first)
|
|
101
|
+
|
|
102
|
+
budget = measure(merged)
|
|
103
|
+
plan(records.size > 1 ? :deduplicate : :merge,
|
|
104
|
+
existing_records: records,
|
|
105
|
+
merged_record: merged,
|
|
106
|
+
all_qualifier: @all_qualifier,
|
|
107
|
+
lookups: budget[:lookups],
|
|
108
|
+
over_lookup_limit: budget[:over_limit])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Does the currently-published record already authorize the sender? Uses
|
|
114
|
+
# the caller's result when it has one, so a diagnostics page does not walk
|
|
115
|
+
# the same chain twice.
|
|
116
|
+
def authorized?
|
|
117
|
+
result = @authorization ||
|
|
118
|
+
Authorization.call(@name, sender: @sender, resolver: @lookup, logger: @logger)
|
|
119
|
+
result.pass?
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
warn_failure("authorization check", e)
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Their mechanisms plus ours, in ONE record.
|
|
126
|
+
#
|
|
127
|
+
# Record order is deliberate: records that are NOT ours come first,
|
|
128
|
+
# because theirs is the policy record and its `all` is the one to
|
|
129
|
+
# preserve. Within a record, terms keep their published order — an SPF
|
|
130
|
+
# record's mechanisms are evaluated in order, and a customer may well have
|
|
131
|
+
# put a cheap ip4 first on purpose.
|
|
132
|
+
def merge(records)
|
|
133
|
+
theirs, ours = records.partition { |r| !ours?(r) }
|
|
134
|
+
mechanisms = []
|
|
135
|
+
modifiers = []
|
|
136
|
+
@all_qualifier = nil
|
|
137
|
+
|
|
138
|
+
(theirs + ours).each do |record|
|
|
139
|
+
seen_all = false
|
|
140
|
+
|
|
141
|
+
Record.terms(record).each do |term|
|
|
142
|
+
if (m = ALL_TERM.match(term))
|
|
143
|
+
seen_all = true
|
|
144
|
+
# First `all` across the ordered records wins — theirs, not ours.
|
|
145
|
+
# A bare `all` is `+all` (§4.6.2); spell it out so the merged line
|
|
146
|
+
# says plainly what it does.
|
|
147
|
+
@all_qualifier ||= m[1] || "+"
|
|
148
|
+
if m[2] && !m[2].strip.empty?
|
|
149
|
+
@notes << "Dropped #{m[2].strip.inspect}, which was glued onto your #{m[1]}all " \
|
|
150
|
+
"and isn't a valid SPF term — publish it as its own TXT record if you still need it."
|
|
151
|
+
end
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if MODIFIER_TERM.match?(term)
|
|
156
|
+
# redirect=/exp= are modifiers, not mechanisms: they apply to the
|
|
157
|
+
# whole record wherever they sit, so keep them (deduped by
|
|
158
|
+
# modifier name).
|
|
159
|
+
key = MODIFIER_TERM.match(term)[1].downcase
|
|
160
|
+
modifiers << term unless modifiers.any? { |t| MODIFIER_TERM.match(t)[1].casecmp?(key) }
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if seen_all
|
|
165
|
+
# Unreachable in the published record (nothing after `all` is ever
|
|
166
|
+
# evaluated). Dropping it preserves the record's exact behavior;
|
|
167
|
+
# keeping it would newly authorize a sender receivers ignore today.
|
|
168
|
+
@notes << "Dropped #{term.inspect}, which sat after your #{@all_qualifier}all and was never evaluated."
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
next if sender_term?(term)
|
|
173
|
+
|
|
174
|
+
mechanisms << term unless mechanisms.any? { |t| t.casecmp?(term) }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Ours goes last — right before the terminal `all`, which matches
|
|
179
|
+
# everything and ends evaluation.
|
|
180
|
+
terms = mechanisms + ["include:#{@include_name}"]
|
|
181
|
+
terms << "#{@all_qualifier}all" if @all_qualifier
|
|
182
|
+
terms += modifiers
|
|
183
|
+
"v=spf1 #{terms.join(" ")}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The merged line's real cost to a receiver: evaluate it exactly as a
|
|
187
|
+
# published record would be evaluated, and let §4.6.4 apply. Our include
|
|
188
|
+
# sits last, so the budget reported is the budget the sender's own mail
|
|
189
|
+
# spends.
|
|
190
|
+
def measure(merged)
|
|
191
|
+
result = Authorization.call(@name, sender: @sender, resolver: @lookup,
|
|
192
|
+
published: merged, logger: @logger)
|
|
193
|
+
if result.unknown? || result.partial?
|
|
194
|
+
# A resolver hiccup mid-chain: the count is a floor, not the cost.
|
|
195
|
+
# Report nothing rather than a budget we could not finish measuring —
|
|
196
|
+
# and "it fits" would be the dangerous direction to get wrong.
|
|
197
|
+
{ lookups: nil, over_limit: false }
|
|
198
|
+
else
|
|
199
|
+
{ lookups: result.lookups, over_limit: result.permerror? && result.reason == :lookup_limit }
|
|
200
|
+
end
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
warn_failure("lookup budget", e)
|
|
203
|
+
{ lookups: nil, over_limit: false }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Does this record name the sender directly? Only a direct term counts
|
|
207
|
+
# here — this decides which record supplies the merged `all`, and a record
|
|
208
|
+
# that reaches the sender through somebody else's include is still THEIR
|
|
209
|
+
# policy record.
|
|
210
|
+
def ours?(record)
|
|
211
|
+
Record.terms(record).any? { |term| sender_term?(term) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def sender_term?(term)
|
|
215
|
+
t = Record.strip_qualifier(term)
|
|
216
|
+
target = t[/\Ainclude:(.+)\z/i, 1] || t[/\Aredirect=(.+)\z/i, 1]
|
|
217
|
+
!target.nil? && !target.empty? && @sender.covers?(target)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def same_terms?(a, b)
|
|
221
|
+
a.to_s.split(/\s+/).map(&:downcase) == b.to_s.split(/\s+/).map(&:downcase)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def warn_failure(what, error)
|
|
225
|
+
@logger&.warn("[MailerToGo::SPF] #{what} for #{@name} failed: #{error.message}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def plan(action, **attrs)
|
|
229
|
+
Plan.new({
|
|
230
|
+
action: action,
|
|
231
|
+
name: @name,
|
|
232
|
+
record: @record,
|
|
233
|
+
include_name: @include_name,
|
|
234
|
+
existing_records: [],
|
|
235
|
+
merged_record: nil,
|
|
236
|
+
all_qualifier: nil,
|
|
237
|
+
lookups: nil,
|
|
238
|
+
lookup_limit: Authorization::MAX_DNS_LOOKUPS,
|
|
239
|
+
over_lookup_limit: false,
|
|
240
|
+
resolved: true,
|
|
241
|
+
notes: @notes,
|
|
242
|
+
}.merge(attrs))
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|